Repository navigation
Correctness and deploy-safety fixes for the database write path - #709
aditya1702 wants to merge 7 commits into
Conversation
22aeaaa to
d8e9f71
Compare
d8e9f71 to
505b369
Compare
505b369 to
1e837af
Compare
There was a problem hiding this comment.
Pull request overview
Improves correctness, resilience, and maintainability of the database persistence pipeline.
Changes:
- Preserves protocol classification across batches and rejects ledger zero.
- Makes commit barriers cancellation-safe and improves persistence metrics.
- Centralizes bulk-table and account-link COPY logic.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
internal/services/protocol_processor.go |
Clarifies protocol history transaction semantics. |
internal/services/ingest.go |
Tracks contract-to-WASM bindings. |
internal/services/ingest_test.go |
Adds persistence and classification regressions. |
internal/services/ingest_live.go |
Updates batching, commits, classification, and persistence. |
internal/services/ingest_backfill.go |
Reuses the authoritative bulk-table list. |
internal/metrics/ingestion.go |
Documents batch-level timing semantics. |
internal/ingest/timescaledb.go |
Derives hypertables from bulk-copy tables. |
internal/ingest/ingest.go |
Documents shutdown behavior. |
internal/data/transactions.go |
Uses the shared account-link COPY helper. |
internal/data/operations.go |
Uses the shared account-link COPY helper. |
internal/data/ingest_store.go |
Centralizes reconciliation table metadata. |
internal/data/ingest_store_test.go |
Reuses authoritative table metadata in tests. |
internal/data/accounts.go |
Adds the shared account-link COPY helper. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // commit round-trip per sibling, the cursor still commits last, and | ||
| // re-reads converge. Hiding it would mean bounding every read by the | ||
| // committed cursor across the whole API surface. | ||
| commitCtx := context.WithoutCancel(ctx) |
There was a problem hiding this comment.
True, and it predates this PR: the detached commit context landed with the barrier in #711 (same lines at fef3fbff:329). A wedged COMMIT is bounded only by TCP keepalive or the pod being killed, and a kill is repaired by startup reconciliation, so nothing is lost; what is lost is a prompt shutdown. A finite deadline on the commit context is a follow-up on main, kept out of this PR so the stack's last commits stay reviewable.
1e837af to
9c5d1e3
Compare
9c5d1e3 to
7d5c0b7
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
internal/services/ingest_test.go:1634
- This assertion checks only
BulkCopyTables ⊆ siblings, which is the reverse of the failure described above it. Adding a new COPY sibling while omitting it fromBulkCopyTablesleaves every current assertion true, so the crash-recovery regression is not detected. Pin the complete ordered sibling set, including the two non-COPY families.
for _, sibling := range siblings {
streamed[sibling.name] = true
}
for _, target := range data.BulkCopyTables {
assert.True(t, streamed[target.Table], "no persist sibling streams %s", target.Table)
internal/services/ingest_live.go:355
context.WithoutCancelalso removes any deadline, so a stalled commit can now block shutdown indefinitely while holding all eight persist connections. The advisory-lock cleanup in this file pairsWithoutCancelwith a finite timeout for exactly this failure mode. Use a bounded detached context sized to complete the barrier within the deployment termination grace period.
commitCtx := context.WithoutCancel(ctx)
for i, s := range siblings {
if commitErr := siblingTxs[i].Commit(commitCtx); commitErr != nil {
7d5c0b7 to
8cbd794
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
internal/services/ingest_test.go:2601
- This comment names a test that does not exist and describes the removed nil-plan/batch-cut design; the following test passes a populated classification map and exercises the new merged-plan behavior. Remove this stale block so the regression coverage is not misrepresented.
// Test_getEffectiveProtocolContracts_NilClassificationKeepsCommitted pins the
// mid-batch semantics: ledgers riding behind a batch head run with no
// classification plan (classification == nil), and their buffered contracts
// are pure re-observations — every binding was already seen committed, the
// batch cut guarantees it. Committed membership must stand untouched;
internal/services/ingest_live.go:969
- This “safe without any cut” claim contradicts the PR's binding-aware cut requirement and is not correct for a contract rebound later in the batch. Because the merged contract map keeps only the final binding, the plan does not resolve the earlier WASM; staging the earlier ledger then removes that contract from protocol membership and can silently drop its events. Restore the batch cut on binding changes, or retain every intermediate binding in the classification plan.
// Merging the inputs is what makes a batch safe without any cut. A contract
// bound to a wasm uploaded by an earlier ledger of the same batch would, per
// ledger, have to resolve that wasm's verdict from protocol_wasms — a pool
// read that cannot see rows the batch has not committed. Supplied together,
// prepareClassificationPlan classifies the wasm from its buffered bytecode
internal/services/ingest_test.go:1634
- This assertion is one-way: if a bulk sibling is accidentally removed from
BulkCopyTables, the loop no longer checks it and the test still passes—the exact orphan-reconciliation failure this test claims to prevent. Compare the complete sibling-name set against the registry plus the two known balance siblings.
for _, target := range data.BulkCopyTables {
assert.True(t, streamed[target.Table], "no persist sibling streams %s", target.Table)
8cbd794 to
36982f7
Compare
c5ac1e9 to
ef6589c
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The implemented whole-batch classification model materially conflicts with the PR’s documented batching contract.
Review effort: Balanced
Findings: 4
Open (7)
UsingBulkCopyTablesto define the test's expected tables makes this reconciliation regression… The new shutdown-safety guarantee is not covered by a test: existing persist tests cover… This does not actually bound the seen sets: the size is checked before adding the current batch, so… The detached context also removes the caller's deadline, so a lost database connection or wedged… Update PR description to reflect whole-batch classification semantics · New This comment still describes a per-item classification plan and saysnilmeans this ledger had… This block documents a nonexistentNilClassificationKeepsCommittedtest and the old “mid-batch…
ef6589c to
ddb23ae
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The key metric cardinality change lacks regression coverage, and a new test comment documents obsolete behavior.
Review effort: Balanced
Findings: 4
Open (7)
UsingBulkCopyTablesto define the test's expected tables makes this reconciliation regression… The new shutdown-safety guarantee is not covered by a test: existing persist tests cover… This does not actually bound the seen sets: the size is checked before adding the current batch, so… The detached context also removes the caller's deadline, so a lost database connection or wedged… Add regression test for one-observation-per-commit metric semantics · New Update PR description to reflect whole-batch classification semantics This block documents a nonexistentNilClassificationKeepsCommittedtest and the old “mid-batch…
Resolved since last review (1)
ddb23ae to
8e16a14
Compare
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
Detached commits can block shutdown indefinitely, and key batching and metrics guarantees need aligned documentation and regression coverage.
Review effort: Balanced
Findings: 4
Open (7)
UsingBulkCopyTablesto define the test's expected tables makes this reconciliation regression… The new shutdown-safety guarantee is not covered by a test: existing persist tests cover… This does not actually bound the seen sets: the size is checked before adding the current batch, so… The detached context also removes the caller's deadline, so a lost database connection or wedged… Add regression test for one-observation-per-commit metric semantics Update PR description to reflect whole-batch classification semantics This block documents a nonexistentNilClassificationKeepsCommittedtest and the old “mid-batch…
8e16a14 to
56b4f42
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
One test still calls the changed helper with the removed argument, preventing the Go test package from compiling.
Review effort: Balanced
Findings: 1
Open (8)
Update remaining oneLedger caller to the new signature · New UsingBulkCopyTablesto define the test's expected tables makes this reconciliation regression… The new shutdown-safety guarantee is not covered by a test: existing persist tests cover… This does not actually bound the seen sets: the size is checked before adding the current batch, so… The detached context also removes the caller's deadline, so a lost database connection or wedged… Add regression test for one-observation-per-commit metric semantics Update PR description to reflect whole-batch classification semantics This block documents a nonexistentNilClassificationKeepsCommittedtest and the old “mid-batch…
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
The batching implementation materially differs from the documented design, leaving its intended correctness guarantees ambiguous.
Review effort: Balanced
Findings: 4
Open (7)
UsingBulkCopyTablesto define the test's expected tables makes this reconciliation regression… The new shutdown-safety guarantee is not covered by a test: existing persist tests cover… This does not actually bound the seen sets: the size is checked before adding the current batch, so… The detached context also removes the caller's deadline, so a lost database connection or wedged… Add regression test for one-observation-per-commit metric semantics Update PR description to reflect whole-batch classification semantics This block documents a nonexistentNilClassificationKeepsCommittedtest and the old “mid-batch…
Resolved since last review (1)
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The implemented batching and queue-payload designs materially differ from the safety and memory behavior documented for the PR.
Review effort: Balanced
Findings: 5
Open (8)
Processed ledger retains full transactions in the persistence queue · New UsingBulkCopyTablesto define the test's expected tables makes this reconciliation regression… The new shutdown-safety guarantee is not covered by a test: existing persist tests cover… This does not actually bound the seen sets: the size is checked before adding the current batch, so… The detached context also removes the caller's deadline, so a lost database connection or wedged… Add regression test for one-observation-per-commit metric semantics Update PR description to reflect whole-batch classification semantics This block documents a nonexistentNilClassificationKeepsCommittedtest and the old “mid-batch…
insert_into_db (and its 0.6s/1.0s grading buckets) now observes one true wall-time value per persist commit instead of len(batch) amortized shares. Batching engages exactly when persist has fallen behind, so the dilution reported the overloaded condition as healthy — a 4s commit for 8 ledgers landed as eight 0.5s observations. The per-ledger total duration keeps amortized shares so ledger durations stay comparable. The commit barrier also documents the deliberate torn-read window between sibling commits (readers can briefly see a transaction without its participant links or state changes; the cursor still commits last).
DeleteRowsAboveLedger falls back to deleting with no partition-column bound when the cursor ledger left no transactions row — correct but a full scan of every chunk of the five bulk tables. Log the degenerate path so an operator can attribute a slow start to it.
… read an account's balances from one snapshot TransactionModel.BatchCopyAccounts and OperationModel.BatchCopyAccounts were the same function twice — same row shape, same zero-timestamp guard, same metrics — differing only in table name, ID column, and parent type. One generic helper now serves both; the wrappers keep their public signatures. The balances field reads its sources (native, trustlines, SAC, SEP-41, liquidity pools) as separate queries, so a ledger committing between two of them could show one source's old balance next to another's new one. db.RunInReadSnapshot runs them inside one read-only REPEATABLE READ transaction (pgx.BeginTxFunc) carried in the context, and the balance models read through db.QuerierFrom, falling back to the pool everywhere else. The resolver walks the sources sequentially, so the transaction is only ever used from one goroutine.
…ommit all current state with the cursor The queue between the process and persist stages carried each ledger's decoded LedgerCloseMeta and its materialized transactions as separate fields. Persist reads only the close time and the ContractData memo, so the item now carries those two, both derived at the end of the process pass. The memo keeps its laziness: extraction still runs only when a CAS-winning processor requires contract data. The memo's transactions still reference the decoded meta, so this is a simplification of the handoff, not a memory saving. Live ingestion refuses to bootstrap from a history archive that reports ledger 0: the cursor guard and the protocol CAS both address ledgerSeq-1, which underflows unsigned, and a cursor of 0 reads as not bootstrapped. The persist-path comments on the state_changes single-transaction rule, the commit barrier and the shutdown behavior lead with the rule, then the reason. All current state commits with the cursor. The native, liquidity-pool and trustline balance families are staged on the coordinating transaction next to the SAC balances, each with its foreign-key parents, so the sibling set is exactly the five bulk-COPY tables startup reconciliation covers. Balances are overwritten in place and cannot be deleted back to the cursor, so they are never visible ahead of it, and a crash between commits leaves only bulk rows above the cursor. With every deferred foreign key on the coordinating transaction, SET CONSTRAINTS ALL IMMEDIATE checks them before the commit barrier, so a violation rolls the batch back whole instead of failing after the siblings commit. Live persist holds six connections at its barrier, so the pool floor is 7.
…then the reason Same facts, easier shape, in internal/ingest/ingest.go and internal/services/protocol_processor.go.
… drop a redundant sibling coverage test The handoff comment claimed the decoded close meta never rides the queue; the ContractData memo's transactions each carry it, so it stays reachable until the ledger persists and the queue item saves only its own copy. The sibling coverage test asserted a subset of what Test_persistSiblings_Set already pins, so its rationale moves into that test's comment. A doc comment orphaned from a deleted test is removed.
A test pins that a two-ledger persist batch observes insert_into_db once, with the batch's wall time, rather than once per ledger.
There was a problem hiding this comment.
🔵 Needs a closer look
Ledger consistency and crash recovery across six transactions warrant final human review and database-backed validation.
2 open issues
4 resolved since last review
Processed ledger retains full transactions in the persistence queue UsingBulkCopyTablesto define the test's expected tables makes this reconciliation regression… This does not actually bound the seen sets: the size is checked before adding the current batch, so… Update PR description to reflect whole-batch classification semantics
Review effort: Balanced



Start here:
persistLedgerDataininternal/services/ingest_live.go, thengetAccountBalancesininternal/serve/graphql/resolvers/account_balances.go.Seven commits finishing the write path and keeping balances on a whole ledger. No migrations, no new flags. The live-ingest connection floor drops from 9 to 7.
Balances never reflect part of a ledger
Two things let the API show balances that don't match a whole ledger. This PR fixes both:
balancesfield reads its 5 sources (native, trustlines, SAC, SEP-41, liquidity pools) as separate queries, so a ledger committing between two of them could show the old XLM next to the new USDC after a path payment.REPEATABLE READtransaction around the 5 queries (db.RunInReadSnapshot, built onpgx.BeginTxFunc). The resolver walks the sources sequentially, so the transaction is used from one goroutine.History reads were already consistent: each top-level read is bounded by the committed cursor and nested reads follow those rows. Balances and history fetched in separate requests (as Freighter does) can still straddle a ledger and converge on the next poll, as before.
Costs
balancesrequest addsBEGINandCOMMITround trips around queries that already ran one after another. History requests are unchanged.One metric changes meaning: re-check any alert on it
insert_into_dbnow records one true wall-time value per persist commit. It used to split the commit time evenly across the ledgers in the batch, so a 4 s commit for 8 ledgers landed as eight 0.5 s observations and an overloaded write step graded as healthy. Batching only engages when persist has fallen behind, which is exactly when the dilution hid the problem. The observation also includes failed attempts and retry backoff. The per-ledger total duration keeps the amortized share so ledger durations stay comparable, so with batches larger than 1 the phases no longer sum to it. At batch size 1 nothing changes.Correctness
SET CONSTRAINTS ALL IMMEDIATEchecks them before any sibling commits. A violation rolls the batch back whole with a permanent 23503, instead of surfacing at the coordinating commit as a partial persist that crash-loops on every restart.ledgerSeq-1, which wraps on an unsigned int, and a cursor of 0 reads as "not bootstrapped". Ledger numbers only originate at startup, so the check lives there.data.BulkCopyTables. A sibling added without a reconciliation entry, or the reverse, fails the test instead of leaving orphan rows that crash re-ingestion. The same test ties the connection floor to the sibling count.Handoff
LedgerCloseMeta. Both are derived at the end of the process pass. This is a simplification, not a memory saving: the memo's transactions still reference the meta, so it stays reachable until the ledger persists. The memo stays lazy, so extraction runs only when a processor that won its cursor swap requires contract data.Cleanup
protocol-migratecatches that protocol up) and how the phase metrics relate to the per-ledger duration.Merging this finishes the chain.
PR 6 of 6 replacing #684 · schema → fixes → pipeline → parallel writes → table groups → final fixes