feat(backend): concurrency-safe appointment booking API with slot reservation (#24) - #37
Merged
meshackyaro merged 1 commit intoAug 7, 2026
Conversation
…tion
Booking could double-book a worker: POST /api/v1/client/bookAppointment
inserted an appointment unconditionally, so two clients posting the same
skilledWorkerId and scheduleTime at the same moment both succeeded. There
was also no way to read a worker's calendar — viewAllAppointment returns
only the calling client's own bookings — so a booking UI could not render
availability at all.
Add a slot_reservations table and a reserve -> confirm flow under
/api/v1/booking. Double-booking is prevented by three layers:
1. SELECT ... FOR UPDATE on the worker's row, taken before anything is
read. This is the guarantee: an overlap query run before the lock
reads a pre-write snapshot, which is exactly how two concurrent
bookings both conclude a slot is free.
2. A half-open [start, end) overlap check under that lock, which catches
partial overlaps no equality constraint can express.
3. A unique index on active_slot_key (NULL once the slot is freed, and
Postgres treats NULLs as distinct) as a backstop, so a future path
that forgets the lock fails loudly instead of double-booking.
Holds last 5 minutes, matching the TTL guildworkman-web's client-side lock
already used, and are reclaimed both inline on the reserve path and by a
background sweep. reserve is idempotent on a caller-supplied key, including
for concurrent retries, which would otherwise lose the race against
themselves and get a spurious 409.
Every path that changes a worker's time goes through the same guard:
the one-step bookAppointment endpoint, rescheduling via updateAppointment
(excluded from its own conflict check, since it still carries its old
scheduleTime), and cancel/decline/delete, which give the slot back.
Add GET /api/v1/booking/workers/{id}/availability, reporting booked
appointments and live holds. It unions reservations with appointments that
predate this feature, which have no reservation row but still occupy the
calendar.
Losing a race is 409 slot-unavailable via the existing RFC 7807 contract.
Also realign the identity sequences in db/data.sql. It seeds hard-coded
ids (skilled_workers 201-204, clients 301-304) without advancing the
sequences, so any later test that lets Hibernate generate an id hits a
duplicate-key violation once the counter walks into that range.
Closes workman-labs#24
balisdev
force-pushed
the
feat/24-concurrency-safe-booking
branch
from
August 7, 2026 17:01
cbea1b6 to
21187a0
Compare
meshackyaro
approved these changes
Aug 7, 2026
meshackyaro
left a comment
Contributor
There was a problem hiding this comment.
Well done and thanks for your contribution
13 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #24
The problem
Two gaps, both visible from the frontend side of this issue.
Nothing stopped a double-booking.
POST /api/v1/client/bookAppointmentinserted an appointment unconditionally. Two clients posting the sameskilledWorkerIdandscheduleTimeat the same moment both succeeded, and the worker found out later.Nothing could see a worker's calendar.
GET /api/v1/client/viewAllAppointmentreturns the calling client's own bookings, so a booking UI had no way to ask what is already taken on a worker's calendar. The calendar in guildworkman-web#34 worked around that with a client-side lock (localStorage+BroadcastChannel, 5-minute TTL) insrc/lib/slotLock.ts— which stops one visitor double-booking themselves across tabs, but cannot stop two different visitors racing, because neither browser can see the other's state.How double-booking is prevented
A new
slot_reservationstable and a reserve → confirm flow under/api/v1/booking. Three layers, in order — the first is the guarantee, the other two exist so it stays true as the code changes.1. A per-worker lock, taken before anything is read.
SELECT id FROM skilled_workers WHERE id = ? FOR UPDATE. The ordering is the entire point: an overlap query run before the lock reads a snapshot from before the competing writer committed, which is exactly how two concurrent bookings both conclude a slot is free. Taking the lock first means the second request blocks inside Postgres until the first commits, then re-checks and sees what the winner took.Per worker, so different workers never contend. Held across one query and one insert, with no network calls inside the critical section. Cannot deadlock — a booking locks exactly one worker row and never a second.
2. A half-open
[start, end)overlap check, under that lock. This is what catches partial overlaps (10:00–11:00 vs 10:30–11:30), which no equality-based constraint can express. Half-open means back-to-back slots are fine.3. A unique index as a backstop.
active_slot_keyholds"<workerId>@<slotStart>"while a reservation occupies the slot and isNULLthe moment it stops; Postgres treatsNULLs in a unique index as distinct, so any number of released rows can coexist for one slot while at most one active row can. Under the lock it should never fire — it's there so a future path that forgets the lock is rejected by the database instead of silently double-booking.Why pessimistic rather than optimistic locking: an optimistic/CAS scheme needs an existing row to contend over. The race here is between two inserts — there is no shared row whose
@Versioncould clash, so a version check has nothing to fail on.Holds
A hold is authoritative and short-lived (5 minutes, matching the TTL the web calendar already used). It's what lets a visitor fill in booking details without racing anyone. Reclaimed two ways, neither the only line of defence: inline on the reserve path (under the lock, before the overlap check — so a slot is never blocked by a dead hold regardless of when the sweep ran) and by a background sweep every 30s, which claims rows
FOR UPDATE ... ORDER BY idso two app instances can't process the same row or deadlock.reserveis idempotent on a caller-supplied key, including the subtle case: a concurrent retry queues behind the original on the worker lock and would otherwise be told it lost a race against itself.SlotReservationService.reservere-reads the key when a claim fails and replays the winner if the row turns out to be the caller's own; anything else stays a 409. It's deliberately not@Transactional— a unique violation marks the surrounding transaction rollback-only, so the recovery read has to happen after it ends.Every path that changes a slot goes through the guard
A guard that only covers creation isn't a guard. So:
bookAppointment(the one-step endpoint) claims through the same lock and overlap check, writing a reservation that'sCONFIRMEDfrom the start. The claim happens before the appointment is saved — not just for early failure, but because an appointment saved first shows up in its own overlap query and conflicts with itself.updateAppointmentwith astartTimeis a booking of a different slot, so it claims the new one and gives the old one back. The appointment is excluded from its own conflict check (it still carries its oldscheduleTime, so a 10:00 → 10:30 shift would otherwise conflict with itself). Fails 409 if the new time is taken, and the whole update rolls back rather than half-applying.updateAppointmenttoCANCELLED/DECLINED,cancelAppointment,deleteAppointmentrelease the reservation — otherwise a declined job would block the worker's time forever.Bookings with no
skilledWorkerIdare unguarded, because there's no calendar to double-book.The availability endpoint
GET /api/v1/booking/workers/{workerId}/availability?from=&to=— the counterpart toviewAllAppointmentthe calendar was missing.It reports what is taken, not what is free: free time is whatever a worker's published working hours leave over, which is a frontend (and future working-hours feature) concern. Live holds come back as
state: "HELD"with aholdExpiresAt, so a calendar can distinguish "booked" from "someone is mid-checkout".It unions reservations with appointments that predate this feature — those have no reservation row but still occupy the calendar. An
Appointmentstores only ascheduleTime, so each legacy row is treated as occupying[scheduleTime, scheduleTime + slot-duration). A slot already covered by aCONFIRMEDreservation is reported once, from the reservation.Endpoints
POST/api/v1/booking/reservationsX-Idempotent-Replayheader)GET/api/v1/booking/reservations/{id}POST/api/v1/booking/reservations/{id}/confirmDELETE/api/v1/booking/reservations/{id}GET/api/v1/booking/workers/{id}/availabilityAll documented in OpenAPI. Errors follow the existing RFC 7807 contract — losing a race is
409 slot-unavailable, not a400: the request was well-formed, it just arrived second.Schema
No migration file, and none needed — this codebase has no Flyway/Liquibase and manages schema via
ddl-auto=update.slot_reservationsis declared as JPA annotations like every other table here, same as #21 and #22 did for their new tables. It's an entirely new table, so the first deploy creates it and every deploy after is a no-op for it; no existing table or column is altered.Two things worth flagging
Booking is public.
/api/v1/booking/**mirrors the flow it replaces:/api/v1/client/bookAppointmentis already public, and a calendar has to read taken slots before anyone signs in. Tightening the booking surface means tightening the client surface with it — a deliberate auth-scope change rather than something to fold into a concurrency fix. Noted as a follow-up in the docs.One unrelated fix.
db/data.sql(used bySkilledWorkerServiceTestvia@Sql) seeds hard-coded ids —skilled_workers201-204,clients301-304 — without realigning the identity sequences. PostgresGENERATED BY DEFAULT AS IDENTITYcolumns don't advance their sequence for an explicit id, andTRUNCATEwithoutRESTART IDENTITYdoesn't reset it, so any later test that lets Hibernate generate an id gets a duplicate-key violation once the counter walks into the seeded range. The booking tests hit it because they create their own workers and clients. The script now ends withsetval(pg_get_serial_sequence(...), MAX(id))per seeded table. It was previously invisible only because CI starts from an empty database each run.Testing
166 tests pass;
./mvnw verifyand./mvnw testare green against a freshpostgres:16-alpine, and the suite was also run twice consecutively against a dirty database to check it isn't order-dependent.SlotReservationIntegrationTest(28) runs against a real Postgres, because that's the only place the guarantee lives — a mocked repository would happily "pass" while double-booking in production. Covers the 12-thread race for one slot (exactly one winner, eleven 409s), the same race throughbookAppointment, 8 concurrent retries of one request taking a single hold, per-worker isolation, partial and adjacent overlaps, legacy appointments, expiry, cancel/decline/reschedule, and the availability read.BookingControllerTest(13) — HTTP contract: status codes, the replay header, problem-JSON shape for every failure.SlotReservationServiceTest(12) — replay, race recovery, confirm/release state checks, mocked.ReserveSlotRequestValidationTest(7) — bean-validation rules.CI already caches Maven dependencies via
setup-java'scache: mavenin.github/workflows/test.yml, so that task was already satisfied. The sweep's poll delay is pinned high in the Surefire config alongside the existing chain-event and escrow pollers, so it can't race tests through the shared test database.New dependencies
None.
Full design write-up:
backend-api/docs/APPOINTMENT_BOOKING.md.Follow-ups (out of scope)
src/lib/slotLock.tsin guildworkman-web once the calendar reads/availabilityand reserves through this API.EXCLUDE USING gistconstraint would make overlaps impossible at the schema level rather than under an application-held lock, butddl-auto=updatecan't express it — needs the Flyway discussion the escrow doc already raises.