Nobody has started this yet — be first.
Business impact
This is the mechanism the README explicitly credits for making commit idempotent under real concurrency: "the FOR UPDATE row lock on the reservation serializes concurrent commit calls for the same id." Without it, two callers racing to resolve the same reservation -- a retried payment webhook and a manual support action landing at the same instant, or two webhook deliveries from a payment provider's own at-least-once retry policy -- can both read status: active before either has written anything, and both apply their own transition. For two concurrent commits, that means sold incrementing twice for a single reservation: a real double-sell of the same unit, silently, with no error to anyone.
Problem
LockReservation's SELECT is missing FOR UPDATE. A second, concurrently-open transaction locking the same reservation id does not wait for a first, still-open transaction -- it proceeds immediately.
Current behavior
A second, concurrently-open transaction's LockReservation call returns immediately instead of blocking behind a first transaction's still-open lock on the same reservation id.
Expected behavior
A second, concurrently-open transaction calling LockReservation for the same reservation id should block until the first one commits or rolls back, so it observes whatever terminal status the first one actually left behind.
Steps to reproduce
go test ./tickets/tests/... -run TestTicket25 -vWhy this matters
Unlike ticket-24's SKIP LOCKED (which never blocks), plain FOR UPDATE does block -- so genuinely proving this requires observing that blocking behavior, not just counting how many times something got double-applied under real goroutine timing. This ticket's test drives LockReservation (exported, and the exact function resolve itself calls) from one transaction held open in the main goroutine and a second one attempted in a background goroutine, and asserts directly on the blocking.
Suggested approach
Compare LockReservation's SELECT against SelectDueReservations in pkg/inventory/reaper.go (ticket-24, right next door) -- both are claiming queries, but this one needs FOR UPDATE alone (block and wait, since a resolve should still eventually run once the first one finishes), not FOR UPDATE SKIP LOCKED (skip and move on, which is right for a reaper's batch poll but wrong here).
Acceptance criteria
Verification
TEST_DATABASE_URL=postgresql://... go test ./tickets/tests/... -run TestTicket25 -v -count=5
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
Sign in to create it automatically, or do it yourself:
$git checkout -b fix/lock-reservation-missing-for-updateFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin fix/lock-reservation-missing-for-updateSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign inSKIP LOCKEDhttp://localhost:8081localhost:5434flash-saleeveryday-widgetcurl localhost:8081/healthzgo run ./cmd/loadtest -url http://localhost:8081 -sku flash-sale -concurrency 10000Work the tickets in tickets/docs/ (ordered easy → hard); each names one dedicated Go test in tickets/tests/:
export TEST_DATABASE_URL=postgresql://inventory:inventory@localhost:5432/inventory_test?sslmode=disable
go test ./tickets/tests/... -run TestTicket24 -v # a single ticket
./tickets/run_tickets.sh # all 25, clean pass/fail summary
Tickets that don't touch Postgres (02, 07, 17, 19) run fine without TEST_DATABASE_URL; the rest t.Skip cleanly, not fail, if it's unset.
Level 1
Fix a bug
Read existing behaviour, correct it.