Skip to content

fix(actions): lock the objective row before computing effective_closed - #74

Merged
lucca65 merged 1 commit into
masterfrom
fix/effective-closed-lock
Sep 27, 2026
Merged

lucca65 merged 1 commit into
masterfrom
fix/effective-closed-lock

Conversation

@lucca65

@lucca65 lucca65 commented Sep 15, 2026

Copy link
Copy Markdown
Member

Summary

Closes the dual-writer race against the backend's objective completion. Inside upsertAction's transaction, the first statement is now:

const freshObjective = await tx.instance.oneOrNone(
  'SELECT id, is_completed FROM objectives WHERE id = $1 FOR SHARE',
  [payload.data.objective_id]
)

The FOR SHARE lock conflicts with the backend's row lock when Objectives.complete_objective/2 flips objectives.is_completed, so the two writers serialize on that row: whichever commits first, the loser re-reads the committed state under READ COMMITTED, and the stale-snapshot insert (indexer reads incomplete → backend completes → indexer inserts effective_closed=false) is impossible.

  • effective_closed is derived from the post-lock read; the null-objective fallback to the top-of-function fetch is preserved (a drifted objective must never crash-loop the indexer).
  • tx.instance.oneOrNone, never .one — pg-promise's .one throws on zero rows, which would roll the block back and retry forever.
  • The backend bounds the wait with a 5s lock_timeout on its finalize transaction plus Oban retry.
  • last_tx/last_eos_account were already stamped on both create and update paths (fix(provenance): stamp last_tx/last_eos_account on objective and action writes #71); no change needed.

Verified

  • yarn format (StandardJS) clean.
  • git diff shows only src/updaters/community.js.

Deploy after the backend schema lands (migration → restart event-source → backend app).

Inside upsertAction's transaction, the first statement is now
SELECT ... FOR SHARE on the objective row (via tx.instance.oneOrNone —
never .one, which would crash-loop the indexer on a drifted objective).
The FOR SHARE lock conflicts with the backend's row lock when
Objectives.complete_objective/2 flips objectives.is_completed, so the two
writers serialize on that row: whichever commits first, the loser re-reads
the committed state under READ COMMITTED and the stale-snapshot insert that
produced a chain-open action under a completed objective is impossible.

effective_closed is derived from the post-lock read, with the null-objective
fallback to the top-of-function fetch preserved. The backend bounds the wait
with lock_timeout 5s on its finalize transaction plus Oban retry.
@lucca65
lucca65 merged commit fddef8b into master Sep 27, 2026
2 checks passed
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