fix(data): chunk the remaining candle-request windows under the venue cap - #271
Merged
Merged
Conversation
eaitbrahim
force-pushed
the
fix/chunk-remaining-candle-windows
branch
from
August 15, 2026 20:06
a38db87 to
3565f14
Compare
… cap Coinbase rejects any candle request spanning more than ~350 candles with `400 INVALID_ARGUMENT ... "number of candles requested should be less than 350"`. #269 fixed this in `market_feed.poll_once`; review found two more sites with the same defect. Both are fixed here so every candle-request windowing site in the codebase agrees. Both are LATENT -- neither is firing today -- and both are the kind that fail quietly once they do. **`repair.py` cannot heal an interior hole larger than the cap.** `gaps.detect` puts no cap on `n_missing`, and `repair_series` asked for the whole hole in one call, so a gap of roughly 349+ candles 400s. Because the per-window `except` swallows the exception into `result.errors` and moves on, the failure would be *silent and permanent*: every scheduled repair retries the same oversized request, and the only symptom is a series that quietly never repairs. No cached series triggers this today. Measured across the deployment (24 products, ONE_HOUR and ONE_DAY), the largest interior gap is 15 bars (ICP-USD, ONE_HOUR) against a ~349 limit, and no series has an interior gap at or over the cap. So this is closing a latent trap, not healing an existing hole. It is still worth doing: an interior gap that large is entirely reachable -- a multi-day venue outage, a delisting and relisting, or a product added back after a long absence -- and the silent-forever failure mode is exactly the kind that is not noticed until much later. (For the avoidance of doubt: the 553-candle ZEC-USD figure from the #269 incident was end-of-series STALENESS, which `poll_once`/`ensure_history` catch up forward. It was never an interior gap, so `repair` was never asked to heal it. 553 survives here only as the oversized-gap size in the tests.) The widened window is now paged in `MAX_CANDLES_PER_REQUEST`-sized chunks (imported from `keel.data.history` -- a duplicated venue limit would be the same bug class), each upserted as it arrives so a mid-window failure leaves the earlier chunks persisted and the next run resumes further along. Semantics preserved exactly, because they are load-bearing: - the ±step widening applies to the OUTER edges only (venues disagree about endpoint inclusivity); internal chunk boundaries stay contiguous and are not themselves widened; - a window is eligible to be recorded *absent at source* only if EVERY chunk completed -- the difference between "the venue lacks this data" and "we failed to ask properly", and the module docstring is explicit that the absence record is an assertion about an observation; - the per-window `except ... continue` stays, so one bad window cannot abort the pass; - `sleep_fn` paces every chunk request, matching `history._fill_forward`. **`history.py`'s off-by-one would activate if the constant were raised.** An inclusive range `[a, a + N*step]` holds N+1 candles, so `_fill_forward` and `_fill_backward` requested 301, not 300 -- verified: a 1000-candle gap produced window sizes [301, 301, 301, 97]. Harmless at 300, but `history.py` invites raising the constant toward 350, at which point it would silently request 351 and reproduce the incident in the backfill path. Both sites now use `(MAX_CANDLES_PER_REQUEST - 1) * step`, matching what #269 did in `market_feed.py`, with a comment on the arithmetic so the -1 never reads as an accident. Tests written first and confirmed failing for the right reason: 9 of the 10 new tests fail against the unfixed code on candle-count, call-count and absence-record assertions (the 10th is a regression guard that a small hole still takes exactly one request, which must pass before and after). The repair tests drive an oversized gap against a fake venue that enforces the true ~349 ceiling, and assert on the actual start/end args it received. The history tests assert exact window sizes and express the invariant in terms of the constant, so raising it toward 350 stays safe. Gates: ruff clean, mypy clean (224 source files), pytest 2737 passed / 1 skipped (2727 + 1 skipped at 29d9b20, plus the 10 added here). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim
force-pushed
the
fix/chunk-remaining-candle-windows
branch
from
August 15, 2026 20:07
3565f14 to
c26c8d3
Compare
eaitbrahim
added a commit
that referenced
this pull request
Aug 15, 2026
…nt (#274) Follow-up from the independent review of #271. The rule "a gap window may be recorded absent-at-source only if EVERY chunk completed" is the most dangerous thing in `repair.py` to get wrong -- getting it wrong permanently writes off a hole the venue was never fully asked about -- and the multi-chunk path had no test that could see a regression in it. `test_one_bad_chunk_is_not_recorded_absent_and_a_later_window_still_repairs` fails the SECOND chunk. That means the first chunk lands 299 bars, so the surviving gap window's key shifts from (BASE+1d, BASE+553d) to (BASE+300d, BASE+553d). `probed_keys` in `repair.py` matches by EXACT key, so that window is skipped no matter what `probed_ok` holds: its `windows_absent_at_source == 0` and `get_gap_probes() == []` assertions are vacuous in their own setup. Failing the FIRST chunk instead upserts nothing, so the remaining window keeps its original key and the gate is genuinely exercised. Verified discriminating: with `probed_ok.append(window)` added to the failure path, this test FAILS and the pre-existing single-chunk `test_a_failed_fetch_is_NOT_recorded_as_absent` alone would not have covered the multi-chunk case. No production code changes -- the shipped behaviour was already correct. This only stops a plausible future refactor ("partial progress means the window was probed") from passing the suite. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 16, 2026
eaitbrahim
added a commit
that referenced
this pull request
Aug 16, 2026
…indow site (#295) #269 chunked `market_feed.poll_once`; #271 swept for the same defect and fixed `repair.py` and `history.py`. `market_feed.backfill` was not reached by either, and has the identical shape: `_missing_ranges` groups absent timestamps into CONTIGUOUS ranges and each range was requested in a single call, so a hole wider than the venue's ~350-candle cap 400s exactly as the poll path did. On an empty repo the entire history window is one such range. LATENT, and stated plainly rather than dressed up: `backfill` has no production caller today -- `keel fetch` goes through `history.ensure_history` -- so nothing is failing on this right now. It is worth closing anyway on #271's own stated grounds, that every candle-request windowing site in the codebase should agree. A public data-layer entry point that 400s the moment it is called from anywhere is a trap left armed for whoever calls it next. The windowing arithmetic is now in one place, `_request_windows`, used by both `backfill` and `_poll_catch_up`. That was the actual reason this site was easy to miss: the `(MAX_CANDLES_PER_REQUEST - 1)` expression existed only inside `_poll_catch_up`, so `backfill` had nothing to be inconsistent WITH. #271 had to correct exactly that off-by-one after finding it duplicated in `history.py` -- the same bug class the shared `MAX_CANDLES_PER_REQUEST` import exists to prevent -- so it now has one definition here. `_poll_catch_up` keeps its own filter and `seen` dedup and is otherwise unchanged; folding it onto the helper also removed a `window_start = window_end + gran_sec` line left stranded when its `while` became a `for`. Tests written first and confirmed failing for the right reason: both cap tests failed on a single 575-candle request against the unfixed code. The third is a regression guard that a window within the cap still costs exactly one request, which passes before and after. Gates: ruff clean, mypy clean (77 source files), pytest 2762 -> 2765 passed / 1 skipped. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Coinbase rejects any candle request spanning more than ~350 candles:
#269 fixed this in
market_feed.poll_once. An independent review then found two more sites with the same defect. Both are fixed here, so every candle-request windowing site in the codebase now agrees.Both are latent. Neither is firing today, and this PR is preventative rather than a repair of live damage. What makes them worth closing is the failure mode: each fails quietly, and one of them fails quietly forever.
Defect 1 —
repair.pycannot heal an interior hole larger than the capgaps.detectputs no cap onn_missing, so an interior hole can be arbitrarily large.repair_seriesasked for the whole hole in one call:A gap of roughly 349+ candles therefore 400s. Because the per-window
exceptswallows the exception intoresult.errorsand continues, the failure is silent and permanent — every scheduled repair retries the same oversized request, and the only symptom is a series that quietly never repairs.This is not currently triggering
Measured across the deployment (24 products,
ONE_HOURandONE_DAY), the largest interior gap is:Series with an interior gap ≥ 349, i.e. that
repaircannot heal: 0. The worst hole in the entire cache is 15 bars against a ~349 limit.So the justification is "close a latent trap before it bites", not "heal an existing hole". It still earns its place: an interior gap that large is entirely reachable — a multi-day venue outage, a delisting and relisting, or a product added back after a long absence — and a silent, self-perpetuating failure is precisely the kind that goes unnoticed until much later.
The fix
The widened window is now paged in
MAX_CANDLES_PER_REQUEST-sized chunks via a new_fetch_window_chunkedhelper, importing the constant fromkeel.data.historyrather than declaring a second one — a duplicated venue limit is the same bug class being fixed. Each chunk is upserted as it arrives, so a mid-window failure leaves the earlier chunks persisted and the next run resumes further along instead of repeating the whole doomed request.Semantics preserved exactly (they are load-bearing and commented):
stepwidening applies to the OUTER edges only — venues are inconsistent about endpoint inclusivity. Internal chunk boundaries stay contiguous and are not themselves widened.except ... continuestays, so one bad window cannot abort the pass.sleep_fnpaces every chunk request, matchinghistory._fill_forward.cli.pypasses the realtime.sleep, so this is real rate limiting.repair_series's signature is unchanged (keel/cli.pyandtests/data/test_fetch_cli.pydepend on it).Defect 2 —
history.pyrequested 301 candles, not 300An INCLUSIVE range
[a, a + N*step]holds N+1 candles. Verified:_fill_forwardover a 1000-candle gap produced window sizes[301, 301, 301, 97].Harmless today (301 < 350) — but
history.pysaysMAX_CANDLES_PER_REQUEST = 300 # conservative under Coinbase's ~350-candle response cap, which openly invites raising it toward 350, at which point this silently requests 351 and reproduces the incident in the backfill path.Both sites now use
(MAX_CANDLES_PER_REQUEST - 1) * step— giving[300, 300, 300, 100]— matching what #269 did inmarket_feed.py, with a comment on the arithmetic at each site so the-1never reads as an accident.Tests (written first, confirmed failing for the right reason)
Reverting only the
keel/changes while keeping the new tests:Every failure is a real assertion about candle counts, call counts or absence records —
assert 351 <= 350 where 351 = max([351, 351, 298]),assert sizes == [300,300,300,100]vs actual[301,301,301,97],assert len(venue.calls) == 2vs actual1— not an import, fixture or typo error. The tenth new test (test_a_small_hole_still_takes_exactly_one_request) is a regression guard that passes both before and after, by design.The repair tests drive an oversized (553-candle) gap against a
_CappedVenuefake that enforces the true ~349 ceiling, so the fake actually returns the 400 the real venue would. They assert on the actual start/end args it received (chunks of exactly[300, 255]), that the chunks tile the outer range with no duplicate and no gap at a boundary, that all 553 rows land, that a failed chunk is not recorded absent while a later window in the same pass still repairs, and that a fully-completed multi-chunk window with nothing at the venue is still recorded absent.The history tests assert exact window sizes and express the invariant in terms of the constant — one monkeypatches
MAX_CANDLES_PER_REQUESTto 350 and asserts no request exceeds it — which is what makes raising the constant safe.Gates
Baseline at
29d9b20measured directly: 2727 passed, 1 skipped, plus the 10 added here.Note this branch is cut from
main, independent of #269 — they touch disjoint files (market_feed.pyvsrepair.py/history.py) and do not conflict.🤖 Generated with Claude Code