Phase 1b: book_snapshot loop — mirror on-chain reserves + resting book into Postgres (freshness contract) #322
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:implement
agent:implement
agent:open_issues
agent:ready
agent:research
agent:security_audit
agent:verify
architecture
backend
blocker:hybrid
blocker:launch
blocker:limit-orders
blocker:v2
block:log_only
block:security
bug
ci
contracts
correctness
deploy
dev
devops
docs
documentation
duplicate
e2e
enhancement
epic
feature
frontend
functional-completion
gas
good first issue
governance
help wanted
high-risk
hooks
hybrid
indexer
infra
infrastructure
integrators
invalid
launch-blocker
limit-orders
localnet
localterra
low priority
missing-implementation
needs-design
ops
performance
priority
high
priority
medium
product
qa
QA
question
ready
ready
research
scripts
security
security-hardening
smartcontracts
tech-debt
testing
ux
UX
v2
verification
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-dex-terraclassic#322
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Phase 1a (#279, MR !761) landed the mirror schema: the
pair_reservesandresting_limit_orderstables, plus the query layer that reads and writes them. Nothing populates those tables yet — they're empty in production.Phase 1b is the writer: a background loop, modeled on the existing
run_oracle_loop/run_tier_sync_looppattern, that periodically queries each pair's on-chain pool reserves + fee and its resting limit book, and writes them into the Phase 1a tables (upsert_pair_reserves+replace_pair_resting_orders). It also defines and records a freshness contract (snapshot interval + max staleness viasnapshot_at/block_height) so Phase 1c can degrade-not-error when the mirror is stale or missing.This is the hard dependency that #319 (Phase 1c) blocks on: the 0-LCD solver can't read a mirror that nothing fills.
Current codebase
Phase 1a schema + query layer (done, MR !761):
indexer/migrations/20260605010000_pair_reserves.sql—pair_reservestable: PKpair_id,reserve_0/reserve_1NUMERIC(38,0),fee_bps SMALLINT, nullableblock_height BIGINT,snapshot_at TIMESTAMPTZ DEFAULT NOW().indexer/migrations/20260605010100_resting_limit_orders.sql—resting_limit_orders: PK(pair_id, order_id),sideCHECK in('bid','ask'),price NUMERIC(38,18),remaining NUMERIC(38,0), nullableowner/expires_at/block_height,snapshot_at. Walk indexidx_resting_orders_book (pair_id, side, price, order_id).indexer/src/db/queries/pair_reserves.rs—upsert_pair_reserves(pool, pair_id, reserve_0, reserve_1, fee_bps, block_height)does anINSERT … ON CONFLICT (pair_id) DO UPDATE … snapshot_at = NOW();get_pair_reserves(pool, pair_id) -> Option<PairReservesRow>(missing snapshot =None, degrade-not-error).indexer/src/db/queries/resting_orders.rs—replace_pair_resting_orders(pool, pair_id, block_height, &[RestingOrderInput])deletes the pair's rows and re-inserts the full book in one transaction;get_pair_resting_book(pool, pair_id, side)returns walk order (bids DESC / asks ASC, then FIFO byorder_id).RestingOrderInput { order_id, side, price, remaining, owner, expires_at }.indexer/tests/db_orderbook_mirror.rs.Loop / poller precedents to mirror:
indexer/src/indexer/oracle.rs—run_oracle_loop(pool, poll_interval_ms, latest_price)(line 18): builds a client, optional warm-load from DB, thenloop { … fetch … write … tokio::time::sleep(interval) }. Interval comes from config (oracle_poll_interval_ms), failures are logged and the last-good state is retained, never propagated as a hard error out of the loop.indexer/src/indexer/trader_tracker.rs—run_tier_sync_loop(pool, lcd, fee_discount_addr)(line 20):loop { sleep; if let Err(e) = sync_tiers(...).await { tracing::error!(...) } }.sync_tiers(line 39) pulls a row set, then per-row queries an LCD contract (query_contract::<serde_json::Value>) and upserts; a per-row LCD failure istracing::warn!-ed and skipped, not fatal.indexer/src/indexer/poller.rs—run_indexerregisters each loop viatokio::spawnwith clonedpool/lcd/ config fields (lines 30-46). This is where the new loop gets wired in.indexer/src/indexer/mod.rs— submodules are declared here (pub mod oracle;etc.); a newpub mod book_snapshot;goes alongside.On-chain query shapes the loop reuses (already exercised elsewhere):
lcd.query_contract::<PoolResponse>(pair_addr, json!({"pool": {}}))—PoolResponse { assets: [Asset; 2], total_share },Asset { info, amount }(indexer/src/lcd/types.rs:99-109; live use atindexer/src/api/orderbook_sim.rs:255-256). Fee viajson!({"get_fee_config": {}})->FeeConfigResponse { fee_config: { fee_bps, treasury } }(indexer/src/lcd/types.rs:111-120; live use inindexer/src/indexer/pair_discovery.rs:108-115).fee_bpscastsu16 -> i16exactly assync_single_pairalready does.json!({"order_book_head": {"side": side_label}})to get the head order id, then linkedlimit_orderlookups per order — seefetch_limit_book_pageinindexer/src/api/limit_book_lcd.rs:108-183(head at line 129-133, per-order at 159-172). The loop reuses this walk to produce the full per-side book, mapping each order intoRestingOrderInput.get_all_pairs(pool) -> Vec<PairRow>(indexer/src/db/queries/pairs.rs:199).LcdClientis#[derive(Clone)](indexer/src/lcd/mod.rs:31) and exposesget_latest_block_height()(indexer/src/lcd/mod.rs:160) for stampingblock_height.Solver side (downstream consumer, for context only — not touched here):
indexer/src/api/route_solver.rs—GET_DEFAULT_MAX_HOPS = 3(line 31),GET_POOL_ONLY_MAX_HOPS = 4(line 33).indexer/src/api/best_execution.rs—solve_global_best_executionoverMAX_PATH_CANDIDATES = 5(line 18),LCD_HYBRID_SIM_BUDGET(line 26). These still hit LCD today; Phase 1c rewires them onto the mirror this loop fills.Why this is needed
The whole 0-LCD hybrid solver program (#279) rests on the solver reading pool reserves and the resting book from Postgres instead of issuing per-request LCD calls. Phase 1a gave us the tables and accessors; without a writer they stay empty and Phase 1c (#319) has nothing to read. The LCD cost doesn't vanish — it moves out of the hot request path into one bounded background loop that amortizes it across all requests.
The freshness contract is the other half. The mirror is eventually-consistent by construction (snapshot cadence, not block-exact). Phase 1c's degrade-not-error semantics (
get_pair_reservesreturningNone, a stalesnapshot_at) only mean something if Phase 1b defines what "stale" is and stamps every row with the data needed to evaluate it.Constraints and guardrails
pool/get_fee_config/ book-walk LCD calls are expected and fine. What must stay bounded is total LCD per snapshot cycle — see acceptance below. This is the inverse of the solver-path budgets (LCD_HYBRID_SIM_BUDGETetc.), which Phase 1c drives toward zero.replace_pair_resting_orders(single transaction, delete-then-insert) so a pair's book is never observed half-updated.upsert_pair_reservesis already a single statement.sync_tiersshape: a failed pool/fee/book query for one pair istracing::warn!-ed and skipped; the loop keeps the last good snapshot for that pair (upsertnot run, rows not replaced) and moves on. A missing/failed snapshot for a pair leaves Phase 1c to degrade on it, not error.run_oracle_loop/run_tier_sync_loop, the spawned task logs and continues; it does not return an error that would take down the indexer.limit_order_placements/_cancellations/_fillsand the parser are out of scope; this loop only writes the two Phase 1a current-state tables.quote_kindrename, poisoned-mirror fidelity, and concurrent candidate evaluation all belong to Phase 1c (#319). The 4-hop bump (Phase 2) and the path-candidate budget rethink (#286) are out of both.Recommended direction
indexer/src/indexer/book_snapshot.rs, declared inindexer/src/indexer/mod.rs.pub async fn run_book_snapshot_loop(pool: PgPool, lcd: LcdClient, snapshot_interval_ms: u64)modeled directly onrun_oracle_loop: optional initial pass, thenloop { snapshot_all_pairs(...).await (logged on error); sleep(interval) }.async fn snapshot_all_pairs(pool, lcd):lcd.get_latest_block_height()to stamp this cycle'sblock_height(best-effort;Noneif it fails, since the column is nullable);get_all_pairs(pool), then for eachPairRow:poolquery -> mapassets[0].amount/assets[1].amounttoreserve_0/reserve_1(the pair'sasset_0/asset_1order; reuse the same asset-order conventionpair_discoveryestablished so reserves align withpairs.asset_0_id/asset_1_id);get_fee_config->fee_bps as i16; callupsert_pair_reserves.bid,ask) via theorder_book_head+ linkedlimit_orderpattern fromfetch_limit_book_page; collect intoVec<RestingOrderInput>(side="bid"/"ask", matching the table CHECK); callreplace_pair_resting_orders(pool, pair_id, block_height, &orders).tracing::warn!+ skip.book_snapshot_interval_ms, envBOOK_SNAPSHOT_INTERVAL_MS) next tooracle_poll_interval_msinindexer/src/config.rs, with a sane default. Document the chosen interval as the snapshot cadence half of the freshness contract.poller.rs::run_indexerwith atokio::spawncloningpool+lcd+ the interval, alongside the oracle / tier-sync spawns.docs/if there's a fitting runbook:snapshot_atTTL beyond which Phase 1c should treat a row as stale and degrade (fall back to LCD or mark the quote degraded). Define it as a multiple of the cadence so it tolerates one missed cycle.block_heightis recorded per snapshot so Phase 1c can reason about block-lag, not just wall-clock staleness.These three (cadence, TTL, recorded height) are the contract Phase 1c codes against; surface the TTL as a documented constant so 1c imports it rather than re-deriving it.
Acceptance criteria
book_snapshotbackground loop exists (moduleindexer/src/indexer/book_snapshot.rs), modeled onrun_oracle_loop/run_tier_sync_loop, and is registered viatokio::spawninpoller.rs::run_indexer.pair_reservesviaupsert_pair_reservesandresting_limit_ordersviareplace_pair_resting_orders(both sides).reserve_0/reserve_1per the pair'sasset_0/asset_1order;fee_bpsis sourced fromget_fee_configand stored asi16.side(bid/ask),price,remaining, and optionalowner/expires_at, and read back in walk order viaget_pair_resting_book.snapshot_at(via the existingNOW()defaults) and records the cycle'sblock_heightwhen available.book_snapshot_interval_ms/BOOK_SNAPSHOT_INTERVAL_MS) with a default, wired throughconfig.rs.Test plan
snapshot_all_pairspass against a mocked/faked LCD returning a knownPoolResponse,FeeConfigResponse, and a small two-sided book; assertpair_reservesandresting_limit_ordersend up with the expected rows (extendindexer/tests/db_orderbook_mirror.rsor a sibling test).get_pair_reservesreturns the populated row andget_pair_resting_book(pair, "bid"/"ask")returns the orders in DESC/ASC walk order — reusing the Phase 1a accessors so the writer and reader agree.get_pair_reservesfor it staysNone(or retains its prior row), and the cycle still completes for the other pairs.replace_pair_resting_ordersleaves no partial book on a mid-write failure (transaction rollback) — assert the pair's prior rows are unchanged.block_heightis stamped when the height query succeeds andNonewhen it doesn't, and that the documented max-staleness TTL constant is exported and non-zero.lcd_budget_is_documented_constantstyle test inbest_execution.rs).Related
8ea4bc1/5392f96).db_orderbook_sim+ rewire solver to read the mirror). 1c's degrade-not-error semantics consume this loop's freshness contract.mentioned in issue #279
mentioned in issue #319
marked as related to #319
mentioned in commit
6c5a09aceementioned in merge request !793
Implementation complete — MR !793
Opened !793 for #322.
What shipped
book_snapshotbackground loop (indexer/src/indexer/book_snapshot.rs) spawned frompoller.rspair_reserves+resting_limit_orderseach cycle for all indexed pairsBOOK_SNAPSHOT_MAX_STALENESS_MS(20s at default 10s cadence), block-height stamping, degrade-not-error semantics documented for Phase 1cBOOK_SNAPSHOT_INTERVAL_MS(default 10_000)book_snapshot_lcd_budget(pair_count, total_resting_orders)Verification (all PASS)
cd indexer && cargo test --lib book_snapshotcargo test --test book_snapshot_loop -j 1 -- --test-threads=1cargo test --test db_orderbook_mirror resting_book_replace_rolls_back -j 1 -- --test-threads=1Issue left open pending MR review/merge.
mentioned in commit
74c0449118Verification complete — #322
Independent QA pass after MR !793 merge (
74c0449). No repo changes required.Acceptance criteria
book_snapshotloop module +tokio::spawninpoller.rsindexer/src/indexer/book_snapshot.rs,poller.rsL50–55,mod.rspair_reserves+resting_limit_ordersfor all pairssnapshot_all_pairs→snapshot_single_pair; integrationsnapshot_populates_reserves_and_resting_bookreserve_0/reserve_1+fee_bpsfrom LCDpool+get_fee_configinsnapshot_single_pair; asserts inbook_snapshot_loop.rsget_pair_resting_bookassertsvec![101,102,100]bids,vec![201,202]asksbook_snapshot_lcd_budget()+BOOK_SNAPSHOT_LCD_*constants;lcd_budget_constant_matches_formulasnapshot_at+block_heightstampingblock_height: Some(12345)/Nonetestsdocs/runbooks/book-snapshot-mirror.md+BOOK_SNAPSHOT_MAX_STALENESS_MSsnapshot_skips_failed_pair_and_keeps_prior_snapshotBOOK_SNAPSHOT_INTERVAL_MS(default 10_000)indexer/src/config.rsTest plan
cd indexer && cargo test --lib book_snapshot::tests→ 2 passedcargo test --test book_snapshot_loop -j 1 -- --test-threads=1→ 4 passedcargo test --test db_orderbook_mirror -j 1 -- --test-threads=1→ 4 passedsnapshot_skips_failed_pair_and_keeps_prior_snapshotsnapshot_block_height_none_when_lcd_height_failsManual / code review
run_book_snapshot_looplogs cycle errors and sleeps; per-pairwarn!+ continueupsert_pair_reserves+replace_pair_resting_orders_in_txthen commitpair_reserves/resting_limit_ordersdocs/indexer-invariants.md, runbook linkedEnvironment: Postgres via
docker compose up -d postgres;TEST_DATABASE_URLfromindexer/.env(synced bysetup-postgres-dev-databases.sh).Closing as verified — implementation merged on
main.mentioned in merge request !798
mentioned in issue #323
mentioned in issue #556
mentioned in issue #684