Repository navigation
Serialize concurrent item replaces per user - #63
Open
tobiashochguertel wants to merge 1 commit into
Open
tobiashochguertel wants to merge 1 commit into
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 <[email protected]>
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.
Author
|
Friendly ping — this serializes concurrent |
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.
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).