fix(indexer): index operations atomically per transaction - #299
fix(indexer): index operations atomically per transaction#299patextreme wants to merge 1 commit into
Conversation
0c711cc to
e90a0f4
Compare
run_indexer_loop previously committed each operation individually, so a
multi-operation transaction passed through a state where only some of its
operations were visible in the indexed views. This created a TOCTOU race:
GET /api/transactions/{tx_id} returns 200 as soon as any operation of a
transaction is indexed, while DID resolution reads the indexed view. A reader
polling for confirmation could observe a partial state and resolve a DID
document missing operations, producing intermittent conformance-test failures.
Operations are now grouped by transaction ((block_number, absn), which the
unindexed query already orders contiguously) and committed with a single
insert_indexed_operations call, which is already wrapped in one DB
transaction. A transaction therefore becomes visible to readers all at once,
so transaction confirmation implies the full transaction is indexed.
A storage update/deactivate may reference a root operation published in the
same transaction; since the batch is not committed until the transaction
boundary, such roots are resolved via an in-memory pending map
(resolve_vdr_root) that walks the same-transaction chain and falls back to the
database for cross-transaction chains. Each phase is independently bounded by
VDR_ROOT_SEARCH_MAX_DEPTH (hoisted from recursively_find_vdr_root) so the
existing depth-limit guard still applies.
get_raw_operations_unindexed (sqlite + postgres) paginates with a 200-row
limit, which could split a transaction across two pages and cause the indexer
to commit it in two separate calls, reintroducing the race. When the page is
full, the last transaction is now re-fetched in full so every returned
transaction is complete and the per-transaction batching is never split by the
page boundary.
Adds unit tests covering per-transaction batching, same-transaction storage
chain resolution without a DB read, cross-transaction DB fallback, and
ignoring orphans; and storage tests verifying a transaction spanning the page
boundary is returned complete.
Signed-off-by: Pat Losoponkul <patextreme@hotmail.com>
e90a0f4 to
b73af74
Compare
|
|
Followed up on an automated review of this PR. Two findings addressed:
The carry-across-fetches alternative was ruled out: if a single transaction has ≥200 operations, its uncommitted rows would re-fill every page and the tail would never be fetched (infinite loop). Returning complete transactions from the source avoids this entirely. All CI green: |
patextreme
left a comment
There was a problem hiding this comment.
AI-assisted review — Verdict: Approve (would approve)
GitHub forbids approving your own PR, so posting this as a comment review. Assessment below.
Correctness — confirmed
- Atomicity holds.
insert_indexed_operationsopens one DB transaction and commits once, in bothsqlite.rsandpostgres.rs, so batching a transaction's operations into a single call genuinely makes them visible atomically.GET /api/transactions/{tx_id}returning 200 now implies the full transaction is indexed. - Pagination boundary is handled.
get_raw_operations_unindexedre-fetches the last transaction in full when the 200-row page is exactly full, so a transaction can never be split across pages and re-committed in two calls. The assumption that only the last sorted group can be truncated is correct, and the re-fetch is uncapped so it can't suffer second-order truncation. - Same-tx storage chains resolve correctly via the in-memory
pending_vdrmap (resolve_vdr_root), falling back torecursively_find_vdr_rootfor cross-tx chains. Theosnordering invariant this relies on is sound.
Test coverage — good
Batching (one insert per tx), same-tx chain resolution with zero DB lookups, cross-tx DB fallback, orphan-ignore, plus two pagination regression tests (250-op single tx across the boundary; a split landing mid-second-tx). The repro data (8/26 keys on iter 46 → 0/40 after the fix) is convincing.
Minor / non-blocking notes
- Each resolution phase has an independent
VDR_ROOT_SEARCH_MAX_DEPTHbudget, so a chain crossing a tx boundary can walk ~2× a single-phase chain. Already documented; low practical concern. - The ~35-line pagination block is duplicated across the two backends, consistent with the files' existing mirroring convention.
- Optional polish: a one-line comment near the
pending_vdr.insertcalls stating the osn-ordering invariant, and a test pinning the combined depth-budget behavior.
No blocking issues. CI green (neoprism-check, coverage, run-conformance-test, lints, DCO).



Problem
run_indexer_loopcommitted each operation individually, so a multi-operation transaction passed through a state where only some of its operations were visible in the indexed views. This created a TOCTOU race:GET /api/transactions/{tx_id}returns200as soon as any operation of a transaction is indexed.A reader polling for confirmation could proceed while a batch was partially indexed and resolve a DID document missing operations — the source of intermittent prism-test failures.
Reproduced deterministically: a create + 25 chained-updates batch returned only 8/26 keys on iteration 46 of 60.
Fix
Index each transaction atomically so a transaction becomes visible to readers all-at-once.
1. Per-transaction commit — Operations are grouped by transaction
((block_number, absn))and committed with a singleinsert_indexed_operationscall (already wrapped in one DB transaction in both backends). After this, transaction confirmation (200) implies the full transaction is indexed.2. Same-transaction storage chains — A storage update/deactivate referencing a root in the same transaction is resolved via an in-memory pending map (
resolve_vdr_root) that walks the same-transaction chain and falls back to the DB for cross-transaction chains. Each phase is independently bounded byVDR_ROOT_SEARCH_MAX_DEPTHso the existing depth-limit guard still applies.3. Pagination boundary (addressing review feedback) —
get_raw_operations_unindexed(sqlite + postgres) paginates with a 200-row limit, which could split a transaction across two pages and make the indexer commit it in two separate calls, reintroducing the race. When the page is full, the last transaction is now re-fetched in full so every returned transaction is complete and the per-transaction batching is never split by the page boundary.Verification
cargo clippy(indexer + storage)-D warningsMainSpec)Files
lib/did-prism-indexer/src/indexing.rs— per-transaction batching,resolve_vdr_root,PendingVdrEntry, hoisted depth const, unit tests.lib/node-storage/src/backend/{sqlite,postgres}.rs—get_raw_operations_unindexedreturns complete transactions across the page boundary + regression tests.