Resolving Race Conditions in the JADA Booking Calendar: A Case Study in Async State Management
One of the more subtle but critical issues we've encountered in the JADA event booking system is a race condition in jadaOpenBook() that occurs when availability data fetches complete asynchronously. This post walks through the problem, our diagnosis, and the architectural fix we implemented.
The Problem: Non-Blocking Availability Fetches
The JADA booking calendar integration handles real-time event availability across multiple suppliers (Viator, GetYourGuide, Ticketmaster). When a user initiates a booking, jadaOpenBook() was triggering availability fetches without waiting for them to complete before returning control to the UI layer.
In practice, this meant:
- The booking modal would render with stale or missing availability data
- Users could select time slots that appeared available but were actually already booked
- Stripe payment processing would sometimes proceed with incorrect inventory state
- Calendar sync back to Google Calendar would reflect outdated event counts
The root cause was architectural: jadaOpenBook() in tools/jada_booking.gs was initiating async fetch calls to supplier APIs but not enforcing a completion guarantee before the function returned.
Technical Diagnosis and Root Cause
Our Google Apps Script execution model made this particularly tricky. GAS has a 6-minute execution timeout and doesn't have native promises in older runtimes. The booking flow looked roughly like this:
function jadaOpenBook(eventId) {
// Fetch availability (fire and forget)
fetchViatorAvailability_(eventId);
fetchGetYourGuideAvailability_(eventId);
// Render UI immediately — data not ready yet
renderBookingModal_(eventId);
return true;
}
The fix required enforcing a synchronization barrier: jadaOpenBook() must block until all supplier availability calls complete and write their results to the shared cache (stored in PropertiesService under key jada_availability_${eventId}).
Implementation: Synchronization with Poll-and-Wait
We refactored jadaOpenBook() to use a poll-and-wait pattern since GAS doesn't support true async/await in all runtime environments:
function jadaOpenBook(eventId) {
// Clear stale cache entry
const cacheKey = `jada_availability_${eventId}`;
PropertiesService.getUserProperties().deleteProperty(cacheKey);
// Kick off all three supplier fetches in parallel
const fetchPromises = [
fetchViatorAvailability_(eventId),
fetchGetYourGuideAvailability_(eventId),
fetchTicketmasterAvailability_(eventId)
];
// Block until all fetches complete or timeout
const allComplete = waitForAvailabilityFetches_(eventId, 30000); // 30s timeout
if (!allComplete) {
Logger.log(`WARNING: Availability fetch incomplete after 30s for event ${eventId}`);
// Fall back to cached data or error state
}
// Now safe to render — all availability data is in PropertiesService
renderBookingModal_(eventId);
return true;
}
function waitForAvailabilityFetches_(eventId, timeoutMs) {
const startTime = new Date().getTime();
const cacheKey = `jada_availability_${eventId}`;
const props = PropertiesService.getUserProperties();
while (new Date().getTime() - startTime < timeoutMs) {
const cached = props.getProperty(cacheKey);
if (cached) {
const data = JSON.parse(cached);
// Check that we have data from all three suppliers
if (data.viator && data.getYourGuide && data.ticketmaster) {
return true;
}
}
Utilities.sleep(500); // Poll every 500ms
}
return false; // Timeout reached
}
Each supplier fetch function was modified to write its results atomically to PropertiesService using a merge pattern to avoid overwriting concurrent writes:
function fetchViatorAvailability_(eventId) {
try {
const response = UrlFetchApp.fetch(
`https://api.viator.com/partner/availability?eventId=${eventId}`,
{ headers: { 'Authorization': 'Bearer ' + getViatorToken_() } }
);
const data = JSON.parse(response.getContentText());
// Atomic merge into cache
updateAvailabilityCache_(eventId, 'viator', data);
} catch (e) {
Logger.log(`Viator fetch failed: ${e}`);
updateAvailabilityCache_(eventId, 'viator', { error: e.toString() });
}
}
function updateAvailabilityCache_(eventId, supplier, data) {
const cacheKey = `jada_availability_${eventId}`;
const props = PropertiesService.getUserProperties();
const existing = props.getProperty(cacheKey);
const merged = existing ? JSON.parse(existing) : {};
merged[supplier] = data;
merged.lastUpdate = new Date().toISOString();
props.setProperty(cacheKey, JSON.stringify(merged));
}
Infrastructure: Cache Layer and TTL Management
We're using PropertiesService (user-level, not document-level) for this cache because:
- Scope isolation: Each user session gets its own availability snapshot
- Fast access: No network round-trip required (unlike Firestore)
- Atomic writes: GAS handles serialization internally
- Cleanup: Data expires naturally after 7 days
We added a cleanup job in the jada-agent daemon (running on Lightsail, controlled via cron) that removes stale entries older than 24 hours:
#!/bin/bash
# Run daily via crontab: 0 3 * * * /home/jada-agent/cleanup_jada_cache.sh
python3 /home/jada-agent/tools/prune_jada_availability_cache.py \
--older-than 86400 \
--project sailjada-prod \
--dry-run false
Stripe Integration Impact
The payment processing layer in lib/stripe_checkout.gs was also updated to validate availability state before creating a Stripe session:
function createStripeCheckoutSession_(eventId, quantity) {
const availability = getCachedAvailability_(eventId);
if (!availability || !availability.ticketmaster) {
throw new Error('Availability data missing — booking window may have closed');
}
const remaining = availability.ticketmaster.remaining || 0;
if (remaining < quantity) {
throw new Error(`Only ${remaining} tickets available, requested ${quantity}`);
}
// Safe to proceed with payment
// ...
}
Deployment and Testing
The changes to tools/jada_booking.gs were staged in the development project and tested with the E2E test suite: