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, supportshas_availability_for?andoccupy!. It is transient — computed per request, never persisted back.
Computation pipeline
What triggers ComputeVenueAvailabilityJob?
| Trigger | Scope | Notes |
|---|---|---|
Appointment state change (after_commit) | Single venue, single date | Via Uala::Sync callbacks |
| Timetable / opening hours change | Single venue, all dates | |
| Staff member change | Single venue, all dates | |
| Scheduled cron (nightly) | All venues, 30-day window | Bulk recompute |
| Manual rake task | Configurable | For data repair |
Key observation: The job is enqueued
after_commiton 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:
- Check a slot is free
- Reserve it
- 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
| Risk | Severity | Root cause |
|---|---|---|
| Stale matrix allows double-booking | P0 | No real-time slot guard; matrix not invalidated synchronously |
| Up to 30-min staleness window | P1 | ComputeVenueAvailabilityJob lock TTL |
| Second validation reads same stale data | P1 | has_room_for? uses cached vector, not live appointments |
| No DB constraint on (staff_member_id, time) | P1 | Missing uniqueness constraint — no last-resort protection |
online_available_only bypass risk | P2 | Staff member or VenueTreatment flag could be stale |