Skip to content

fix(store): make item field + parent-link update atomic (BUG-2013)#868

Merged
xarmian merged 2 commits into
mainfrom
fix/update-item-partial-commit
Jul 8, 2026
Merged

fix(store): make item field + parent-link update atomic (BUG-2013)#868
xarmian merged 2 commits into
mainfrom
fix/update-item-partial-commit

Conversation

@xarmian

@xarmian xarmian commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

handleUpdateItem committed the field write, then ran SetParentLink/ClearParentLink as a separate store transaction. A failure there (a cycle discovered late, a DB error) returned 500 with half the patch already applied — a code-acknowledged partial-commit window (BUG-2013).

This folds the parent-link mutation into the same transaction as the field write, so a single item update is all-or-nothing.

Changes

  • store: extracted setParentLinkTx / clearParentLinkTx from the public SetParentLink / ClearParentLink (which keep their own tx). Added UpdateItemWithParentLink(id, input, precheck, *ParentLinkUpdate); UpdateItemWithPreCheck now delegates to it with a nil link. The link write runs after the field UPDATE but before COMMIT, so a failing link write rolls the field write back too.
  • lock ordering: the NEW parent's advisory key is folded into the update's initial sorted AcquireParentChildrenLocks batch (extraKeys on acquireParentChildrenLocksForUpdate), so setParentLinkTx's later re-lock is an idempotent no-op and the combined update stays deadlock-free. checkParentCycle is parameterized over the queryer (checkParentCycleQ) so the cycle walk reads inside the tx, and now runs after lock acquisition.
  • handler: restructured into validate / atomic-write / post-commit stages. The parent-link directive is built once and threaded through all three UpdateItemWithParentLink call sites; the old post-commit SetParentLink/ClearParentLink block is removed.

Works on both SQLite (atomicity from BEGIN IMMEDIATE) and Postgres (advisory locks).

Tests

  • TestUpdateItemWithParentLink_AtomicRollback — a failing (cycle) parent-link write rolls back the field change; asserts no partial state and no committed link.
  • TestUpdateItemWithParentLink_AtomicCommit — happy path commits field + link together; also covers atomic clear.

Gates

  • golangci-lint run — 0 issues
  • go test ./... — green
  • make test-pg (Postgres suite) — green
  • Codex review — atomicity confirmed correct

Out of scope / follow-up (BUG-2073)

Codex review surfaced two pre-existing concurrency TOCTOU races in the parent-link locking scheme (present on main verbatim, orthogonal to this partial-commit fix): (1) public SetParentLink doesn't lock the child's own key, so a symmetric A↔B reparent can form a cycle; (2) oldParent is read before the lock and not re-read after (the same read-then-lock pattern the whole parent-children protocol uses). This PR already makes the new atomic path cycle-safe (it holds the child + parents + new-parent locks up front). The residual public-path races are tracked in BUG-2073 rather than expanding this PR.

https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS

xarmian added 2 commits July 8, 2026 16:01
handleUpdateItem committed the field write, then ran SetParentLink/
ClearParentLink as a SEPARATE store transaction. A failure there
(cycle discovered late, DB error) returned 500 with half the patch
already applied — a code-acknowledged partial-commit window.

Fold the parent-link mutation into the SAME transaction as the field
write:

- store: extract setParentLinkTx / clearParentLinkTx from the public
  SetParentLink / ClearParentLink (which keep their own tx). Add
  UpdateItemWithParentLink(id, input, precheck, *ParentLinkUpdate) —
  UpdateItemWithPreCheck now delegates to it with a nil link. The
  link write runs after the field UPDATE but before COMMIT, so a
  failing link write rolls the field write back too.
- lock ordering: the NEW parent's advisory key is folded into the
  update's initial sorted AcquireParentChildrenLocks batch (extraKeys
  on acquireParentChildrenLocksForUpdate), so setParentLinkTx's later
  re-lock is an idempotent no-op and the combined update stays
  deadlock-free. checkParentCycle is parameterized over the queryer
  so the cycle walk reads inside the tx.
- handler: restructured into validate / atomic-write / post-commit
  stages. The parent-link directive is built once and threaded through
  all three UpdateItemWithParentLink call sites; the post-commit
  SetParentLink/ClearParentLink block is removed.

Works on both SQLite and Postgres (advisory locks are pg-only; SQLite
gets atomicity from BEGIN IMMEDIATE). Adds store tests proving a
failing parent-link write rolls back the field change (no partial
state) and that the happy path commits both together.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
)

Codex review flagged a cycle-check TOCTOU: setParentLinkTx walked the
ancestor chain BEFORE acquiring the parent-children advisory locks, so
two concurrent reparents could each pass on a stale snapshot, block on
the lock, then both insert — forming a cycle (A→B→C→A). Move the cycle
check to AFTER lock acquisition; under the lock the tx-scoped walk sees
the edge the just-unblocked peer committed and rejects the cycle.

Pre-existing behavior (the old SetParentLink checked cycles on s.db
before even beginning its tx), hardened here since this function was
already being refactored. Residual: cycles closed via an edge on an
item neither endpoint locks remain possible — a limitation of the
per-endpoint lock scheme, tracked separately.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
@xarmian xarmian merged commit a7994fc into main Jul 8, 2026
4 checks passed
@xarmian xarmian deleted the fix/update-item-partial-commit branch July 8, 2026 16:18
xarmian added a commit that referenced this pull request Jul 8, 2026
…BUG-2073) (#870)

The public SetParentLink/ClearParentLink paths and the shared
acquireParentChildrenLocksForUpdate helper had two residual TOCTOU
races (pre-existing on main; the NEW atomic UpdateItemWithParentLink
path was already made cycle-safe in PR #868 / BUG-2013):

1. Cycle race: SetParentLink acquired only the old+new parent advisory
   keys, never the CHILD's own (itemID) key. Concurrent
   SetParentLink(A,B) and SetParentLink(B,A) locked disjoint keys
   ({B} vs {A}), so both cycle walks passed on stale snapshots and both
   inserts committed — forming an A<->B cycle.

2. Stale-old-parent race: oldParent was read BEFORE the parent-children
   locks were acquired and never re-read. A concurrent reparent of the
   same child committing while this tx waited on locks let it DELETE the
   newly-committed parent link without holding that real old parent's
   lock, breaking the open-children guard serialization. The shared
   read-then-lock helper (UpdateItem/RestoreItem/MoveItem) had the same
   defect: it read the parent set before locking itemID.

Fix, consistent with the PR #868 pattern (sorted lock batches, tx-scoped
cycle walk), and deadlock-free:

- setParentLinkTx / clearParentLinkTx: fold itemID into the lock set and
  acquire {itemID + old + new parent} in ONE sorted batch, then RE-READ
  the old parent under the (now-held) child lock. New readParentLinkTarget
  helper.
- acquireParentChildrenLocksForUpdate: after the sorted acquisition,
  re-read the parent set under the itemID lock (keysNotIn detects any
  parent that appeared during the acquisition window).
- When a re-read shows the parent set moved, signal the errParentSetChanged
  sentinel instead of acquiring the moved key out of the canonical sorted
  order (which could deadlock). The tx-owning callers — SetParentLink,
  ClearParentLink, UpdateItemWithParentLink, RestoreItem,
  MoveItemWithPreCheck — wrap their bodies in retryOnParentSetChanged,
  which rolls back (releasing every advisory lock) and retries from a
  fresh read. Every acquisition stays a single in-order sorted batch. The
  signal fires before any commit, so a retry never leaves partial state;
  bounded by maxParentLockRetries.
- RestoreItem: route through acquireParentChildrenLocksForUpdate so it
  also holds the item's own lock and gets the re-read correction.
- CreateItemLink / DeleteItemLink: for child link types, lock the SOURCE
  item's key in addition to the target's. Attaching/detaching sourceID as
  a child mutates sourceID's parent set, so sourceID's own lock must be
  held for the "the child lock freezes an item's parent set" invariant the
  re-read/retry above depends on. Both keys go through the sorted helper,
  so the two-key grab stays deadlock-free.

Postgres-only races (SQLite serializes writers via BEGIN IMMEDIATE), so
the new concurrency tests are gated on the Postgres dialect. They pass
with the fix and reproduce the A<->B cycle without it.

Out of scope (pre-existing, tracked separately): cycles closed via an
edge on an item that NEITHER endpoint locks (e.g. A->B->C->D->A) still
slip through the per-endpoint cycle walk — a documented limitation of the
per-endpoint lock scheme, not the direct A<->B race this bug names.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant