Skip to main content

03 — Availability Management

Overview​

The availability system is a precomputed bitmask cache. Slots are not queried in real time against the appointments table. Instead, a background job periodically rebuilds a bitmask matrix for every venue and date, which is then read by FinderOverMatricesService during booking.

This design is fast but introduces a staleness window that is the root cause of several double-booking risks.


Data model​

  • VenueAvailabilityStaticContent — venue-wide settings: opening hours, slot duration, online-availability flags. Changes infrequently.
  • VenueAvailabilityDynamicContent — daily bitmask matrix encoding free/occupied slots for every staff member and workstation at a venue on a given date. Each bit = one slot granule (e.g. 15 min). 0 = occupied, 1 = free.
  • AvailabilityVector (uala-core/lib/uala/availability_vector.rb) — in-memory wrapper that decodes the bitmask, supports has_availability_for? and occupy!. It is transient — computed per request, never persisted back.

Computation pipeline​

What triggers ComputeVenueAvailabilityJob?​

TriggerScopeNotes
Appointment state change (after_commit)Single venue, single dateVia Uala::Sync callbacks
Timetable / opening hours changeSingle venue, all dates
Staff member changeSingle venue, all dates
Scheduled cron (nightly)All venues, 30-day windowBulk recompute
Manual rake taskConfigurableFor data repair

Key observation: The job is enqueued after_commit on appointment changes, not synchronously inside the booking transaction. Between when the appointment is committed and when the job completes, the matrix is stale.


Staleness window​

The lock on ComputeVenueAvailabilityJob has a 30-minute TTL:

# compute_venue_availability_job.rb:7-11
unique :until_and_while_executing,
lock_ttl: 30.minutes,
runtime_lock_ttl: 30.minutes,
on_conflict: :log,
on_runtime_conflict: ->(job) { on_runtime_conflict(job) }

In the worst case (job enqueued but lock already held for another venue recompute), a newly booked slot can remain visible as free in the matrix for up to 30 minutes.


Request-time availability check​

replacing_appointments_ids is used during reschedule to un-occupy the current slot in the vector, so it correctly appears free for the new booking at the same venue.

Second validation pass (_validate_availabilities)​

After FinderOverMatricesService returns slots and appointment drafts are built, BookingService#_validate_availabilities performs a second check by calling staff_member.has_room_for? and workstation.has_enough_space? directly.

These methods also operate on the precomputed availability vector (loaded from DB on StaffMember/Workstation), not on live appointment data. They add no safety compared to the first check — they read the same stale matrix, just through a different code path.

There is no check against the live appointments table at any point in the booking flow.


Missing: real-time slot guard​

The system has no mechanism to atomically:

  1. Check a slot is free
  2. Reserve it
  3. Let the booking proceed

The closest pattern in the codebase is the pessimistic lock used in CommissionUpdateService:

# commission_update_service.rb:19
appointment.with_lock do
# safe to update
end

This pattern has not been applied to the booking creation path.

What a safe pattern would look like​

Option A — DB advisory lock per (venue, staff_member, slot):

slot_key = [venue_id, staff_member_id, slot_time.to_i].hash
ActiveRecord::Base.connection.execute("SELECT pg_try_advisory_xact_lock(#{slot_key})")
# proceed only if lock acquired

Option B — Redis distributed lock:

lock_key = "slot:#{venue_id}:#{staff_member_id}:#{slot_time.to_i}"
Redlock::Client.new([redis]).lock!(lock_key, 5_000) do
# validate + save
end

Option C — DB unique constraint on (staff_member_id, time) as a last-resort guard (does not prevent the attempt but guarantees at most one appointment wins).


Risk summary for availability management​

RiskSeverityRoot cause
Stale matrix allows double-bookingP0No real-time slot guard; matrix not invalidated synchronously
Up to 30-min staleness windowP1ComputeVenueAvailabilityJob lock TTL
Second validation reads same stale dataP1has_room_for? uses cached vector, not live appointments
No DB constraint on (staff_member_id, time)P1Missing uniqueness constraint — no last-resort protection
online_available_only bypass riskP2Staff member or VenueTreatment flag could be stale