Repository navigation
Serialize concurrent item replaces per user - #63
tobiashochguertel wants to merge 1 commit into
Conversation
Two overlapping PUT /items (or /items/archive) requests for the same user could both run the DELETE and then insert the same keys. Under READ COMMITTED the second transaction does not see the rows the first one inserted, so it failed with a unique-constraint violation and returned a 500. replace_items now takes a per-user transaction-level advisory lock before the DELETE, so replaces for the same user run one after another. Adds an ignored regression test that needs PostgreSQL (TB_TEST_DATABASE_URL). Co-Authored-By: Tobias Hochgürtel <tobias.hochguertel@googlemail.com>
There was a problem hiding this comment.
🟢 Approval recommended
The lock correctly scopes serialization per user and the regression test exercises the reported race.
0 open findings
What changed in this PR
Adds per-user PostgreSQL advisory locking to prevent concurrent item replacements from causing duplicate-key failures.
Changes:
- Serializes active and archived item replacements per user.
- Adds an ignored PostgreSQL concurrency regression test.
| File | Description |
|---|---|
crates/taskbook-server/src/handlers/items.rs |
Adds transactional advisory locking and concurrency coverage. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Friendly ping — this serializes concurrent |
|
Gentle follow-up — still current with |
Summary
PUT /api/v1/itemsandPUT /api/v1/items/archivecallreplace_items. It runsDELETE FROM items WHERE user_id = $1 AND archived = $2and then oneINSERTper item, all in one transaction.When two of these requests overlap for the same user (two synced clients, or one client saving twice in quick succession), PostgreSQL's default READ COMMITTED isolation lets both transactions run the
DELETE. The secondDELETEwaits for the first transaction, then skips the rows it already deleted. It does not see the rows that transaction just inserted. ItsINSERTs then conflict withUNIQUE(user_id, item_key, archived). The request fails withduplicate key value violates unique constraint "items_user_id_item_key_archived_key", which the server returns as a 500, and that client's sync write is lost.Fix
Inside the transaction,
replace_itemsfirst takes a per-user transaction-level advisory lock:This serializes replaces for the same user. The lock is released automatically on commit or rollback. Different users still run in parallel. The delete-then-insert logic and the API stay the same. There is no schema change.
Tests
Added
replace_items_concurrent_same_userinhandlers/items.rs. It runs 8 concurrentreplace_itemscalls with the same 50 keys for one user, repeated 5 times, and checks that all of them succeed and 50 rows remain. It needs a real PostgreSQL, so it is#[ignore]d by default and readsTB_TEST_DATABASE_URL. That keepscargo test --workspacein CI unchanged:Against PostgreSQL 18:
duplicate key value violates unique constraint "items_user_id_item_key_archived_key".A standalone script running the same delete-then-insert transactions shows the same thing: 35 of 40 concurrent replaces failed without the lock, and none failed with it.
rustfmt --checkpasses on the changed file. The only Rust toolchain available to me was 1.83, which cannot build the workspace:rand0.10 needs edition 2024. So I compiledreplace_itemsand the new test in a small standalone crate with the samesqlx0.8, ranclippy -D warningsthere, and leave the full-workspacecargo clippy/cargo testto CI.This change was prepared with AI assistance (Devin).