Repository navigation
feat(ingest): bound every ingester cycle with a deadline, abandoned-cycle metric, and overlap-proof abandonment (#351) - #471
Conversation
`clearAllMocks` clears calls but leaves implementations in place, so the rejection stub from one case leaked into the next under shuffled test order and poisoned ingestPool's dispatch assertion. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
…nity-Labs#351) A single slow upstream could stretch an ingester cycle indefinitely: the loop never crashed, so nothing restarted it, and every health signal still reported the ingester healthy while it silently stopped producing. Each cycle now races a configurable per-ingester deadline. On expiry the cycle aborts, ingester_abandoned_cycles_total{venue,network} is incremented, and the next cycle starts only after the abandoned one has unwound, so two cycles against the same venue can never overlap. Abort checkpoints sit before a write unit and never inside one, so an abandoned cycle skips whole, replayable units (skipDuplicates makes the refetch idempotent) instead of committing a partial write. Closes Divinity-Labs#351 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
|
@felixumeh Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
|
Merged as First, a correction on my side: #351 had been closed in error. I closed it when merging #479, which added exponential backoff and crash reporting for an ingester that crashes — a different failure entirely. I have reopened it and this PR closes it properly. What is rightI read The cycle promise is always attached to a handler. const cycleSettled: Promise<CycleSettled> = cycle(controller.signal).then(
() => ({ outcome: 'fulfilled' as const }),
(error) => ({ outcome: 'rejected' as const, error })
)When the deadline wins the race, the abandoned cycle may still reject later. Without this it becomes an unhandled rejection, which in Node takes the process down — so the naive version of this wrapper turns a slow venue into a crash. Converting rejection into a value at the point of creation is the right fix. Both timers are cleared in Overlap is impossible by construction. On expiry you
if (deadlineMs <= 0) {
await cycle(new AbortController().signal)
return { status: 'completed', durationMs: Date.now() - startedAt }
}Adapter code paths are identical whether or not a deadline is configured, so disabling it in one environment cannot change behaviour anywhere else. Easy to skip, and skipping it is how you get a bug that only appears in the environment that turned the feature off. And the env precedence ( The mergeSeven conflicts, all from work landed after your branch was cut. Resolved as unions; the one worth flagging is ingestPool(
{ poolAddress: addr, tokenA, tokenB },
pair,
fetchPoolReserves,
network,
fetchTokenDecimals, // omitting this put the AbortSignal in its place
signal
)
In Two fixes on top1. That guard exists because 2. A test spied on const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
process.env.INGEST_CYCLE_DEADLINE_MS = 'soon'
expect(getCycleDeadlineMs('SDEX', 'testnet')).toBe(DEFAULT_CYCLE_DEADLINE_MS)
expect(warn).toHaveBeenCalledTimes(1)It broke with the logger change, but it was testing the wrong thing anyway: the behaviour that matters is the fallback, not that a particular function was called once. Rewritten to assert the value, plus a second case for precedence around a bad value — a broken One limitation worth stating plainlyOn expiry the loop waits for the abandoned cycle to unwind, and the grace timer only logs: const graceTimer = setTimeout(() => {
log.error({ venue, network, graceMs: CYCLE_UNWIND_GRACE_MS }, 'abandoned cycle has not unwound …')
}, CYCLE_UNWIND_GRACE_MS)So an upstream call that genuinely never settles still parks the loop — the original failure, strictly speaking. What has changed is that it is no longer silent: Your comment says this outright, which is why I merged it rather than asking for a hard abort. Refusing to overlap beats double-writing, and a documented trade-off is worth more than a wrapper that quietly starts a second cycle. 128 test files / 1,530 tests pass. |
A rate-limited Horizon call returned a body with no `_embedded.records`, the
caller read that as "this pair has no pools", and the venue silently stopped
producing prices until a later cycle happened to land outside the window.
`fetchHorizonJson` now retries a 429 — honouring `Retry-After` when Horizon
sends one, otherwise exponential with full jitter so four ingesters on the same
poll interval do not retry in lockstep — and raises `HorizonRateLimitError`
once the attempts are spent, which the AMM adapter rethrows rather than logging
and swallowing, so the runner counts it and backs the cycle off.
`horizon_rate_limit_remaining` is read from `X-RateLimit-Remaining`, so how
close an instance runs to the limit is visible before it starts failing.
Merged locally; two conflicts, both against work landed since the branch was
cut.
One fix on top of the contribution. **The helper used a bare `fetch(url)`.**
`main` had moved these two call sites to `fetchWithTimeout(url, { signal })`,
which supplies both the configured `HTTP_TIMEOUT_MS` and — since #471 this
afternoon — the cycle deadline's abort signal. Taking the branch's version
verbatim would have reverted both: an unbounded request inside a loop whose
whole purpose is to have an upper bound, in the module that exists because a
slow Horizon is the failure being handled. `fetchHorizonJson` now takes
`{ attempts, signal }` and passes the signal to every request *and* to the
backoff sleep, so an abandoned cycle does not sit out a 30-second retry wait
after it has been told to stop, and gives up rather than spending another
request once it has.
Also kept `main`'s structured `log.error` over the branch's `console.error`
(`src/__tests__/logger.test.ts` rejects `console.*` in non-test source).
Separately, a defect in my own #475 merge this afternoon: `tests/readme.test.ts`
byte-compares README.md against generator output joined with `\n`, so it fails
on every line of an identical table on any Windows checkout, where git supplies
CRLF. It is the fifth guard in this repo to hit that, after `openapi.json`,
`*.yaml`, `*.csv` and `schema.graphql`. README.md is now pinned to LF in
`.gitattributes` and the test normalises line endings as it reads, so a
contributor whose git config differs is not blocked by a diff that is only line
endings.
129 test files / 1,553 tests pass; tsc clean.
Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
…closes #212) A dedicated ingester reads Blend pool reserves over Soroban, derives utilisation and supply/borrow APR, and stores them in `lending_rates` rather than `price_points` — a lending rate is not a spot price, and the adapter's `derive` returns `[]` so nothing leaks into the volume or VWAP paths. `GET /rates/:asset` serves them, a retention job prunes old rows, and the new columns ship as a migration. What it got right, and why I am noting it: the schema change comes with `20261023130000_extend_lending_rates_for_blend/migration.sql` rather than a bare `schema.prisma` edit. Since `migrate deploy` replaced `db push` this morning, a schema edit alone reaches no live database. The migration is additive (every column nullable), leaves the baseline untouched, sorts after `0_init`, and its index leads with `network`. The first PR today to need that workflow got it wrong; this one did not. Three changes on top of the contribution. 1. **`classification` was missing.** #455 made it a required field on `VenueAdapter` about ten minutes earlier, so this did not compile on merge. Set to `'primary'`, with a comment recording that it is informational here: Blend emits no price points, so it never reaches the net/gross split — and it is 'primary' because it holds its own reserves, being the venue a DeFindex vault allocates *into* rather than one re-reporting another venue's trades. Also registered in `VENUE_CLASSIFICATIONS`, which #455's new drift test requires — that guard earned its place on the very next adapter. 2. **The ingester loop was unbounded.** Blend runs outside `startVenueAdapterLoop` (it writes `lending_rates`, so the generic derive/write cycle does not fit), which meant it also missed the per-cycle deadline #471 added this afternoon. A `while (true)` around `await ingestBlendPools(network)` is the exact #351 shape: a slow Soroban RPC stretches one sweep indefinitely, the loop never crashes, so `restartIngester` never fires and the venue goes quiet with nothing raised anywhere. Now wrapped in `runCycleWithDeadline`, the same treatment #471 gave the bespoke snapshot ingester, and `ingestBlendPools` takes the signal so an abandoned cycle stops at a pool boundary — one pool is one write unit, so nothing is left half-written. 3. Regenerated `clients/typescript/src/schema.d.ts` for the new `/rates` response shape; it is byte-compared in CI. Note for later: `blendAdapter` is exported but deliberately not in `venueAdapters`, so Blend does not appear in the README's generated venue table. That is defensible — the table describes price venues — but worth revisiting if more non-price venues arrive. 134 test files / 1,603 tests pass; tsc clean. Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Closes #351
Summary of Changes
Bounds every ingester cycle with a deadline so one slow upstream can no longer stall an ingester while every health signal still reports the service as healthy. Issues below are fixed by this PR:
runCycleWithDeadline()in the newsrc/ingest/cycleDeadline.ts. A cycle that exceeds its deadline is aborted at its next unit checkpoint, counted in the newingester_abandoned_cycles_totalmetric labelledvenue+network, logged, and abandoned — the loop resumes after teardown of work already in flight instead of awaiting the abandoned cycle's remaining work.All five acceptance criteria from the issue:
AbortSignalfires and every checkpoint stops further pairs/pools from starting, so the abandoned cycle stops at its in-flight unit rather than grinding through the rest of the round.INGEST_CYCLE_DEADLINE_MSwith per-venue (INGEST_CYCLE_DEADLINE_MS_SOROSWAP) and per-venue-per-network (INGEST_CYCLE_DEADLINE_MS_SOROSWAP_MAINNET) overrides, precedence documented ingetCycleDeadlineMs()and.env.example; default 60s,0explicitly disables the deadline. Follows the paired-var convention used everywhere else insrc/config.ts.ingester_abandoned_cycles_total{venue,network}(prom-client Counter insrc/metrics.ts); cardinality isvenues x networks, bounded by the adapter registry, documented indocs/ingest-metrics.mdalongside the existing metrics, with apromqlquery example.upsertPricePointsis onecreateMany(skipDuplicates); the SDEX/AMM page upsert and its cursor advance are checked before the sequence as a whole, never between the two statements;snapshotPool, Soroswap's per-pool write and the snapshot appender are each either skipped whole or run whole. Skipping costs only a refetch from the same cursor, andskipDuplicatesmakes the replay idempotent.src/__tests__/ingestCycleDeadline.test.tsdrives the realstartVenueAdapterloop with a 250ms upstream under a 50ms deadline and asserts ≥3 cycles start,maxActive === 1(no two cycles ever overlap), and ≥2 abandonments land on the counter withvenue/networklabels.Guarantees in detail
runCycleWithDeadline()never resolves while the previous cycle's promise is unsettled — abandonment awaits unwind, not work. The unwind is prompt because the signal is passed tofetchWithTimeout({ signal })(the house pattern fromsrc/fetch.ts/src/alerts/delivery.ts) and checked at every unit boundary. If a single in-flight call ignores even its own timeout, the loop still refuses to start an overlapping cycle and escalates with a loud[cycle-deadline] … still has not unwounderror after a 10s grace (CYCLE_UNWIND_GRACE_MS), rather than silently double-running a venue.clearTimeout, sostartVenueAdapter's exponential backoff andrestartIngesterfault isolation behave exactly as before; only deadline expiry counts as an abandonment (metric, no error backoff), so a slow venue is not also punished with retry delay.ingestPair(pair, network, signal?)adds an optional third parameter; all four adapters, bothwriteVenuePricePointsand the snapshot loop thread it through; existing custom adapters and tests compile and run unmodified.startVenueAdapter(adapter, network, { stopSignal })lets a loop stop at a cycle boundary — used by the new test; unset in production where loops legitimately run until process exit.Files changed
src/ingest/cycleDeadline.tsrunCycleWithDeadline(),getCycleDeadlineMs()precedence chain,CycleDeadlineExceeded, grace-window loggingsrc/metrics.tsingester_abandoned_cycles_totalcounter (venue,network)src/ingest/venues/runner.tswriteVenuePricePointspre-write checkpoint;VenueAdapterLoopOptionssrc/ingest/venues/types.tsingestPairgains optionalsignalsrc/ingest/venues/{sdex,amm,soroswap,aquarius}.tsingestPair→ outbound fetches (fetchWithTimeout({ signal })) and checkpoints before each write unitsrc/ingesters/snapshot.tsINSERT … ON CONFLICT).env.exampleINGEST_CYCLE_DEADLINE_MS[_<NETWORK>]documented (drift guard)docs/ingest-metrics.md.changeset/ingest-cycle-deadline.mdsrc/__tests__/ingestCycleDeadline.test.tsThe branch also carries one pre-existing test-hygiene commit (
test: reset upsertPricePoints mock between soroswap ingester cases) —clearAllMocksretains implementations, so a rejection stub could leak into the next test under a shuffled run.Verification (all run locally, mirroring
.github/workflows/ci.yml)npm ci→ up to date, exit 0prisma generate→ exit 0npx tsc --noEmit→ exit 0, zero errorsnpm run build→ exit 0 (emitsdist/)npm test→ exit 0 — 67 files passed, 673 tests passed, 1 skipped (includes the 8 new Ingester loops have no upper bound on a single cycle #351 tests;.env.exampledrift guard and OpenAPI sync green)ingestCycleDeadline.test.ts→ 8/8 pass with live[cycle-deadline] … abandoned after exceeding its 50ms deadlinelogs proving the behavior end-to-endNotes for reviewers
main; happy to reconcile whichever direction you prefer — therunCycleWithDeadline()seam is small and would slot under whatever scheduling feat(ingest): add schema verification, cycle deadlines, lag telemetry, and /status health breakdown #463 lands.fetchWithTimeout-style per-call deadlines (fix: enforce deadline timeouts on all outbound fetch calls #379); the cycle deadline ensures we refuse to begin work for an abandoned cycle and to overlap cycles regardless.main(per the issue text) and is not part of PR CI; unaffected here.npm testincludes one intentionally skipped integration e2e test (tests/integration/e2e.test.ts), pre-existing and unrelated.