Skip to content

3072-T3b2 (P5): plumb AccessIntent from the SQL executor to the cache #3084

Closed

Description

@pmoust

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 under stats.cache.bulkScan, a one-series metrics statement whose name prunes nothing shows under interactive; shown failing against a classifier keyed on segments_pruned == 0 alone).
  • 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_json parity tests (phase_costs_json_names_every_field_exactly_once) unchanged; a new exact-key-set test for stats.cache.
  • /metrics renders ravel_cache_hits_total{cache="fetch",tier="ram",intent="bulk_scan"} (asserted on the real surface).

Risk. Medium.

Activity

  1. pmoust commented on Oct 18, 2026

    @pmoust
    MemberAuthor

    Checkpoint review of task 6503eae7 (result 770a369, landing as PR #3098): PASS, with three items to settle before #3084 sets BulkScan from the executor, each either fixed in #3084's task or recorded here as an accepted deviation:

    1. The bulk region is a reservation once bulk traffic stops: below its share, region bytes are reclaimed by an interactive admission only when both the small and main queues are empty (s3fifo.rs around 818-833), and rotation and idle-tenant retirement run only inside observe_bulk, whose clock advances on bulk reads. After the last bulk read, up to bulk_share of 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.
    2. An entry's life in the region is bounded at 16 to 32 x max_bytes of traffic (rotation window capped at 16 x max_bytes, eviction at the second rotation after the last read), so a loop larger than about 32 x max_bytes (about 368 GB on an 11.5 GB cache) serves nothing. docs/guides/caching.md and the AccessIntent::BulkScan doc state the stable subset with no bound.
    3. The small-queue eviction added for a cache already full of interactive entries (s3fifo.rs around 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_unread and retire_tenant walk the whole rank map (up to about 599k entries at 11.5 GB) under the cache mutex, amortised over at least 2 x max_bytes of traffic, but once 64 tenants are tracked every new bulk tenant triggers a full walk; AccessIntent lives 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.md and the config.rs flag help still say LRU.

  2. pmoust commented on Oct 20, 2026

    @pmoust
    MemberAuthor

    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.

  3. added a commit that references this issue on Oct 20, 2026
    6b91bb0
  4. pmoust commented on Oct 20, 2026

    @pmoust
    MemberAuthor

    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):

    • BulkScan narrows on both lanes to a predicate-free plan with no Join or Limit over an epoch-to-now window. The logs whole-segment test alone admitted a one-hour ORDER BY ... LIMIT 50 and per-tick alert counts as bulk; the samples arm ignored WHERE.
    • Table tests for the predicate-free check through the real planner, HTTP tests for both lanes' windows, funnel intent tests for blocks, runs, whole-object splits and spans, exact hot-set count.
    • Observability table, query-engine JSON shape, caching guide wording, an ADR-3072 amendment recording the narrower trigger.

    Filed: #3133 (bulk region persists after bulk traffic stops), #3134 (intent to distributed workers, frozen-contract change).

  5. added 2 commits that reference this issue on Oct 20, 2026
    0935a8e
    52584fe
  6. pmoust commented on Oct 21, 2026

    @pmoust
    MemberAuthor

    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):

    • the foreground served 0.9999 from cache in every arm, so this fixture shows no interactive loss for the trigger to recover; the served-share band is unreachable on it;
    • background statements completed: 61, 61, 70;
    • scans-only with the cache at 1.5x the corpus: 0.9987 served in every arm;
    • identical result digests in every arm.

    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.

  7. 7 remaining items

  8. pmoust commented on Oct 22, 2026

    @pmoust
    MemberAuthor

    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 as BulkScan. 43/43 statements answered on both arms, with identical row counts and answers.

    figure (B/A) value pre-registered reading
    hot geometric mean, 43 statements, best of tries 2 and 3 1.053 (672.5 ms to 708.2 ms) regressed (> 1.03)
    hot geomean without q25 and q27 1.006 neutral
    hot sum 1.012 (56.77 s to 57.43 s) neutral
    cold sum 0.999 neutral
    cold geomean 1.008 neutral
    concurrent qps_pooled 0.807 (0.508 to 0.410) regressed; kit band >= 0.95 x A MISS; B slower in every round (0.377 / 0.452 / 0.402 against 0.545 / 0.518 / 0.462)
    kit relative bands 50 PASS, 1 MISS (the qps row)

    The hot regression comes from q25 (2.49x) and q27 (2.93x). These are small ORDER BY ... LIMIT statements 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.

  9. pmoust commented on Oct 22, 2026

    @pmoust
    MemberAuthor

    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 main 8b7630877 (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.

    Figure Band Basis
    arm B statements whose stats report cache.intent = bulkScan, over every call in the benchmark phase exactly 0 of 43, since the floor raises 1 GiB to the 6.18 GB cache and no statement reads more predicted
    arm B startup line for the resolved threshold present once, value 6184752906, source naming the cache floor predicted
    concurrent qps_pooled B/A >= 0.95 predicted
    hot geomean B/A over 43 statements <= 1.03 predicted
    rows returned equal on all 43 predicted

    A 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.

    Figure Band Basis
    flagged arms, served share on runs 2 to 4, median of 5 >= 40 % (miss below 25 %) predicted
    filtered statement's intent interactive on run 1 in every arm; bulkScan on runs 2 to 4 in the flagged arms only predicted
    unset arm, median served share no band; reported (the earlier figure was 11.92 %) predicted

    The flag stays default-off however these come out.

  10. pmoust commented on Oct 22, 2026

    @pmoust
    MemberAuthor

    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.

    Figure Band Result Verdict
    arm B benchmark calls with cache.intent = bulkScan 0 of 43 statements 0 of 129 calls (all interactive) HIT
    arm B startup line once, 6184752906, floored setting="cache_bulk_scan_min_bytes" value=6184752906 source="flag-floored-to-cache-max-bytes" flag=1073741824 HIT
    concurrent qps_pooled B/A >= 0.95 0.512 / 0.538 = 0.9505 HIT
    hot geomean B/A, 43 statements <= 1.03 1.020 HIT
    rows returned equal on 43 equal HIT
    • The kit's own relative bands all pass too: hot sum 56.62 s against a bound of 58.58 s, cold sum 1621.3 s against 1699.7 s, and q29 probe min 4.91 s.
    • Concurrent errors are 0.025 weighted in B against 0.036 in A.
    • Both arms now run the same cache policy, so the 2 to 5 % differences above are the run-to-run noise of two boxes, not an effect.
    • The largest hot ratios are again q25 (1.29) and q27 (1.15). Those are the two TopK statements whose warm runs read a different block set each try, and they did the same with the flag off, as shown earlier on this issue.
    • For comparison, the pre-fix A/B with the flag at 1 GiB gave 43 of 43 statements bulk, QPS 0.807 and hot geomean 1.053.

    Run 2: generated lane. Fleet task f83d367b, amd64:

    • 25 objects, 2,089,843 bytes;
    • cache 1,044,922 bytes;
    • --runs 4, 5 interleaved repetitions per arm.
    Arm Served share on runs 2 to 4, per rep Median Band Verdict
    unset 0, 0, 0, 0, 0 % 0.0 % none reported
    threshold = half the corpus (floored to the cache) 47.89, 43.75, 47.76, 47.81, 43.99 % 47.76 % >= 40 % HIT
    threshold = cache bytes 47.88, 47.90, 43.81, 39.74, 47.98 % 47.88 % >= 40 % HIT
    • Preconditions passed: identical rows on every run. The intent was interactive on run 1 in every arm, interactive on runs 2 to 4 unset, and bulkScan on 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.
    • Segment content-hash distinctness could not be checked, because the bench report carries no per-segment hash.

    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:

    • late-served reads are not counted in the recorded bytes;
    • three untested claims;
    • the record key ignores the request window and the row limit;
    • one stale doc line.

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions