Skip to content

Correctness and deploy-safety fixes for the database write path - #709

Open
aditya1702 wants to merge 7 commits into
mainfrom
persist/6-hardening
Open

aditya1702 wants to merge 7 commits into
mainfrom
persist/6-hardening

Conversation

@aditya1702

@aditya1702 aditya1702 commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Start here: persistLedgerData in internal/services/ingest_live.go, then getAccountBalances in internal/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:

Cause Fix
The balance and trustline siblings committed before the coordinating transaction that advances the cursor. After a crash, balances stayed ahead until replay caught up. All current state commits with the cursor. Native, liquidity-pool and trustline balances are staged on the coordinating transaction next to the SAC balances, each with its foreign-key parents. The siblings are exactly the five bulk-COPY tables startup reconciliation covers, so the only crash residue is bulk rows above the cursor.
The balances field 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. One read-only REPEATABLE READ transaction around the 5 queries (db.RunInReadSnapshot, built on pgx.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

  • Ingest: the coordinator's serial path now includes the balance upserts. On dev mainnet the native-balance upsert takes about 11 ms per ledger and a whole persist about 57 ms, against a ~5 s ledger close.
  • Serve: a balances request adds BEGIN and COMMIT round 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_db now 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

  1. Foreign keys are checked before the commit barrier. Every deferred foreign key now has its parent on the coordinating transaction, so SET CONSTRAINTS ALL IMMEDIATE checks 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.
  2. Live ingestion refuses to bootstrap from ledger 0. The cursor guard and the protocol compare-and-swap both address 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.
  3. The sibling set is a named constructor pinned by a test to 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

  1. The process→persist queue item carries the close time and the ContractData memo, not its own copy of the decoded 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

  1. One shared COPY helper for the two account link tables. The two bodies were identical apart from table name, ID column and parent type; the public signatures are unchanged.
  2. Startup reconciliation logs (at info) when the close-time bound is unresolvable and the deletes run unbounded, so a slow start can be attributed. That is expected after a checkpoint bootstrap or for an empty cursor ledger.
  3. A comment pass over the persist path: each rule is stated once, leading with the rule, then the reason. The architecture and deploy docs describe the six-transaction commit set, the floor of 7, the balances snapshot (SEP-41 balances lag while protocol-migrate catches 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

@aditya1702 aditya1702 changed the title Correctness, deploy-safety and hygiene across the persist path Correctness and deploy-safety fixes for the database write path Aug 26, 2026
@aditya1702
aditya1702 force-pushed the persist/6-hardening branch from 22aeaaa to d8e9f71 Compare August 27, 2026 02:52
@aditya1702
aditya1702 force-pushed the persist/6-hardening branch from d8e9f71 to 505b369 Compare August 27, 2026 13:54
Copilot AI balanced review requested due to automatic review settings August 27, 2026 17:18
@aditya1702
aditya1702 force-pushed the persist/6-hardening branch from 505b369 to 1e837af Compare August 27, 2026 17:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/services/ingest_live.go Outdated
Comment thread internal/services/ingest_test.go Outdated
Copilot AI review requested due to automatic review settings August 27, 2026 17:24
@aditya1702
aditya1702 force-pushed the persist/6-hardening branch from 1e837af to 9c5d1e3 Compare August 27, 2026 17:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings August 27, 2026 20:41
@aditya1702
aditya1702 force-pushed the persist/6-hardening branch from 9c5d1e3 to 7d5c0b7 Compare August 27, 2026 20:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 from BulkCopyTables leaves 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.WithoutCancel also 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 pairs WithoutCancel with 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 {

Comment thread internal/services/ingest_live.go
Comment thread internal/services/ingest_test.go Outdated
Copilot AI review requested due to automatic review settings August 28, 2026 15:33
@aditya1702
aditya1702 force-pushed the persist/6-hardening branch from 7d5c0b7 to 8cbd794 Compare August 28, 2026 15:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread internal/services/ingest_live.go
Copilot AI review requested due to automatic review settings August 28, 2026 15:43
@aditya1702
aditya1702 force-pushed the persist/6-hardening branch from 8cbd794 to 36982f7 Compare August 28, 2026 15:43
Copilot AI balanced review requested due to automatic review settings October 6, 2026 17:29
@aditya1702
aditya1702 force-pushed the persist/6-hardening branch from c5ac1e9 to ef6589c Compare October 6, 2026 17:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread internal/services/ingest_live.go
Copilot AI balanced review requested due to automatic review settings October 6, 2026 17:50
@aditya1702
aditya1702 force-pushed the persist/6-hardening branch from ef6589c to ddb23ae Compare October 6, 2026 17:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread internal/services/ingest_live.go
Copilot AI balanced review requested due to automatic review settings October 6, 2026 19:13
@aditya1702
aditya1702 force-pushed the persist/6-hardening branch from ddb23ae to 8e16a14 Compare October 6, 2026 19:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aditya1702
aditya1702 force-pushed the persist/6-hardening branch from 8e16a14 to 56b4f42 Compare October 6, 2026 19:42
Copilot AI balanced review requested due to automatic review settings October 6, 2026 19:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread internal/services/ingest_test.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread internal/services/ingest_live.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

2 participants