Indexer resilience: ingestion task fails silently + RPC calls have no timeouts (launch-day risk) #168
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:open_issues
agents
agent:security_audit
agent:verify
area
timecurve
arena
arena-v2
audit
block:feature_review
block:security
bug
contracts
design
devops
docs
documentation
enhancement
epic
feature
frontend
indexer
low priority
megaeth
operations
ops
performance
QA
qa checklist
referrals
removal
security
smart-contracts
tech-debt
testing
time-arena
timecurve
visual-qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
code/yieldomega#168
Loading…
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?
two tightly-coupled indexer resilience gaps surfaced during a code review pass against
390f9be. both are launch-day failure modes that compound — RPC hang triggers ingestion silent exit, ingestion silent exit means HTTP API serves stale data forever, no path to recover without process restart.Gap 1 — ingestion task fail-silent in
main.rs:31-35spawned task swallows errors with just
tracing::error!and exits cleanly:after exit, the HTTP API keeps responding 200 OK to all
/v1/*reads, just from frozen state.ingestion_handleis not awaited or supervised./v1/statusexposesmax_indexed_blockbut no client reads it as a liveness check, and there is noingestion_alive: boolorlast_indexed_at_msto alert against.failure modes that trip this:
get_block_by_number(any?bubble)risk profile: sale ends, indexer dies mid-finalization, frontend shows stale podiums, nobody notices for hours.
fix shape: either (a) wrap ingestion::run in a backoff-retry loop that re-enters on Err, or (b) propagate the Err to main and let the process supervisor restart the binary. additionally surface
ingestion_alive: boolandlast_indexed_at_ms: u64in/v1/statusso smoke tests can alert on staleness.Gap 2 — RPC calls have no per-call or transport-level timeout
chain_timer.rs:138andingestion.rs:92both build providers without timeouts:no
.timeout(...)on the builder, notokio::time::timeout(...)wrapper on any of the downstream calls. default reqwest behavior is no timeout.chain_timer::poll_oncedoes 9 sequentialeth_calls per tick (saleStart, deadline, timerCapSec, ended, podium(0..3)); a single hung call freezes the snapshot at the previous value. ingestionget_block_by_number(next)with no timeout blocks the entire indexer.fix shape: wrap each RPC await in
tokio::time::timeout(Duration::from_secs(5), ...)(or set a transport-level timeout viareqwest::Client::builder().timeout(...)and pipe throughClientBuilder::on_provider). also:poll_once9 sequential calls would benefit from a multicall or paralleltry_join!, but the timeout fix is the priority.Why these are coupled
gap 2 alone gives a stalled-but-alive ingestion task — observable through metrics if you look. gap 1 alone gives a clean failure path that should never trigger. together: RPC hangs → first call eventually times out at the OS level (minutes/hours), ingestion
?bubbles → fail-silent log → indexer is now a stale-data zombie with no signal to operators.closing either alone halves the risk. closing both makes the indexer self-recovering through normal ops.
Priority
gap 1 is the single highest-impact launch-day fix — operator visibility into "is ingestion actually running" goes from zero to definitive. gap 2 is one-day defense-in-depth that mitigates the most common cause of gap 1 firing.
Out of scope
this is distinct from #156 (production config fail-closed), #157 (raw DB error redaction), and the existing #138 children — those covered config + error-message hygiene, not runtime resilience of the ingestion task itself.
cc @PlasticDigits
mentioned in commit
c90bca7149mentioned in commit
74967bc5fdImplementation merged (main @
74967bc)What changed
ingestion::runis wrapped in a loop inmain.rswith exponential backoff (1s base, 60s cap) on fatal errors so the HTTP API does not keep serving frozen Postgres after a background task exit.reqwest::Clientis built withINDEXER_RPC_REQUEST_TIMEOUT_SEC(default 5, clamped 1–120) viarpc_http::reqwest_http_providerfor ingestion and the chain-timer poller (chain_timer.rs).GET /v1/status(schema ≥ 1.16.1) addsingestion_aliveandlast_indexed_at_ms(0 if no block committed yet).ingestion_aliveis false when ingestion is API-only idle (INGESTION_ENABLEDoff or no registry addresses).Docs / invariants
INV-INDEXER-168indocs/testing/invariants-and-business-logic.md, indexer README,docs/indexer/design.md,skills/README.md, guardrails skill.Verify (checklist)
cd indexer && cargo clippy --all-targets -- -D warnings && cargo testpasses (ran locally before push).GET /v1/statusreturnsingestion_alive: trueand a recentlast_indexed_at_mswhile blocks are ingesting;false+0in intentional API-only mode./v1/*reads still work butmax_indexed_blockshould stall until recovery (expected).INDEXER_RPC_REQUEST_TIMEOUT_SEC=2and confirm hung endpoints fail within ~2s in logs (defense-in-depth).Please verify on your side when convenient.
cc @brouie
@PlasticDigits — verification PASS on
74967bc/c90bca7.supervisor loop at
indexer/src/main.rs:38-54— base 1s backoff, 60s cap, doubled perErrreturn, reset onOk(()). log lines at:44("ingestion::run returned Ok unexpectedly; resetting backoff") and:48("ingestion failed; backing off before retry"). all three failure branches iningestion::run(INGESTION_ENABLED=false, empty addrs, inner-loop?) flipingestion_aliveto false atingestion.rs:97/113/232before returning. atomic stays false during backoff sleep, flips true again at:132afterbootstrap_pointersucceeds.INDEXER_RPC_REQUEST_TIMEOUT_SECatconfig.rs:344-361— default 5s (DEFAULT_RPC_REQUEST_TIMEOUT_SECS = 5), clamp1..=120s, lower boundbail!on 0, upper boundmin(120)(silent — see follow-up below). wired throughrpc_http::reqwest_http_providerat bothingestion.rs:125andchain_timer.rs:141./v1/statusbody atapi.rs:334-344—ingestion_aliveboolean fromstate.ingestion_alive.load(Acquire),last_indexed_at_msu64 fromIngestionProgress::mark_indexed_now()atingestion.rs:213-215(set aftertx.commit()per block, initial 0 until first commit).integration test smoke at
integration_stage2.rs:130-131coversingestion_alive: false+last_indexed_at_ms: 0for the harness state. retry/backoff loop itself not exercised in tests — small enough surface to review by inspection, fine.ready for close on your side.
two follow-up observations from the same review pass — flagging here so you can fold them into a follow-up commit, spawn separate tickets, or accept as out-of-scope, your call:
chain-timer poll task at
main.rs:67-69is unsupervised —tokio::spawn(async move { chain_timer::run_poll_loop(...).await })with discardedJoinHandle. ifrun_poll_loopreturns or panics,/v1/timecurve/chain-timersilently degrades to 503 with no liveness signal. the ingestion supervisor at:38-54does not cover it. partially in #168 territory since the original ticket cited "ingestion + chain-timer poller" timeouts together.INDEXER_RPC_REQUEST_TIMEOUT_SECupper-bound clamp atconfig.rs:359silently truncates values >120 to 120. lower boundbail!s at 0. asymmetric — atracing::warn!on truncate or symmetricbail!would give operators a heads-up. minor / style.no panic-safety concern in
ingestion::runbody (nounwrap/expect/array-indexpaths surfaced), so the supervisormatchcovering onlyResultarms is fine in practice today. worth keeping in mind ifingestion::runever grows future code.@PlasticDigits — runtime checklist walked, all PASS.
cargo clippy --all-targets -- -D warnings && cargo test— clippy clean (0 warnings), tests 40 unit + 2 integration = 42/42 PASS at HEAD284c15c.live
/v1/statusagainst the anvil-backed stack:ingestion_alive: trueflips on afterbootstrap_pointersucceeds.last_indexed_at_msadvances per block.max_indexed_blockkeeps pace with anvil tip.RPC_URL=http://127.0.0.1:9999(separate test DB +LISTEN_ADDR=127.0.0.1:3199). log shows backoff/retry working as designed:/v1/*reads stay responsive throughout —GET /v1/statuson the failing instance returnedingestion_alive: false,last_indexed_at_ms: 0,database_connected: true, schema1.16.1. operator visibility into "API up, ingestion dead" works exactly as intended.INDEXER_RPC_REQUEST_TIMEOUT_SECdefense path is for hung endpoints (accepts connection then never responds), not the connection-refused case I induced. covered by inspection atconfig.rs:344-361+ wiring atingestion.rs:125/chain_timer.rs:141. happy to set up a hung-RPC fixture and walk this if you want it for completeness, otherwise treating as out-of-scope per your "optional" tag.ready for close on your side.
mentioned in issue #177
mentioned in issue #170
mentioned in commit
84b0db5b80mentioned in commit
f887490696mentioned in issue #237
mentioned in issue #306
mentioned in issue #307
mentioned in issue #308
mentioned in issue #341