Repository navigation
3072-T3b2 (P5): plumb AccessIntent from the SQL executor to the cache #3084
Description
Epic #3072, ADR-3072 decision 3a, plumbing side. Track P5. After #3083.
Scope. QueryAccounting (crates/ravel-types/src/accounting.rs) carries an AccessIntent (default Interactive); every data-object funnel (crates/ravel-query/src/fetcher.rs ReadCache, the log_fetcher.rs whole, extent, front-section, block and run paths, admit_blocks_from_whole taking the intent of the whole-object read it splits, span_fetcher.rs) passes it to the cache. The SQL executor sets it once per statement after resolve and before any segment read: BulkScan when the statement is a single metrics lane whose resolve pruned no segment AND the resolved window covers every segment of the tenant's snapshot, or when the logs scan's whole-segment test (predicate-free, window-covering, evaluated on the resolved segment list) would pass; Interactive otherwise. PromQL engine and cache warming set Interactive. Distributed workers keep Interactive (the proto field is a frozen-contract follow-up ticket, filed by this task). Server flag --cache-bulk-share (default 0.5) wired to CacheLimits. Observability: per-intent cacheHits/cacheServedBytes as a new stats.cache object beside phases and io (not inside the exact-key-set phase_costs_json), and /metrics ravel_cache_* gain an intent label.
Crates. ravel-types, ravel-query, ravel-sql, ravel-server, docs.
Predicted files. crates/ravel-types/src/accounting.rs, crates/ravel-query/src/fetcher.rs, crates/ravel-query/src/log_fetcher.rs, crates/ravel-query/src/span_fetcher.rs, crates/ravel-sql/src/executor.rs, crates/ravel-sql/src/stats_json.rs, services/ravel-server/src/store.rs, services/ravel-server/src/config.rs, services/ravel-server/src/sql.rs, services/ravel-server/src/metrics.rs, docs/guides/caching.md, docs/reference/ (flag reference regenerated), changelog.d/.
Acceptance tests.
services/ravel-server/tests/sql_cache_intent.rs::a_full_tenant_scan_is_admitted_as_bulk_and_a_one_series_statement_as_interactive(through HTTP/api/v1/sql: the full scan's reads show understats.cache.bulkScan, a one-series metrics statement whose name prunes nothing shows underinteractive; shown failing against a classifier keyed onsegments_pruned == 0alone).an_interactive_statement_keeps_its_hot_set_under_a_concurrent_bulk_loop(reachability of decision 3b through the real funnels: 99 % hits on the interactive ranges while a bulk loop runs).stats_jsonparity tests (phase_costs_json_names_every_field_exactly_once) unchanged; a new exact-key-set test forstats.cache./metricsrendersravel_cache_hits_total{cache="fetch",tier="ram",intent="bulk_scan"}(asserted on the real surface).
Risk. Medium.
Activity
feat(cache): admit reads by access intent
pmoust commentedon Oct 18, 2026
Checkpoint review of task 6503eae7 (result 770a369, landing as PR #3098): PASS, with three items to settle before #3084 sets
BulkScanfrom the executor, each either fixed in #3084's task or recorded here as an accepted deviation:s3fifo.rsaround 818-833), and rotation and idle-tenant retirement run only insideobserve_bulk, whose clock advances on bulk reads. After the last bulk read, up tobulk_shareof RAM stays held against interactive admissions until the age sweep, while interactive admissions evict their own main entries. The ADR's "bounded to a share, may grow into bytes the interactive queues are not using" reads as a cap, not a reservation.max_bytesof traffic (rotation window capped at 16 xmax_bytes, eviction at the second rotation after the last read), so a loop larger than about 32 xmax_bytes(about 368 GB on an 11.5 GB cache) serves nothing.docs/guides/caching.mdand theAccessIntent::BulkScandoc state the stable subset with no bound.s3fifo.rsaround 494-498, 641-642) has no test for the case it was added for: with the region below its share, one 25 MB bulk admission evicts about 1,500 single-read 16 KiB probation entries whose second chance the 171-key ghost cannot hold. Add a test where the cache is full of interactive entries and the hot set is first read while the region fills.Also noted:
evict_unreadandretire_tenantwalk the whole rank map (up to about 599k entries at 11.5 GB) under the cache mutex, amortised over at least 2 xmax_bytesof traffic, but once 64 tenants are tracked every new bulk tenant triggers a full walk;AccessIntentlives in ravel-cache, which does not depend on ravel-types, so #3084 either maps a second enum or moves it; the guide's "bulk bytes never enter the main queue" is wrong for a region entry that later takes an interactive hit (it moves to small, then main). #2738 stays open:configuration.md,ravel-server-flags.mdand theconfig.rsflag help still say LRU.fix(cache): decline bulk reads the small queue cannot make fit
fix(cache): decline bulk reads the small queue cannot make fit
pmoust commentedon Oct 20, 2026
T3b2: the first task (e53f20fe) wrote the code and the HTTP tests as five wip commits, then ended its turn on a backgrounded gate, so nothing was gated and docs, changelog and the reference regeneration were missing. Checked before continuing: the intent is not on the wire (worker reports decode as Interactive, no proto change), ravel-types carries a mirror enum with no ravel-cache dependency, and ravel-sql's ravel-cache moved from dev to normal dependency (workspace crate). The chain was rebased onto current main (same delta, pushed as task/e53f20fe-.../rebased) and the continuation dispatched as fleet task 6000cd29 (opus): finish caching.md (including the #3083 gate items), the changelog fragment, the flag reference, the distinguishing-mutant proofs, and the gate one lane per call. A Stage 4 checkpoint review runs before the pull request.
feat(sql): admit whole-tenant scans to the read cache as bulk reads
pmoust commentedon Oct 20, 2026
Checkpoint review of the T3b2 result (task 6000cd29, squashed as land/3084 at 7d6fd40 on main 6c8b4aa): PASS, no format, durability or invariant finding, five should_fix items.
Decided and dispatched as fix round 1 (task 75291809):
ORDER BY ... LIMIT 50and per-tick alert counts as bulk; the samples arm ignored WHERE.Filed: #3133 (bulk region persists after bulk traffic stops), #3134 (intent to distributed workers, frozen-contract change).
feat(sql): admit whole-tenant scans to the read cache as bulk reads
docs(server): state the narrowed bulk trigger in --cache-bulk-share help
feat(sql): classify large scans as bulk behind a byte threshold
feat(sql): classify large logs scans as bulk behind a byte threshold
feat(bench): mixed scan and interactive workload for cache admission
pmoust commentedon Oct 21, 2026
Mixed scan-plus-interactive bench (task b88ed073, Fable): the bench itself is committed on
task/b88ed073-a306-4e45-bc26-93b47b633ed9/result(d3ded66,sql_latency_bench --mixed). The task hit the 4 h ceiling during its gates, so they are incomplete and the branch is not proposed for merge. Kept as reference.Its figures, from its commit message: a 1,043,411,346-byte generated corpus (260 objects, 3.9 M rows), a 521,705,673-byte cache, four scan and four interactive connections, three 90 s repetitions per arm (flag unset / bulk-region bytes / whole cache):
Reading: inconclusive for the default. The interactive set is small enough that S3-FIFO keeps it hot under the scan loop even with every scan admitted as interactive. Nothing here supports turning the flag on by default. It ships unset in #3170.
It also found a bench bug that affects earlier P5 figures. The generated lane stamped every object with a zero content hash, and the read cache keys on that hash and the object size, so equal-size objects could serve each other's bytes. The fix is dispatched on its own (task e621d165). The executor's 48 % served figure for the size trigger came from that lane, so #3170 claims no figure, and the measurement is re-taken after the fix lands. The ClickBench A/B with the flag set is the remaining evidence for the default.
feat(cache): admit reads by access intent
7 remaining items
pmoust commentedon Oct 22, 2026
ClickBench A/B on the size trigger: result. Pre-registration: #3084 (comment)
Run clickbench-20261021T234540Z-83977-3132, kit lane, both arms on 5f4e5f2. Arm B adds
--cache-bulk-scan-min-bytes 1073741824, so every statement reads asBulkScan. 43/43 statements answered on both arms, with identical row counts and answers.>= 0.95 x AMISS; B slower in every round (0.377 / 0.452 / 0.402 against 0.545 / 0.518 / 0.462)The hot regression comes from q25 (2.49x) and q27 (2.93x). These are small
ORDER BY ... LIMITstatements whose hot tries depend on re-reading cached bytes, and the bulk region does not keep those bytes. Their tries are noisy on both arms: q27 A 784.5 / 46.7 ms, B 136.8 / 221.1 ms.Decision: the default stays unset. The flag does not improve the ClickBench geomean, and with every statement classified bulk it costs concurrent throughput. It stays opt-in for a scan-heavy tenant whose interactive working set the scans would otherwise evict, which is the generated-lane case (48 % served against 12 %, measured 2026-10-22, comment above). The ADR's further amendment that records the default will cite these figures, in a follow-up after #3170 merges.
Separate observation, not caused by the flag: in arm A (flag unset), the server process died during concurrent round 3. Its log stops at 01:07:04Z with no panic and no shutdown line, and the kit restarted it at 01:08:20Z. The last lines before that are tenant memory-budget refusals near the 16,454,479,872-byte limit. Most of round 3's 1,383 errors fall in that gap. No server died in the other three arm-runs I checked: B of this run, and A and B of the v0.25 acceptance kit run. The evidence has no kernel log, so an OOM kill is the likely cause but is not confirmed. I am reporting it on its own issue.
feat(server): floor the bulk-scan threshold at the read cache size
feat(sql): key the bulk-scan size trigger on a statement's own read b…
feat(server): floor the bulk-scan threshold at the read cache size
feat(bench): floor the generated lane's bulk-scan threshold at the cache
docs: describe the read-bytes size trigger and its cache floor
pmoust commentedon Oct 22, 2026
Pre-registration for the re-measure of the size-trigger fix, written before either run.
Fix. Commit 59468e6 on branch
land/3084-fix, on top of main8b7630877(which holds #3170). The trigger now compares the bytes the same SQL text read on its last run, not the lane's resolved object bytes. The threshold is floored at the read cache's max bytes. The ClickBench A/B diagnosis that motivated it is in the comment above, and is summarised in the commit and in the ADR-3072 amendment.Run 1: ClickBench kit A/B. Both arms on 59468e6. Arm B adds
--cache-bulk-scan-min-bytes 1073741824. The run has the benchmark and concurrent phases, and q29_probe, as in the A/B that regressed.cache.intent = bulkScan, over every call in the benchmark phaseA miss on the bulkScan count or on the startup line means the fix did not reach the shipped binary, and no other figure counts until that is resolved.
Run 2: generated lane. sql_latency_bench, same shape as the earlier re-measure: 25 objects of 20,000 records, 4 runs, cache at half the corpus's object bytes. The arms are unset, threshold at half the corpus, and threshold at the cache bytes; the last two now both resolve to the cache bytes. 5 interleaved repetitions each, judged on the filtered statement's served share over runs 2 to 4.
The flag stays default-off however these come out.
test(sql): pin the size trigger's cache, late-serve, failure and shap…
test(server): pin the bulk-scan floor wiring and correct its docs
test(server): count lateServedBytes in the SQL phase entry pin
test: count lateServedBytes in the phase entry field pins
pmoust commentedon Oct 22, 2026
Readout for the size-trigger fix (59468e6, branch
land/3084-fix), against #3084 (comment).Verdict: every pre-registered band passed. With the flag set, ClickBench is now neutral and the generated loop keeps its win.
Run 1: ClickBench kit A/B. Run
clickbench-20261022T095228Z-97175-27837, both arms on 59468e6, arm B with--cache-bulk-scan-min-bytes 1073741824. fetch.sh judged the arms comparable: they differ only by the declared flag.cache.intent = bulkScaninteractive)setting="cache_bulk_scan_min_bytes" value=6184752906 source="flag-floored-to-cache-max-bytes" flag=1073741824Run 2: generated lane. Fleet task f83d367b, amd64:
--runs 4, 5 interleaved repetitions per arm.interactiveon run 1 in every arm,interactiveon runs 2 to 4 unset, andbulkScanon runs 2 to 4 in both flagged arms. The stamped threshold equals the cache bytes in the flagged arms and is absent in the unset arm.The unset arm moved, and not because of this fix. It fell from the earlier 11.92 % to 0 %. Bisect task 8f08b856 put it on the logs-scan work queue (#3164): 12.26 % just before it, 0.341 % just after, and 0.341 % on this commit. That is filed as #3192. It is on main only, and the flag is the designed remedy for that loop shape.
What is still open before the PR. The checkpoint review passed this commit with six should-fix findings, now in fix round 748bad6e:
These figures were taken on 59468e6, before that round. The round only makes the trigger count bytes it now misses, and it narrows the key, so it cannot add a bulk classification ClickBench does not already have. The PR will say which commit was measured.
Decision. The flag stays default-off. It is now safe to set on a mixed analytic workload, where it does nothing that hurts, and it helps a scan repeated over more data than the cache holds.
feat(sql): trigger bulk scans on a statement's own read bytes
test(sql): let the late-serve test tolerate a second late serve
docs(server): list lateServedBytes among the exemplars accounting exc…