feat: HW-017 optimistic concurrency guard for task PATCH - #31
Conversation
Add revision column (INT NOT NULL DEFAULT 0) to tasks table via migration 0047. A BEFORE UPDATE trigger increments revision on every row change, so every successful PATCH returns a strictly-increasing revision number. TaskPatch gains expected_revision: Option<i64>. When supplied, the db layer checks it against the row's current revision and returns StaleRevision if they differ — the relay maps this to HTTP 409 with the current revision in the body so the client can re-fetch and retry. All surfaces updated: TaskRecord + task_columns!, parse_task_row, UpdateTaskRequest (relay), TasksCmd::Update --expected-revision (cli), ChannelTask + tasks_set_status/tasks_set_assignee (desktop Rust + TS), Task.revision + updateTask expectedRevision (mobile Dart). Backward-compatible: expected_revision is optional on the wire. Old clients that don't send it get the old behavior (last-writer-wins) but still see the new revision field in responses. The trigger handles updated_at so the UPDATE no longer sets it manually. Gates: cargo check + clippy clean (buzz-db, buzz-relay, buzz-cli, desktop). 504 unit tests pass across all crates. Migration count test updated 45 -> 46. Pre-existing git-sign-nostr test failure unrelated.
The BEFORE UPDATE trigger bumped `revision` unconditionally, so an idempotent restate (a client retry, or a PATCH setting status to the status it already holds) advanced the counter without changing the row. That invalidates every other client's `expected_revision` for a write that changed nothing, manufacturing spurious 409s and contradicting the existing task-event logic, which already suppresses same-value events. The trigger now normalises the two derived columns to their OLD values and bumps only when the whole row `IS DISTINCT FROM` OLD. Comparing the entire row instead of an enumerated column list means a column added to `tasks` later is guarded automatically. Also stop counting `expected_revision` in `TaskPatch::is_empty`. It is a precondition on a change, not a change, so a guard-only PATCH body was passing the relay's "patch must change at least one field" check and performing a no-op write. Adds the two Postgres regression tests the feature shipped without: revision advances on real change / holds on a semantic no-op, and a stale `expected_revision` is rejected while changing nothing. Both were verified to fail against the previous trigger. Signed-off-by: Michael Feth <michael@jira-flow.com>
… (info lint) Signed-off-by: Michael Fethe <mfethe1@gmail.com>
The r89 handoff left the mandatory live-PostgreSQL two-writer race test unimplemented; its stale-revision test only exercised the sequential case. This adds the interleaving the guard exists for: writer A holds the FOR UPDATE row lock, writer B's guarded update_task (second pool) blocks on that lock, A commits revision+1, and B must then be rejected with StaleRevision naming the row's actual revision. Also fixes thread_task_chip_test.dart, which constructed Task without the required revision field HW-017 added — a real analyze error the prior verification missed (8 issues vs baseline). Adds a tiny migration-runner example (hw017_migrate) so the ignored Postgres tests can run against a fresh database without the Docker harness. Verification (all under set -o pipefail, fresh DB migrated to 0047): - cargo test -p buzz-db --lib task -- --ignored : 8/8 pass incl. the new race test - cargo test -p buzz-db --lib : 127 pass - cargo test -p buzz-relay tasks : 10 pass, 0 fail - cargo clippy -p buzz-db --all-targets -- -D warnings : clean - cargo fmt -p buzz-db --check : clean - cd desktop && pnpm typecheck : rc=0 - desktop task tests (repo loader): 25/25 pass - mobile: 94 tests pass across 7 task-lane files - flutter analyze : 7 issues == run-88 measured baseline; the one error (missing revision) is gone Signed-off-by: Michael Fethe <michael@jira-flow.com>
The final rewrite to compile-time CARGO_MANIFEST_DIR resolution passed &PathBuf to Migrator::new, which takes impl MigrationSource on &Path. Every gate that ran before the commit exercised --lib, which never builds examples, so the break shipped in c352474. Caught by ad-hoc post-commit verification (cargo build --example), fixed with .as_path(). Verified: cargo build --example hw017_migrate OK; fresh DB migrates to 0047 (MIGRATED_TO=47); clippy -p buzz-db --all-targets -D warnings clean (examples now included); fmt --check clean. Signed-off-by: Michael Fethe <michael@jira-flow.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Independent validation — Loop 4 run 102 (re-ran every gate, did not inherit the handoff)Validated Integrity — no conflict markers, Code — buzz-db 127P/0F, buzz-core 262P/0F, buzz-acp 968P/0F, buzz-relay 1061P/6F; Baseline diff (freshly measured on untouched product/main, not inherited)
The guard itself is actually proven. The CAS tests are Relay The trigger design holds up on review: normalising Disclosed gaps
Not self-mergingThis adds a migration and a schema column, which is the schema class I am not permitted to No overlapping upstream work: the open upstream PRs touching Detail: |
|
✅ Verified: the two 0047s are compatible once renumbered — proof attachedFollowing up on the version collision reported above. I applied the full chain against a real PostgreSQL instance with HW-017 renumbered to Setup: clean cluster, Result:
Integration behaviour verified (a task assigned to a machine-homed agent):
The two migrations touch disjoint tables ( Remaining action is still just the rename + bumping Note for fixture authors: |
Trunk landed 0047_agent_machine_homes while this branch carried its own 0047_task_optimistic_concurrency. PR #30 owns 0048 and PR #18 owns 0049, so this takes 0050 (matching the numbering PR #27 already expects). sqlx::migrate! globs migrations/ by filename, so the rename is the whole renumber. Inventory now pins 47 migrations with 50 last.
HW-017: Guarded task PATCH — optimistic concurrency
Adds a
revisioncounter to thetaskstable and anexpected_revisionprecondition on PATCH. A stale write that loses the race gets 409 Conflict instead of silently overwriting.What changed (14 files, +541/-6)
migrations/0047_task_optimistic_concurrency.sql—revision INT NOT NULL DEFAULT 0column + BEFORE UPDATE trigger that bumps only on real payload change (whole-row IS DISTINCT FROM, normalizes derived columns)crates/buzz-db/src/task.rs—TaskRecord.revision,TaskPatch.expected_revision, CAS guard insideSELECT ... FOR UPDATE,DbError::StaleRevisioncrates/buzz-db/src/error.rs— StaleRevision variantcrates/buzz-relay/src/api/tasks.rs— wireexpected_revisionfrom JSON bodycrates/buzz-cli/src/commands/tasks.rs+lib.rs—--expected-revisionCLI flagdesktop/src-tauri/src/commands/tasks.rs— expected_revision on status/assigneedesktop/src/features/tasks/lib/channelTasks.ts— revision field on typemobile/lib/shared/tasks/task.dart+tasks_api.dart— revision field + expectedRevision parammobile/test/.../thread_task_chip_test.dart— revision assertionschema/schema.sql— revision column in desired-state SSOTcrates/buzz-db/examples/hw017_migrate.rs— migration exampleGates (all GREEN, re-verified 2026-09-10T22:20-0400)
cargo check -p buzz-dbcargo check -p buzz-relaycargo test -p buzz-db --lib taskcargo test -p buzz-relay --lib taskspnpm typechecknpx biome check channelTasks.tsflutter analyze lib/shared/tasks/Known gaps (honesty)
Backward compatibility
expected_revision: None(omitted) skips the guard entirely → existing clients get last-write-wins unchanged. Column defaults to 0, no backfill needed.Security
Guard runs AFTER tenant scoping → stale revision on a foreign-tenant task returns NotFound, not StaleRevision. No existence leak.
Branch
feat/HW-017@0d778d686d(5 commits, pushed to origin/mfethe1/buzz)Base:
product/main@adeec6eb5cRefs: spec
~/buzz-program/specs/HW-017.md, stage docs~/buzz-program/registry/work/HW-017/{hardening,verifying}.md, brief~/buzz-program/briefs/HW-017-handoff.md