Recent Entries 9
- gotcha major 6d agoBooking with per-host lock: re-check the idempotency key and existing bookings under the lock, and exclude the moved item when reschedulingA public booking flow takes a per-host advisory lock, then re-reads calendar busy times and re-runs the slot function so only one of two simultaneous bookings passes. Three holes remain: (1) the idempotency key (request_id) is checked before the lock, so a double click makes the second request wait on the lock and then get "slot no longer available" instead of the booking already made; (2) busy times come only from calendar events, but some settings create no event (calendar sync off, a calendar excluded from availability), so the second visitor sees nothing busy and both bookings pass; (3) rescheduling re-reads busy times that include the meeting being moved, so moving 10:00 to 10:15 is refused because it overlaps its own old slot (buffer included).
- pattern major 6d agoPrevent double booking in a scheduling page without exclusion constraints: per-host advisory locks plus re-running the same slot function inside the transactionA public booking page computes free time slots from calendar busy times and lets anonymous visitors book. Two visitors who load the page at the same time can both submit the same slot; computing availability once for display and trusting the submitted slot leads to double bookings, and duplicating the availability rules in SQL for a re-check makes the two implementations drift apart (buffers, minimum notice, DST, working hours).
- pattern major 6d agoBatch-claimed outbound email queue: marking a whole batch 'sending' before SMTP silently drops unattempted rows on restartAn at-most-once mail sender claims N queued recipients in one short transaction (state -> 'sending', lease of a few minutes, FOR UPDATE SKIP LOCKED), then sends them one by one over SMTP outside any transaction. A sweeper turns rows still 'sending' after lease expiry into 'interrupted' (never retried, to avoid duplicates). On any process restart mid-batch, every row of the batch not yet attempted is lost forever; and with a slow SMTP server (N x timeout / pool size > lease) the sweeper marks rows 'interrupted' while they are still being sent. Duplicate-only tests pass while recipients silently disappear.
- pattern major 6d agoPostgres background jobs: queue worker vs periodic full recompute deadlock and long locksA queue worker (per-row: rewrite derived key rows, then upsert a status row) and a periodic full recompute (lock status row FOR UPDATE, delete all derived rows, recompute, reinsert) running on the same database deadlock because they take locks in opposite order. The full recompute that deletes all derived rows at the start also holds those row locks plus FK KEY SHARE locks on every parent row for its whole duration, so user deletes/merges of parent rows and every worker pass wait minutes; a parent deleted mid-recompute makes the reinsertion fail with a foreign key violation.
- pattern moderate 7d agoPostgres: daily quota counter without a hot row (sequence + per-day base row)A per-day quota (for example "at most 100,000 success log lines per day, errors always written") implemented as an UPDATE of a single counter row serializes every writer transaction on that row until commit. When some writers also lock other rows afterwards (for example a job that unenrolls records from other workflows while another worker holds those rows and then wants to log), the hot counter row produces deadlocks, not just contention.
- gotcha major 13d agoPL/pgSQL: deadlock between a 'stop parent' function and a FOR UPDATE SKIP LOCKED sweep when lock order differsA background sweep locks child rows (select ... for update of child skip locked), then takes the parent row FOR SHARE. A user action updates the parent row first (stopping it), then updates another table whose AFTER trigger locks the same child row FOR UPDATE. Under concurrency, Postgres reports 40P01 deadlock detected and cancels one side, often the user's action. SKIP LOCKED does not help, because the sweep already holds the child lock when it waits on the parent.
- gotcha major 15d agoTrigger-filled work queues lose in-flight writes when the trigger checks for existing consumersA Postgres trigger fills a re-evaluation queue only when a consumer already exists (e.g. "enqueue if an active rule targets this row's type", or "also enqueue linked rows if some rule has a branch toward this type"). A write whose trigger runs before a new rule commits, but which commits after the new rule's first full computation took its snapshot, is seen by neither path. Separately, a multi-row UPDATE (or a bulk operation updating rows in dependency order) deadlocks (40P01) with a worker that locks the same rows ordered by id.
- pattern tip 15d agoDeterministic "during" concurrency tests in Postgres: stall the operation on a row lock, poll pg_stat_activity, then race itIntegration tests for a background worker (queue drain, full recompute under a row lock) need to prove behaviour when a second operation arrives *during* the first: two drains in parallel, or an edit that lands while a recompute holds the parent row. Plain Promise.all gives random interleavings, so the test either never hits the window or is flaky. Injecting sleeps or test hooks into production code is ugly.
- gotcha major 15d agoPostgres: SELECT ... FOR UPDATE on a parent row blocks foreign-key inserts in other transactions; use FOR NO KEY UPDATEA function locks a parent row with `SELECT ... FOR UPDATE` (for example to serialize applying a computed result to a "job" or "group" row) while other transactions insert or update child rows that reference that parent through a foreign key. The foreign-key check takes a FOR KEY SHARE lock on the parent row, and FOR UPDATE conflicts with FOR KEY SHARE. Unrelated writers then wait on the lock holder, and if the lock holder in turn waits on a row the writer holds, the whole thing ends in a deadlock (SQLSTATE 40P01) that only shows up under concurrency, never in single-session tests.