fix(actions): read effective_closed inside the write transaction - #72
Conversation
Objectives.complete_objective/2 in the backend bulk-sets actions.effective_closed=true for every action under an objective in one update_all, triggered off the first of a multi-action completion's N upsertactions to confirm. This function is typically still processing actions 2..N at that point. Reading the objective's is_completed at the top of the function (before the create path's chain-id-resolution await, or before anything else that runs earlier in a busy indexer) captured is_completed=false from before completion, and writing that into the row after the backend's update_all had already landed true clobbered it back to false — a lost update on the happy path of every multi-action objective completion, not a rare race. Move the read inside db.withTransaction, on tx's own connection, right before the write. Narrows the window from the whole function's duration down to one SELECT-to-write gap; full elimination would need a correlated-subquery UPDATE or explicit row locking, which doesn't fit this write's shape. What's left is exactly the shape the backend's new monitoring query (objectives.is_completed=true AND actions.effective_closed=false) watches for. See scripts/objective-closed-action-adr.md in the backend repo for full background. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HoKQkEipzS7KSQgRcrBpha
Review response on PR #72 (feat/objective-closed-propagation): the in-transaction re-read of the objective (`freshObjective`) could come back null on the same chain<->DB id-drift class the audit found for actions 399-406 (objective row deleted, or never indexed). Reading `freshObjective.is_completed` unguarded would throw inside db.withTransaction, whose .catch logs AND rethrows by design — so a single drifted objective row would roll back the transaction and the indexer would retry the same block forever. Fall back to `o`, the objective already fetched and null-checked at the top of upsertAction, matching this file's established log-and-continue pattern for this exact class of missing-row risk (the objective-existence check at the top of upsertAction, and verifyClaim's three skip-and-log guards). Also corrects the comment above this read, which overstated that moving the read inside the transaction fully closes the lost-update race against the backend's Objectives.complete_objective/2 update_all. Under Postgres's default READ COMMITTED isolation this is a plain SELECT with no row lock, so the race is narrowed, not closed; what actually makes the residual race unreachable today is the backend's completion guardrail (refuses to mark an objective complete until every action already reads is_completed: true on chain), not this transaction scoping alone. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HoKQkEipzS7KSQgRcrBpha
|
Independent staff review round on this PR (and companion cambiatus/backend#443) found one real must-fix, addressed in a follow-up commit:
Also corrected the comment above that read, which overstated that moving the read inside the transaction fully closes the race against the backend's |
Summary
Companion to cambiatus/backend#443 (objective-closed-column). That PR adds
actions.effective_closed, written by both the backend'sObjectives.complete_objective/2and this repo's action mapper — see itsscripts/objective-closed-action-adr.mdfor the full design and why the write issplit across two repos.
This PR is the event-source half of that dual writer, plus a fix for a lost-update
bug that dual writer created:
upsertActionnow reads the objective'sis_completedinside the writetransaction, on the transaction's own connection, immediately before the write
— not from the objective fetched earlier in the function.
complete_objective/2bulk-setseffective_closed=truefor every actionunder an objective in one
update_all, triggered off the first of amulti-action completion's N
upsertactions to confirm. This function istypically still processing actions 2..N at that point. Reading
is_completedearlier in the function captured
falsefrom before completion; writing thatstale value after the backend's
update_allhad already landedtrueclobbered it back to
false— on the happy path of every multi-actionobjective completion, not a rare edge case.
SELECT-to-write gap inside the transaction. It does not eliminate it outright —
full elimination would need a correlated-subquery UPDATE or explicit row
locking, which didn't fit this write's existing shape. What's left is exactly
the drift shape the backend PR's new monitoring query watches for.
Test plan
node -c src/updaters/community.js— syntax validnpx standard src/updaters/community.js— no new lint findings (pre-existingwarnings on untouched lines only)
reading the diff directly, no automated test coverage added
process must be restarted after that deploy — the DB layer introspects
the schema once at boot and won't see the new column otherwise. See that
PR's runbook update for the full sequencing.
🤖 Generated with Claude Code