Repository navigation
fix(consolidation): make per-bank consolidation submit atomic + scope-aware (#1842) - #1898
Merged
Merged
Conversation
nicoloboschi
force-pushed
the
fix/consolidation-dedup-pending-1842
branch
from
June 1, 2026 09:31
cc1deb0 to
f2347b0
Compare
nicoloboschi
force-pushed
the
fix/consolidation-dedup-pending-1842
branch
2 times, most recently
from
June 1, 2026 10:20
663e18b to
5332415
Compare
…-aware (#1842) `_submit_async_operation`'s dedup was a check-then-INSERT split across two separate connection acquisitions — inherently racy. Under READ COMMITTED two concurrent submits (a manual /consolidate loop racing a retain-driven submit or the round-limit re-queue) both see no pending row and both insert, leaking duplicate pending consolidation ops for one bank. Those extras then enter retry-backoff and pile up as retry_blocked, starving the bank of claimable work — the root cause behind the dedup-guard-fails and idle-bank symptoms in #1842. Make the dedup check-and-insert atomic: run it in a single transaction that first locks the bank row, so concurrent submits for the same bank serialize and the second observes the first's pending row. The lock releases on commit, before submit_task runs. Use SELECT ... FOR NO KEY UPDATE, not FOR UPDATE: async_operations has an FK to banks, so every async-op insert for the bank (a scoped consolidation, a batch-retain op, a webhook delivery, ...) takes a FOR KEY SHARE lock on the bank row. FOR UPDATE conflicts with FOR KEY SHARE and would block all of those during the submit; FOR NO KEY UPDATE conflicts only with itself, so two submits serialize while those inserts proceed unblocked. The Oracle SQL rewriter maps FOR NO KEY UPDATE to FOR UPDATE (Oracle has only the latter and it does not block indexed-FK child inserts). Dedup is also scope-aware: an unscoped (full-bank) submit dedups only against an existing *unscoped* pending op. A pending scoped consolidation covers only its tag subset, so it must not swallow a full-bank sweep. (Scoped submits already pass dedupe_by_bank=False and skip the lock/dedup entirely — they always run.) The scope check is in Python because the JSON predicate isn't portable (Oracle's JSON_VALUE returns NULL for the array-valued observation_scopes). This enforces the intended invariant — at most one pending full-bank consolidation per bank — at the point of creation rather than cleaning up duplicates downstream. No schema change.
nicoloboschi
force-pushed
the
fix/consolidation-dedup-pending-1842
branch
from
June 1, 2026 10:21
5332415 to
b233c77
Compare
cdbartholomew
added a commit
that referenced
this pull request
Oct 20, 2026
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
Fixes the duplicate-pending-consolidation pile-up in #1842 (reporter saw
PENDING_BREAKDOWN: total=3 claimable=0 retry_blocked=3— several pending ops forone bank, all parked on backoff, leaving the bank with no claimable work).
Root cause — a non-atomic endpoint.
_submit_async_operation's dedup is acheck-then-INSERT split across two separate connection acquisitions (pre-check
SELECTthen a laterINSERTin a different block) — not even one transaction. UnderREAD COMMITTED, two concurrent submits both see no pending row and both insert. A
manual
POST /consolidateloop racing retain-driven submits and the round-limitre-queue therefore leaks duplicate pending consolidation ops for a bank; those extras
each enter retry-backoff and pile up as
retry_blocked. Everything downstream — thededup-guard failing ops, the idle banks — is a consequence of the endpoint creating
duplicates it promised not to.
Fix — make the submit atomic. Run the dedup check-and-insert in a single
transaction that first locks the bank row, so concurrent submits for the same bank
serialize and the second observes the first's pending row. The lock releases on
commit, before
submit_taskruns.This enforces the intended invariant — at most one pending full-bank consolidation
per bank — at the point of creation, rather than cleaning up duplicates downstream.
Combined with the already-merged #1857 (rounds auto-continue) and #1854 (5s backoff),
the pile-up can't form and the external re-POST workaround is no longer needed.
Lock choice —
FOR NO KEY UPDATE, notFOR UPDATEasync_operationshas an FK tobanks, so every async-op insert for the bank (ascoped consolidation, a batch-retain op, a webhook delivery, …) takes a
FOR KEY SHARElock on the bank row.
FOR UPDATEconflicts withFOR KEY SHAREand would brieflyblock all of those during the submit.
FOR NO KEY UPDATEconflicts only with itself —so two submits serialize (what we want) while those inserts proceed unblocked. The
Oracle SQL rewriter maps
FOR NO KEY UPDATE→FOR UPDATE(Oracle has only thelatter, and there it does not block indexed-FK child inserts).
Scopes
Consolidation scopes are handled in both directions so a full-bank sweep and a scoped
run never suppress each other:
observation_scopesset) passdedupe_by_bank=False— they skipthe lock and dedup entirely and always run (existing intent: "a targeted run
should not be merged into a pending full-bank sweep").
scoped consolidation covers only its tag subset, so it must not swallow a full-bank
sweep. The scope check is done in Python because the JSON predicate isn't portable
(Oracle's
JSON_VALUEreturns NULL for the array-valuedobservation_scopes).SELECT … FOR NO KEY UPDATEkeeps this app-level, cross-dialect, and consistent withexisting locking in the engine. No schema change.
Testing
tests/test_consolidation_submit_atomic_dedup.py:tests/test_db_abstraction.py::TestOracleQueryRewriter::test_for_no_key_update_rewrite—FOR NO KEY UPDATE→FOR UPDATEon OracleAll pass; ruff + ty clean. (The
submit_taskinline executor is neutralized in-test so the pending rows survive inspection under the sync backend.)