Skip to content

Pre-prod hardening 1/4: schema & TimescaleDB foundation (destructive; fresh DB)#667

Open
aditya1702 wants to merge 8 commits into
mainfrom
hardening/pr1-schema-tsdb
Open

Pre-prod hardening 1/4: schema & TimescaleDB foundation (destructive; fresh DB)#667
aditya1702 wants to merge 8 commits into
mainfrom
hardening/pr1-schema-tsdb

Conversation

@aditya1702

@aditya1702 aditya1702 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

What

First of four pre-production hardening PRs (1/4 → this one, then #668 loadtest removal, #669 ingestion, #670 GraphQL/docs). This PR carries every destructive schema change — existing migration files are edited in place, so it must land before the production database is created. Everything here requires a fresh DB; the remaining PRs in the stack do not.

Why

wallet-backend is pre-release: this is the last window to fix schema-level issues without ALTER migrations. These changes come out of a full data-layer/TimescaleDB audit against a mainnet-data dev cluster (EXPLAIN plans and pg_stat/timescaledb_information measurements referenced in the commit messages).

Changes

  • Storage retune + idempotent TSDB policy config: balance-table storage blocks retuned from measured churn (fillfactor 80→90, drop the autovacuum_vacuum_cost_limit no-op — per-table cost_delay=0 already exempts the worker from cost balancing); retention/compression policy setup made idempotent and applied in every ingestion mode.
  • Time-pinned lateral probes for batch queries: the five dataloader batch queries carried no partition-column predicate, so each call decompressed all chunks (26–40s per batch on 9 days of mainnet data, growing with retention). They are now UNNEST + LATERAL per-key probes pinned to the parent's ledger time behind an OFFSET 0 fence: 2.5–18ms measured, constant with retention. Because relationship lookups pin on the parent row's ledger_created_at, every transaction/operation/state-change projection forces that column regardless of the client's field selection (regression-tested on minimal projections, end to end through the pinned child lookup). Also: bound array/limit parameters replace tuple IN-lists and interpolated LIMITs, root GetAll queries fail closed on non-positive limits, GetLedgerGaps scans only the requested window via chunk skipping.
  • NUMERIC amount columns + deterministic state_change IDs: TEXT numeric columns (sac/sep41 balances, allowance amounts) become NUMERIC — writes cast once at the boundary, accumulation SQL runs natively, readers cast back so the GraphQL String contract is unchanged. state_change_id switches from crypto/rand to deterministic per-(to_id, operation_id) ordinals in namespaced ranges, so re-ingesting a ledger produces byte-identical rows and duplicates fail loudly on the PK.
  • Per-processor sub-namespaces for state_change IDs: each emitting indexer processor (token transfers, effects, contract deploys, SAC events) numbers its own emissions inside its own sub-range (k<<28 within the indexer's namespace), declared via StateChangeSubBase() on the processor itself. IDs therefore never depend on processor registration/invocation order — adding or reordering processors cannot shift existing IDs, which would otherwise silently defeat the duplicate-insert PK backstop on replays. Within one processor, emission order derives from transaction meta (canonical on-chain data).
  • Chunk skipping on transactions.ledger_number for windowed gap detection.
  • Hypertable PK consolidation: PK column order matches the batch queries' access shape (equality column first, then the walk's sort key), making the separate API-sort indexes redundant — they're dropped; the TimescaleDB default partition-column indexes with zero consumers are dropped too.
  • TimescaleDB pinned to 2.28.2 (Dockerfile, CI workflows, docker-compose).

Deploy notes

  • Requires a fresh database (migrations edited in place; existing envs must be wiped + re-initialized).
  • Fresh-apply is exercised end-to-end by the integration suite (fresh TimescaleDB container + all migrations).

🤖 Generated with Claude Code

…licy config

Storage parameters (migrations edited in place, pre-release):
- fillfactor 80->90 on 14 upsert tables; measured churn (~0.1%/day on the
  large balance tables) uses half the 20% reserve, and COPY-loaded heaps
  carry the reserve permanently. liquidity_pools keeps 80 (every-row-per-day
  churn).
- autovacuum_vacuum_cost_limit removed everywhere: cost_delay=0 disables
  cost-based throttling entirely, making cost_limit a no-op.
- The aggressive autovacuum block now applies only to usage-scaling tables;
  protocol-bounded tiny blend tables use defaults.
- sep41_allowances drops its redundant owner index (PK-leading column) and
  unused spender index; the expiration index stays for the sweep with its
  non-HOT-update tradeoff documented.

Root query indexes: transactions/operations/state_changes replace the
auto-created single-column time index with composites matching the API
keyset sort, turning root first pages from full-chunk seq scan + heapsort
into an index walk.

TSDB runtime config: configureHypertableSettings now runs in every
ingestion mode (a backfill-first bootstrap previously ran TimescaleDB's
auto-created columnstore policy with unlimited maxchunks and no retention),
and retention/reconcile jobs converge in place via alter_job so job IDs and
run history survive restarts.
The five dataloader batch queries (BatchGetByStateChangeIDs on
transactions/operations, BatchGetByToIDs and BatchGetByOperationIDs on
operations/state_changes) carried no ledger_created_at predicate, so every
call hash-joined or band-joined against a full decompression of all chunks
— 26-40s per batch on 9 days of mainnet data, growing linearly with
retention. They are now UNNEST + CROSS JOIN LATERAL per-key probes with the
partition column pinned from the parent's ledger time, projecting only the
requested columns; the ORDER BY/LIMIT laterals sit behind an OFFSET 0 fence
so the chunk-selecting scan keeps runtime chunk exclusion (measured on the
dev replica: 2.5-18ms for 100-key batches with all 9 chunks excluded,
constant with retention). Tuple IN-lists and interpolated limits become
bound array/limit parameters. TransactionColumnsKey and the resolvers now
thread the parent ledger time into the loader keys.

Also: root GetAll queries fail closed on non-positive limits (a previously
overflowable path silently dropped the LIMIT clause on state_changes);
GetLedgerGaps takes the backfill window so gap detection scans only the
requested ledger range via chunk-skipping, with open trailing gaps clipped
to the window edge; the stateChanges txHash filter tolerates duplicate
hashes (IN instead of a scalar subquery); dead ProtocolsModel.GetClassified
removed.
…ange IDs

Numeric amounts, rates, and prices previously stored as TEXT (sac/sep41
balances, sep41 allowance amounts, the blend position/reserve/backstop/
emission/oracle columns) become unconstrained NUMERIC: writes cast once at
the ingestion boundary, accumulation SQL operates natively (no more
text->numeric->text round-trips per write), binary COPY encodes via
pgtype.Numeric (malformed decoder output now fails at write time), and
readers cast back to text so Go structs and the GraphQL String! contract
stay byte-identical.

state_change_id switches from crypto/rand to a deterministic ordinal per
(to_id, operation_id) assigned at emission in slice order, namespaced per
emitter (indexer base 0, SEP-41 1<<40, Blend 2<<40) because all three
write state changes for the same operations in independently-committed
transactions. Reprocessing a ledger now yields byte-identical rows, an
accidental duplicate insert fails loudly on the PK instead of silently
duplicating, and cursor ordering within an operation reflects emission
order.
…ap detection

EXPLAIN verification of the windowed GetLedgerGaps showed the window was
bounded only by the columnstore's batch min/max metadata — ledger_number
carried no chunk-skipping ranges, so no chunks were excluded at plan time
and every chunk paid at least a metadata pass. ledger_number rises
monotonically with the partition column, making it an ideal skipping
column: enabling it gives ledger-windowed queries plan-time chunk
exclusion (ranges are recorded as chunks compress). The GetLedgerGaps doc
comment now describes both bounding mechanisms accurately.
Three hypertables carried a secondary index over the same column set as
their primary key in a different order, and the two _accounts tables kept
TimescaleDB's auto-created single-column time index that no query path
uses. The PKs now use the column order their consumers need — the
_accounts tables as (account_id, ledger_created_at, id) so one index
serves both uniqueness and the account-history walk (B-tree scans run
backward for DESC pages), state_changes as (ledger_created_at, to_id,
operation_id, state_change_id) matching the root connection sort and
decomposed cursor — and the redundant secondaries and dead time indexes
are gone.

Every remaining index has a nameable consumer. The uncompressed hot-day
index working set drops from ~14GB to ~9.5GB and the two highest-insert-
rate tables halve their random-B-tree maintenance per row. Verified by an
EXPLAIN sweep of all sixteen consumer queries against the new layout on a
seeded multi-chunk database: every account walk and root page is an index
(only) scan with no sort or seq scan; reverse-lookup paths unchanged.

The operations doc gains the hypertable index-usage measurement caveat:
parent pg_stat_user_indexes always reads zero — usage accrues on chunk
indexes in _timescaledb_internal and must be aggregated from there.

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

Prepares the fresh pre-production database with optimized TimescaleDB storage, deterministic state-change identifiers, and partition-pinned queries.

Changes:

  • Retunes schemas, indexes, numeric storage, policies, and TimescaleDB version.
  • Reworks batch queries for chunk exclusion and bounded scans.
  • Adds deterministic state-change IDs and regression coverage.

Reviewed changes

Copilot reviewed 52 out of 52 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
internal/services/sep41/processor.go Assigns deterministic SEP-41 IDs.
internal/services/ingest_backfill.go Bounds ledger-gap detection.
internal/serve/graphql/resolvers/transaction.resolvers.go Passes transaction timestamps to loaders.
internal/serve/graphql/resolvers/transaction_resolvers_test.go Updates transaction fixtures.
internal/serve/graphql/resolvers/test_utils.go Shares fixture ledger timestamp.
internal/serve/graphql/resolvers/statechange.resolvers.go Passes timestamps for relationships.
internal/serve/graphql/resolvers/statechange_resolvers_test.go Updates state-change fixtures.
internal/serve/graphql/resolvers/resolver.go Extends relationship loader keys.
internal/serve/graphql/resolvers/operation.resolvers.go Pins operation state-change lookup.
internal/serve/graphql/resolvers/operation_resolvers_test.go Updates operation fixtures.
internal/serve/graphql/dataloaders/transaction_loaders.go Batches transaction timestamps.
internal/serve/graphql/dataloaders/statechange_loaders.go Batches partition-pinned state changes.
internal/serve/graphql/dataloaders/operation_loaders.go Batches partition-pinned operations.
internal/ingest/timescaledb.go Converges TimescaleDB policies and jobs.
internal/ingest/timescaledb_test.go Tests policy/job stability.
internal/ingest/ingest.go Configures hypertables in every mode.
internal/indexer/types/types.go Defines deterministic ID namespaces.
internal/indexer/types/types_test.go Tests ordinal assignment.
internal/indexer/processors/state_change_builder.go Documents deferred ID assignment.
internal/indexer/processors/contracts_test_utils.go Updates state-change comparison notes.
internal/indexer/indexer.go Assigns indexer state-change ordinals.
internal/db/migrations/2026-07-02.1-liquidity_pool_balances.sql Retunes balance storage.
internal/db/migrations/2026-07-02.0-liquidity_pools.sql Retunes pool autovacuum settings.
internal/db/migrations/2026-04-17.1-sep41_allowances.sql Uses NUMERIC and removes indexes.
internal/db/migrations/2026-04-17.0-sep41_balances.sql Uses NUMERIC and retunes storage.
internal/db/migrations/2026-01-16.0-sac-balances.sql Converts SAC balances to NUMERIC.
internal/db/migrations/2026-01-15.0-native_balances.sql Retunes native-balance storage.
internal/db/migrations/2026-01-12.0-trustline_balances.sql Retunes trustline storage.
internal/db/migrations/2025-06-10.4-statechanges.sql Reorders the state-change PK.
internal/db/migrations/2025-06-10.3-operations.sql Consolidates operation indexes.
internal/db/migrations/2025-06-10.2-transactions.sql Adds chunk skipping and indexes.
internal/data/transactions.go Adds bounded and time-pinned queries.
internal/data/transactions_test.go Tests transaction time pinning.
internal/data/statechanges.go Reworks state-change queries and IDs.
internal/data/statechanges_test.go Adds state-change regressions.
internal/data/sep41/balances.go Performs native NUMERIC arithmetic.
internal/data/sep41/allowances.go Casts allowance amounts at write/read boundaries.
internal/data/sac_balances.go Supports binary NUMERIC writes.
internal/data/query_utils.go Validates positive limits.
internal/data/protocols.go Removes unused classified-protocol lookup.
internal/data/operations.go Adds partition-pinned operation probes.
internal/data/operations_test.go Tests operation query changes.
internal/data/mocks.go Removes obsolete protocol mock method.
internal/data/ingest_store.go Bounds ledger-gap scans.
internal/data/ingest_store_test.go Tests bounded gap semantics.
docs/operations.md Documents hypertable index auditing.
docs/data-migrations/adding-a-protocol.md Updates storage guidance.
Dockerfile-timescale-cnpg Pins TimescaleDB 2.28.2.
docker-compose.yaml Updates local TimescaleDB image.
.github/workflows/go.yaml Updates CI TimescaleDB image.
.github/workflows/build-cnpg-timescaledb-stg.yml Updates staging image defaults.
.github/workflows/build-cnpg-timescaledb-dev.yml Updates development image defaults.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/serve/graphql/resolvers/transaction.resolvers.go
Comment thread internal/serve/graphql/resolvers/transaction.resolvers.go
Comment thread internal/serve/graphql/resolvers/operation.resolvers.go
Comment thread internal/serve/graphql/resolvers/resolver.go
Comment thread internal/serve/graphql/resolvers/resolver.go
Indexer state_change_ids previously depended on the order emitting
processors are registered and invoked in: all streams were merged into
one slice and numbered together, so inserting or reordering a processor
would shift every later processor's ordinals for operations where both
emit. Replaying an already-persisted ledger with such a binary would
produce different IDs and silently bypass the duplicate-insert PK
backstop.

Each emitting processor now numbers its own emissions independently and
adds its slot from the sub-namespace registry (k<<28 inside the
indexer's 1<<40-wide namespace: token transfers 0, effects 1, contract
deploys 2, SAC events 3), declared via StateChangeSubBase on the
processor itself so the slot travels with the processor rather than the
registration site. Within one processor, emission order derives from
the transaction meta — canonical on-chain data — so IDs stay
reproducible across runs and code versions regardless of how the
processor list evolves. Protocol emitters (SEP-41, ...) are
single-stream and keep using their emitter base directly.

Account-less state changes are now filtered per stream before ordinal
assignment (previously after merging), which also stops them from
being counted as a phantom "" participant in the participant metric.
…ection

The time-pinned relationship lookups key on the parent row's
ledger_created_at. Projections built from client field selections only
carried it when the client happened to request that scalar (only
BatchGetByAccountAddress forced it), so e.g. `transaction { operations }`
without ledgerCreatedAt selected pinned the child lookup to the zero time
and returned an empty connection. Every transaction/operation/state-change
projection now forces the partition column, exactly like the key columns
already were; regression tests cover the minimal-projection paths and the
pinned child lookup end to end.
// Callers must pass one emission stream at a time — the final, filtered slice
// actually handed to BatchCopy — so ordinals come out contiguous (1..N, no
// gaps) per group within the stream's namespace.
func AssignStateChangeOrdinals(changes []StateChange, base int64) {

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.

Grouping ordinals by OperationID alone doesn't match the contract the registry promises — and the gap silently defeats the PK backstop for window-scoped emitters.

The namespace registry comment above says each emitter "numbers its own state changes 1..N in deterministic emission order within an (to_id, operation_id) group (see AssignStateChangeOrdinals)", but the implementation keys the counter map on OperationID only:

next := make(map[int64]int64, len(changes))
for i := range changes {
    opID := changes[i].OperationID
    next[opID]++
    changes[i].StateChangeID = base + next[opID]
}

Both current emitters are safe only incidentally:

  • the indexer assigns per-transaction stream, so ToID is constant within a call;
  • SEP-41 stages across a multi-ledger window, but its opID = toid(ledger, txIdx, opIdx+1) embeds the transaction and is never 0, so an OperationID group can never span ToIDs.

Nothing enforces either property at the call sites. The failing usage is exactly what the documented contract invites: a future window-scoped emitter (the Blend base is already reserved) staging transaction-level changes with OperationID = 0 for several transactions in one window. Those all share one counter bucket, so a given row's ordinal depends on which other ledgers happen to be in the window. Re-ingesting the same ledger under different window bounds (live vs. backfill, or a re-run migration) then produces different state_change_ids for the same logical rows — BatchCopy inserts silent duplicates instead of failing on the PK, which is precisely the corruption this scheme exists to surface.

Fix is one line of shape change — key the map on the pair, which also makes the code match its own doc:

type ordinalKey struct{ toID, opID int64 }

next := make(map[ordinalKey]int64, len(changes))
for i := range changes {
    k := ordinalKey{changes[i].ToID, changes[i].OperationID}
    next[k]++
    changes[i].StateChangeID = base + next[k]
}

(The function doc's "within each distinct OperationID group" wording would want the same update.)

Worth doing in this PR rather than later: once rows exist, any emitter that ever hit the single-key bucket has persisted IDs that a re-run under the fixed grouping won't reproduce — cheap now, messy after.

Comment thread internal/ingest/ingest.go
// run under TimescaleDB's auto-created columnstore policy defaults (unlimited
// maxchunks, no retention). All settings applied here are idempotent, so
// running this on every start (live or backfill) is safe.
if err := configureHypertableSettings(ctx, dbConnectionPool, cfg.ChunkInterval, cfg.RetentionPeriod, cfg.OldestLedgerCursorName, cfg.CompressionScheduleInterval, cfg.CompressAfter, cfg.MaxChunksToCompress); err != nil {

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.

Removing the IngestionModeLive guard here trades a per-invocation property (idempotence) for one this call doesn't have: convergence across processes with different flags.

The new comment says "All settings applied here are idempotent, so running this on every start (live or backfill) is safe." That's true for one process re-running with the same flags — but the policies live in shared database state, and every process start now imposes its own flag values on them. A backfill job is typically launched ad hoc, without the live deployment's flag set, and the empty/zero defaults are interpreted as "disable"/"skip" rather than "leave unchanged". Three concrete failure modes fall out:

  1. Backfill without --retention-period strips live retention. The flag defaults to "", and configureRetentionPolicy treats empty as disable: remove_retention_policy(...) runs on every hypertable, and configureReconciliationJob("") executes delete_job on reconcile_oldest_cursor. A one-off backfill on a production database silently disables retention and cursor reconciliation until the next live pod restart re-creates them — meanwhile chunks are never dropped and oldest_ingest_ledger goes stale.

  2. Backfill with retention drops the history it's writing. The policy is now installed at startup, before the backfill writes. Backfilling a range older than RetentionPeriod (e.g. 90 days of history under a 30-day policy — the bootstrap case the new comment is motivated by) runs under an active policy_retention job that drops the freshly written chunks: the backfill logs success, ~60 days of it vanish. The progressive recompressor's compress_chunk($chunk) calls also race the concurrent chunk drops and error out, failing batches.

  3. A backfill's compression cap ratchets live jobs down permanently. --compression-max-chunks 10 writes maxchunks_to_compress = 10 into every compression job's config, but the write is gated on maxChunksToCompress > 0 — so when live restarts with its default of 0, the block is skipped and the cap is never removed. Live compression is permanently limited to 10 chunks per run until someone alter_jobs by hand.

One fix shape covers all three. Either:

  • restore the mode guard (backfill never touches policies), which gives up the stated motivation — a backfill-first bootstrap running under TimescaleDB's auto-created policy defaults; or
  • keep the unconditional call but make it convergent: empty/zero flag values mean "leave existing configuration unchanged" instead of the current asymmetry where "" means disable for retention but 0 means skip for maxchunks. The bootstrap concern is then handled explicitly — e.g. only apply defaults-sensitive settings when the policy/job doesn't exist yet, so a backfill can create missing policies but never mutate or remove ones the live deployment configured.

The second keeps the bootstrap win and removes the operational footgun; the first is smaller but reintroduces the "backfill-first bootstrap runs uncapped" gap this PR set out to close.

}
}
columns = prepareColumnsWithID(columns, types.StateChange{}, "sc", "to_id")
columns = prepareColumnsWithID(columns, types.StateChange{}, "sc", "to_id", "ledger_created_at")

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.

The forced-column set here is missing the id columns the state-change resolvers build their loader keys from — nested operation { ... } silently resolves to null on minimal selections.

This line (correctly) forces to_id and ledger_created_at into the projection for the new time-pinned child lookups, but its sibling BatchGetByAccountAddress forces the full key set:

// BatchGetByAccountAddress (line ~35)
columns = prepareColumnsWithID(columns, types.StateChange{}, "", "to_id", "operation_id", "state_change_id", "account_id", "ledger_created_at")

// BatchGetAccountStateChangesByToIDs (this line)
columns = prepareColumnsWithID(columns, types.StateChange{}, "sc", "to_id", "ledger_created_at")

operation_id and state_change_id aren't just data fields — every state-change resolver feeds them into its dataloader key (resolver.go:136):

stateChangeKey := fmt.Sprintf("%d-%d-%d", toID, operationID, stateChangeID)

So for an account-scoped query whose selection doesn't happen to include a field mapping to operation_id — e.g. account → transactions → stateChanges { type, amount, operation { id } } — the rows come back with OperationID = 0, the loader key is "<toID>-0-0", and the operation lookup probes o.id = 0: no match, operation resolves to null even though it exists. As a second-order effect, all state changes of that transaction collapse onto the same "<toID>-0-0" key, so the dataloader dedups them into one shared (null) result.

Fix is to match the sibling's forced set on this line:

columns = prepareColumnsWithID(columns, types.StateChange{}, "sc", "to_id", "operation_id", "state_change_id", "account_id", "ledger_created_at")

Since this PR already added regression tests for minimal projections end-to-end through the pinned child lookup, extending one of those to assert operation { id } is non-null on a minimal account-scoped state-change selection would lock this in.

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.

3 participants