Skip to content

Fix the DB query-duration buckets, the oldest-ledger index path, and compression scheduling - #686

Open
aditya1702 wants to merge 96 commits into
blend/pr6-integration-testsfrom
ingest-db-observability
Open

Fix the DB query-duration buckets, the oldest-ledger index path, and compression scheduling#686
aditya1702 wants to merge 96 commits into
blend/pr6-integration-testsfrom
ingest-db-observability

Conversation

@aditya1702

@aditya1702 aditya1702 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Three independent fixes, one commit each — reviewable in any order. Review time: ~15 min. One manual-DDL warning below.

Found while profiling live ingestion: a metric that clipped exactly the queries being optimized, a missing index that made an hourly job 45% of all DB disk reads, and five compression policies firing at the same instant.

1. Query-duration buckets clipped at 0.38 s

wallet_db_query_duration_seconds topped out at 0.38 s: every multi-second bulk COPY landed in +Inf and p99 panels flattened. Widened to 0.1 ms – ~17.7 s.

2. Oldest-ledger lookup had no ordered path

SELECT ledger_number FROM transactions ORDER BY ledger_created_at ASC LIMIT 1 (backfill gap detection + the hourly reconcile_oldest_cursor job) had no index to walk — an earlier audit dropped TimescaleDB's default partition-column index after counting only WHERE-clause consumers, missing that this query consumes the index's ordering. The planner pulled the first row from every chunk: 12–187 s on an 87 GB DB; the hourly job was 45% of all DB disk-read time.

Fix: keep the default index; drop the to_id tie-break from both query sites (close times increase strictly — the tie-break could only force an extra sort). Tested by running the reconciliation job against out-of-order chunks.

Warning

Manual DDL on already-migrated databases (fresh DBs get the index from create_hypertable). Applied on the loadtest DB; pending on dev/staging/prod:

CREATE INDEX transactions_ledger_created_at_idx ON transactions (ledger_created_at DESC);

Note

2025-06-10.2-transactions.sql (already shipped) is edited in place, not superseded. Deliberate: wallet-backend is pre-release, and the baseline files stay the single readable statement of each table's final shape.

3. Compression policies all fired at once

All five hypertables' columnstore policies were auto-created on identical schedules and compressed their just-closed chunks concurrently — an I/O storm that took persist p50 from 0.68 s to 3.0 s for 18 of every 60 minutes on the rig. Each policy is now anchored to a distinct slot offset on the schedule grid, so at most one fires at a time; jobs already on their slot are untouched across restarts.


Fourth of four PRs from the loadtest campaign (replaces #682). Independent — branches directly off blend/pr6-integration-tests, shares no code with #679/#684/#685, mergeable anytime. Combined rig result: 18,458 tx/s, process p99 0.992 s; PRs not separately re-benchmarked.

A concurrent protocol-migrate engine snapshots membership from committed
protocol_contracts rows only, so when it wins a ledger's cursor swap it
has folded that ledger without contracts whose classification commits
with live's in-flight transaction — their deploy-ledger state (constructor
ContractData writes, first events) was extracted by nobody, and
cursor-passed ledgers are never replayed. Never-rewritten keys stayed
missing forever.

On a lost swap where this ledger classified new contracts for the
protocol and the lost cursor's committed value is at or past this ledger
(a lost CAS blocked on the winner's row lock, so that value is reliably
visible in-transaction via the new IngestStore.GetInTx), live now
re-stages scoped to exactly the gap contracts and persists the lost
halves in the same transaction, without moving any cursor. The
CAS-winning path is extracted into stageAndPersistProtocolLedger
alongside the new repairClassificationGap.

Covered by CAS-gating cases M1-M4: frontier repair, behind-tip
non-repair, partial-loss scoped repair, and already-committed exclusion.
…r is fetched

The engine refreshed a tracker's classified-contract membership right
after each window commit — milliseconds before a concurrent live
transaction for that same contested ledger finishes committing a new
contract's classification, since live's lost CAS was blocked behind the
engine's row lock and still has work to do after unblocking. The engine
then staged the next window with a snapshot missing that contract,
skipping its events and entries for one more ledger; additive fold
columns (cost basis, lifetime claimed totals) never heal from a missed
ledger, so those deltas were permanently lost whenever the engine also
won that next ledger.

The refresh now runs at window start, after GetLedger returns for the
window's first ledger: the fetch blocks until that ledger has closed,
which is a full ledger interval after any concurrent transaction for the
previous one committed. Same cadence — once per window per requiring
tracker — with the read taken at the latest useful moment. The refresh
test now pins the ordering: the first folded ledger must already carry
membership committed after the run-start snapshot (mutation-verified —
removing the window-start refresh fails it).
…sons

Blend state changes follow the core convention: category names the
on-chain object (BLEND_SUPPLY, BLEND_COLLATERAL, BLEND_DEBT,
BLEND_AUCTION, BLEND_EMISSIONS, BLEND_BACKSTOP_EMISSIONS,
BLEND_BACKSTOP, BLEND_BACKSTOP_QUEUE) and reason names the action.
Amount-bearing categories reuse the generic CREDIT/DEBIT/ADD/REMOVE/
BURN verbs; only BORROW, REPAY, FLASH_LOAN, BAD_DEBT, FILL, and CLAIM
are added.
…keys

Postgres UPDATE ... FROM applies only one matching source row per target
row, so duplicate (pool, user, asset) keys in a batch silently dropped
deltas. Net deltas reject duplicates (ZeroBorrowed makes merging
order-dependent; the processor pre-aggregates); auction adjustments are
purely additive and are summed server-side before applying.
…odels

Add blend_pool_claimed (pool, user) and blend_backstop_claimed (user) tables
plus PoolClaimedModel/BackstopClaimedModel with additive BatchApplyDeltas. These
hold lifetime claimed BLND / Comet LP totals, folded from claim events during
current-state indexing — the only pass that sees every claim since Blend's first
ledger. Mirrors the net_supplied/net_borrowed cost-basis accumulator.
…account_id

Both tables are read by GetByAccount (the per-user positions path) filtering on
user_account_id, which is the second PK column and so cannot use the primary key.
Add single-column B-tree indexes mirroring idx_blend_positions_user, keeping the
index defined alongside its table in the same migration.
ApplyAuctionAdjustments converted protocol tokens to underlying with exact
numeric division, leaving a fractional tail (e.g. "1100.0000000000000000") in
net_supplied/net_borrowed while every other write to those columns stores floored
integer text. The contract uses fixed_mul_floor (floor of the positive magnitude).
Wrap the conversion in trunc(): for the signed lot/bid deltas, trunc toward zero
reproduces floor-of-magnitude-with-sign (trunc(-366.3) = -366, not floor's -367).
Adds a subtest covering a fractional product on both the positive (filler) and
negative (liquidated user) sides.
… writers

BatchApplyNetDeltas and ApplyAuctionAdjustments mutate existing rows only; a delta
for a not-yet-inserted position row silently no-ops. Document that callers must
upsert the Positions snapshot (and reserves) first, as PersistCurrentState does.
The full-snapshot upsert/zero writers overwrote last_modified_ledger outright while
the additive writers (net deltas, claimed, reserve data, reward zone) already use
GREATEST. Switch the snapshot writers to GREATEST(<table>.last_modified_ledger,
EXCLUDED/u.ledger) too, so the column never moves backward and every writer treats
it uniformly. Behavior is unchanged under the strictly ledger-ordered persist path.
StartBlock and LastModifiedLedger were int32 while every other blend row struct
uses uint32 for ledger-valued fields, casting to int32 only at the write boundary.
Align Auction with that convention.
…ments

Flooring doesn't distribute over addition, so n fills folded into one
(pool, user, asset) row overstate magnitude by at most n-1 stroops versus
per-fill flooring. The existing duplicate-key test uses amounts exact at
the fixture rates, so it cannot tell the two orders apart; this one can
(two fills of 5 at b_rate 1.1: 11 aggregated vs 10 per-fill). The doc on
applyAuctionAdjustmentsSQL states why the single trunc on the summed
delta is kept: the skew is strictly below the end-of-window rate
approximation already accepted on the same display-only fields.
The Down re-adds the narrowed CHECKs as NOT VALID, which is precisely the
modifier that skips checking existing rows — so the stated precondition
(no BLEND_* rows) was a comment, not a guarantee, and a rollback over
Blend data left rows the restored constraints forbid: unwritable, and
fatal to whole stateChanges pages in the pre-Blend resolver. A DO block
now probes both the category and reason lists (bloom sparse indexes serve
both) and raises before touching any constraint. All four DROP
CONSTRAINTs gain IF EXISTS so both directions are re-runnable. The Up
keeps NOT VALID: validating would scan every columnstore chunk under an
AccessExclusiveLock.
…claim token

Verified against blend-contracts-v2 @ ba22b487:

fill_auction (pool/src/auctions/*.rs): fill_bad_debt_auction moves the bid
dTokens from the backstop's Positions to the FILLER's — the filler assumes
the debt — while the lot (backstop LP tokens) is drawn straight to the
filler's wallet and never touches pool Positions. fill_interest_auction
settles entirely outside pool Positions (bid donated to the backstop, lot
paid from the reserves' backstop_credit, captured by the ResData entry
snapshot). The decoder previously folded the user side of every asset for
all auction types and mirrored to the filler only for type 0: a type-1
filler's net_borrowed missed the assumed debt, and type-2 fills fabricated
lot adjustments against the backstop-address row (in the wrong units —
underlying, valued as bTokens). Folds now mirror the on-chain Positions
moves exactly: type 0 both sides, type 1 bid-only both sides, type 2 none.

backstop claim (backstop/src/contract.rs -> emissions/claim.rs): the event's
amount is execute_claim's return — the Comet LP tokens minted and
auto-deposited (per-pool deposit events are emitted alongside) — never raw
BLND. The row's token_id is now NULL with units backstop_lp in key_value,
matching every other backstop-LP-denominated row, instead of mislabeling
LP amounts as BLND.
Decode a ClaimFold from each pool/backstop claim event and accumulate it into
the staged pool/backstop claimed-total maps during current-state indexing,
persisting via PoolClaimed/BackstopClaimed.BatchApplyDeltas. History mode is
unchanged — it still records the CLAIM feed rows but folds no totals.
… swap caveats

Audit of the decoders/processor against blend-contracts-v2 @ ba22b487
found no behavioral gaps; this records the three residual findings:

- test: a backstop claim op also emits one genuine deposit event per
  claimed pool (auto-restake) — 1 CLAIM + N BACKSTOP_DEPOSIT rows, a
  single account-wide claimed-total fold, no double count
- godoc: TTL-evicted temporary Auction entries are invisible to
  ingestion (tx-meta-only reads), so an unfilled expired auction leaves
  a stale blend_auctions row
- godoc: an emitter backstop swap requires updating the canonical
  backstop pin and migrating backstop-derived state
aditya1702 and others added 23 commits August 7, 2026 14:15
ToAPY guarded only the negative base; the positive side overflows
math.Pow to +Inf once borrow APR exceeds ~2,186 at 365 daily periods, and
ir_mod — an unclamped i128 read straight from permissionless reserve
config — can push APR there. gqlgen cannot marshal Inf/NaN (the field
errors and strict clients drop the whole response), and a zero USD side
multiplied into an Inf APY turns netApy into NaN.

ToAPY now reports non-finite results via an ok flag, computeReserveRates
carries *float64 APYs (nil when unrepresentable), and both USD-weighted
aggregates treat a nil APY on a priced reserve as uncomputable — netApy/
interestApy go null rather than silently weighting that reserve at 0%
yield, the same convention a missing oracle price follows. Covered by
boundary tests at the 2,186/2,187 threshold and resolver tests that seed
a poisoned ir_mod and assert null APYs with no GraphQL error.
buildPoolPosition's contract is that totals go nil rather than silently
understate, but its rp == nil branch — a position whose blend_reserves
row doesn't exist — continued without clearing the known flags, shrinking
suppliedUsd/usdValue while omitting the reserve from the response. Worst
case a live position rendered as usdValue 0 with reserves: [],
indistinguishable from a closed one. The case is reachable: a staging
window carrying a new reserve's ResConfig without its ResData writes no
row (BatchUpdateConfig is a plain UPDATE), so positions can precede their
reserve. The branch now clears both flags, matching the missing-price
rule; buildReservePosition's doc drops its 'shouldn't happen in practice'
framing for the real mechanism, and a regression test pins the nil
propagation (mutation-verified: restoring the bare continue fails it).
…ot zero

An emissions-only write creates the pool's blend_backstop_pools row with
zero balance columns (BatchUpsertEmissions inserts just the emis_*
columns; the DDL defaults shares/tokens/q4w to '0'), and the balance half
lands independently — so a user provably holding shares could render
lpTokens "0" / usdValue 0, indistinguishable from a closed position, with
a stale emission index silently applied to their real shares. The row's
mere existence proved nothing.

buildBackstopPosition now keys the shares→LP conversion on the rate being
known (poolShares > 0; a user holding shares proves deposits exist, so
zero pool shares is always a data gap): lpTokens and usdValue go null —
lpTokens becomes nullable in the schema — and every Q4W entry follows the
same rule; emissionsEarnedBlnd keeps the stored-index floor. The pool
catalog's backstopUsdForPool applies the matching rule: zero balances
beside a populated emission half read null (backstop emissions only crank
for reward-zone pools, which requires deposits), a missing row stays a
genuine $0, and zero tokens with nonzero shares (a backstop fully drawn
for bad debt) stays a genuine $0. Regression tests cover the
emissions-only row, the missing row, and the catalog split; the gate is
mutation-verified.
…on fan-out

One blendPositions resolution issues 13 DB queries (errgroup waves of
6+4+3, no dataloader by design) and one blendPool resolution 7, but both
fields sat at the default 1+childComplexity — so an account-history page
selecting stateChanges → account → blendPositions priced at ~500 while
executing 13 queries per state-change row against a pgxpool of
MaxConns=10. The fields now carry their query count as an additive flat
cost (+13 / +7), which keeps the no-double-charge property for list
depth while charging nested repetition its real fan-out — the amplified
shape now prices at 1,701 (pinned by a new test case). The three
blendPositions waves are also SetLimit-capped (4/4/3) so a single
request can never drain the shared connection pool.
Blend pool/positions/earn-options query builders and client methods, plus
client-side DTOs and inline fragments for the eight concrete Blend
state-change types, registered through the generic unmarshal dispatch and
the schema-validation test.
…or tables

Read blend_pool_claimed/blend_backstop_claimed via the new GetByAccount readers
after phase-2 live ingestion and assert the supplier's pool claim and whale's
backstop claim folded positive totals. Guard that phase-1 (no claims) leaves both
accumulator tables empty.
…s survive

The canonical-backstop pin resolves to empty on the standalone network,
which made the processor drop every backstop-shaped entry and event as a
non-canonical impostor — BlendMigrationTestSuite failed on the whale's
missing blend_backstop_positions row.

The suite deploys the backstop from the master account (keypair.Root of
the passphrase) with a fixed salt, so its address is a deterministic
function of the passphrase alone. Pin it in canonicalBackstopAddress the
same way blndTokenAddress pins the standalone BLND SAC: unit test + a
deploy-time assertion in SetupBlendStack.
Absorbs the review-round API changes on blend/pr5-graphql: drops the
GetBlendEarnOptions client method, query, and types (the query was
removed - earn discovery composes from blendPools); BlendPool.status
becomes the BlendPoolStatus enum string; BlendReservePosition's single
emissionsApr splits into emissionsSupplyApr/emissionsBorrowApr.
Claimable-emission assertions were already sign-based, so the
projected-to-now claimable math needs no test changes.
executeSorobanOperationAs was a near-verbatim copy of
executeSorobanOperation that fixed two real defects only in the copy:
it preserved simulation-assigned auth nonces (nonce 0 is one-shot per
address) and re-simulated after signing so MinResourceFee reflects the
signed entries' size. One executeSorobanOperation(op, source,
extraSigners, retries) now carries both behaviors for every caller.

Sequence resolution branches on the source: the master account drives
SharedContainers' locally tracked counter, any other actor's sequence is
fetched from RPC. Since every master submission now advances the local
counter, the As-path drift SyncMasterSequence existed to repair can no
longer occur, so it and its re-sync call sites are removed.

Auth entries an available keypair cannot sign now fail loudly naming the
required address (the old shared path silently left them unsigned);
source-account-credentialed entries pass through unchanged as before.
…mit for them

The wbclient full-detail account-history query measures 10,101 at
first:100 — over the previous 10,000 default — because the shared
stateChangeFragments const gained 8 Blend fragments (+27 fields per
state-change node, ×100 edges). The regression test could not catch this:
it asserted a hand-copied mirror of the SDK query that had drifted (no
Blend fragments, aliases the builder never emits), and the integration
container overrode the limit to 30,000.

pkg/wbclient now exports Queries(), the exact documents the client
sends, and both server-side guards consume it: schema validation (which
previously missed the three Blend queries) and the complexity regression
test, which prices every SDK query at the largest accepted page size
against the flag's own FlagDefault. Measured: BlendPools=26,150,
full-detail history=10,101, state-change queries=8,401,
blendPositions=7,583.

The default limit rises to 30,000, documented as sized for the SDK's
heaviest shipped queries with the DoS tradeoff stated (deployments not
serving Blend should lower it). The integration container no longer
overrides GRAPHQL_COMPLEXITY_LIMIT, so the suite proves the shipped
default serves the SDK's full-selection queries end-to-end.
The wallet_db_query_duration_seconds histogram topped out at 0.38s
(ExponentialBuckets(0.0001, 2.5, 10)), so every multi-second bulk COPY
landed in +Inf and histogram_quantile panels clipped at ~0.38s,
under-reporting exactly the queries being optimized. Widen to
ExponentialBuckets(0.0001, 3, 12) — 0.1ms through ~17.7s.
…-ledger lookup

The oldest-ledger lookup (SELECT ledger_number FROM transactions ORDER BY
ledger_created_at ASC LIMIT 1) has two consumers — backfill gap
detection's left bound (GetOldestLedger) and the hourly
reconcile_oldest_cursor TimescaleDB job — and neither had an ordered
path: the transactions migration dropped TimescaleDB's default
partition-column index as consumer-less, an audit that counted
WHERE-clause consumers and missed that this query consumes the index's
ordering. Without it the planner pulls the first row from every chunk
instead of running an ordered ChunkAppend that stops at the oldest
(12-187s on an 87GB DB; the hourly job alone accounted for 45% of all DB
disk-read time and flushed shared_buffers every run).

Keep the default index (fresh databases get it from create_hypertable;
already-migrated environments need a one-time manual
`CREATE INDEX transactions_ledger_created_at_idx ON transactions (ledger_created_at DESC)`)
and drop the to_id tie-break from both query sites: close times increase
strictly, so rows sharing a ledger_created_at carry the same
ledger_number and the tie-break could only force an incremental sort on
top of the index's pathkeys. Covered by a new test that runs the
reconciliation job via run_job against out-of-order chunks.
…the schedule interval

Each hypertable's columnstore policy is auto-created with an identical
schedule, so all five fire at the same instant and compress their
just-closed chunks concurrently — an I/O storm that starves the persist
stage (measured on the loadtest rig: persist p50 0.68s -> 3.0s for 18 of
every 60 minutes). Converge each policy onto a fixed schedule anchored to
the interval grid with a distinct per-table slot offset, so at most one
policy comes due at a time. Jobs already on their slot are left untouched
across restarts, preserving next_start and run history.
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