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: