Skip to content

perf: unwrap metrics read their buckets from the stats extent cache - #886

Merged
szibis merged 1 commit into
mainfrom
perf/unwrap-extent-cache
Oct 28, 2026
Merged

szibis merged 1 commit into
mainfrom
perf/unwrap-extent-cache

Conversation

@szibis

@szibis szibis commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Unwrap metrics now use the stats extent cache. A refresh or moved window of a step or more, and a re-run, reads only the VictoriaLogs buckets it has not read before; a window that moves by less than a step is not helped (measured below). Loki's answers stay byte for byte the same, its 400 for a rejected value included.

This builds on the unwrap window change (#885, merged), rebased onto main as 8ca2b16e.

What was wrong

  • Every query holding unwrap carries the conversion-error check. The check counts:

    • values Loki's conversion rejects, which make Loki answer 400 (pkg/logql/evaluator.go:726-733);
    • nan values (a sample for Loki).

    It is attached even with | __error__="", for the nan count.

  • statsExtentPost refused every stats_query_range call of a checked request (stats_extent_cache.go, "checked unwraps are not cached").

  • So a dashboard refresh read the whole window from VictoriaLogs again. On the A/B refresh pass, all 77 unwrap rows were slower than Loki: median 6× at 1h and 20× at 6h. The worst was sum by (service_name) (sum_over_time({env="production"} | logfmt | unwrap latency_ms [5m])) at 6h: 544 ms against Loki's 3 ms.

  • Simply dropping the refusal would have been wrong. vlPostHTTP called the cache before countingStatsQuery rewrote the query, so:

    • the cache would have sent and stored the plain query;
    • no hit would have been recorded;
    • Loki's 400 would have become a 200.

Fix

  • vlPostHTTP now rewrites the query to its counting form before statsExtentPost. The cache keys and stores the counting query's buckets.
  • The rejected values (flag 1) and the nan values (flag 2) are groups of their own in each bucket. They are bucket-local like every series of the answer, and the cache rebuilds VictoriaLogs' body exactly.
  • The strip hook runs on the answer the cache assembles, from stored and fresh buckets alike. It records the hits and applies the nan groups. A hit's lookup then reads that bucket's rows, as before.
  • A read the cache sends itself (statsExtentFetchKey) already carries the rewritten query and is not rewritten again.
  • As for every other metric, a row written into a stored bucket later is seen only once the entry expires (-query-range-extent-cache-ttl). The newest -query-range-freshness is always read. Loki's results cache has the same window.

Tests

  • TestUnwrapExtentCacheMatchesUncached (unit): cache on against cache off, over the same emulated VictoriaLogs.
    • Queries (five unwrap metrics):
      • sum, with and without | __error__="";
      • max by;
      • avg over [1m];
      • a [30s] range at step 2m.
    • Windows: a refresh moving one step at a time, a re-run, a window widened back, and one moved.
    • Every answer is byte for byte the same:
      • a value Loki rejects, read only in the fresh part of a moved window: Loki's 400 in 6 answers;
      • the same value in a stored bucket: 18 answers with the 400, two of them answered from stored buckets alone;
      • a nan: NaN windows;
      • clean data.
    • Buckets read from VictoriaLogs: 570 with the cache, 2760 without.
    • The test fails without the change: the cache never answers an unwrap call.
  • The emulator now models:
    • the counting gate's flags;
    • the lookup of the flagged rows;
    • the stored-row read.
  • TestCompat_UnwrapExtentCacheRefresh (e2e, group core, compiled with -tags e2e): an own fixture with one rejected value and one nan, asked of Loki and the proxy over windows that are moved, repeated, moved back and widened. It compares status, Loki's pipeline-error text and every value.

Proofs (local, no Docker)

  • Unit tests capped: pass. -race on the touched tests (741 after the rebase): pass.

  • Full capped run, GOMEMLIMIT=3GiB GOMAXPROCS=4 /usr/bin/time -l go test -short -count=1 -p 1 ./cmd/... ./internal/...: every package ok.

    internal/proxy peak RSS
    main 250c5d3c 113.0 s 2,514,403,328 B
    stacked base c538f410 120.0 s 2,698,952,704 B
    this branch bfaedadd 124.2 s 2,431,811,584 B
    stacked base after the rebase 93af511d 121.8 s 2,648,932,352 B
    this branch after the rebase 0f75dcee 123.1 s 2,410,201,088 B

    Every package ok on an otherwise idle host. The peak depends on how the tests are scheduled (this work's earlier runs ranged from 1.735 to 2.7 GB); this branch peaks below its base and below main in these runs. Full runs taken on a loaded host failed on macOS ephemeral-port exhaustion (can't assign requested address) or the ten-minute go test timeout, never on an assertion, and passed when repeated idle.

  • go vet ./... and go vet -tags e2e ./test/e2e-compat/: clean. gofmt -l: clean. golangci-lint run ./...: 0 issues.

  • Conformance gate ok. Changelog gate ok.

Registry

New (pending owner review):

id: quality/unwrap-query-range-extent-cache
title: an unwrap metric moved by a refresh or a re-run reads only the VictoriaLogs buckets not read before, with the same answers and the same 400s (fixed)
proves:
  - data-probing-and-freshness
  - loki_api_v1_query_range
consumer: explore
why: |
  Loki answers a moved metric window from its results cache extents
  (pkg/storage/chunk/cache/resultscache/cache.go Do, handleHit), unwrap
  metrics included, and evaluates the rest; a value its conversion rejects
  still fails the query with its 400 (pkg/logql/evaluator.go:726-733).

  The stats extent cache (quality/query-range-extent-cache) refused every
  stats_query_range call of a request whose unwrap the conversion check reads
  (statsExtentPost), and every query holding `unwrap` gets one: the check
  counts the nan values even with `| __error__=""`. So every unwrap metric read
  its whole window from VictoriaLogs on a refresh: on the 2026-10 A/B all 77
  unwrap refresh rows were slower than Loki, up to 544 ms against 3 ms for
  `sum by (service_name) (sum_over_time({env="production"} | logfmt | unwrap latency_ms [5m]))`
  at 6h. The cache ran ahead of the counting rewrite (vlPostHTTP), so dropping
  the refusal alone would have stored the plain query and turned Loki's 400
  into a 200.

  Fixed: vlPostHTTP rewrites the query to its counting form before the extent
  cache, which keys and stores that query's buckets; the rejected values
  (flag 1) and the nan values (flag 2) are groups of their own in each bucket,
  bucket-local like every series of the answer, and the check reads them off
  the answer the cache assembles, stored buckets and fresh ones alike, then
  looks the rejected row up in its bucket as before. A read the cache sends
  itself carries the rewritten query. As for every other metric, a row
  written into a bucket after it was stored is not seen until the entry
  expires (-query-range-extent-cache-ttl); the newest -query-range-freshness
  is always read.
request:
  method: GET
  path: /loki/api/v1/query_range
  params:
    query: 'sum by (service_name) (sum_over_time({env="production"} | logfmt | unwrap latency_ms [5m]))'
    start: 'now-6h'
    end: 'now'
    step: '60'
  headers:
    X-Scope-OrgID: "0"
compare:
  mode: numeric
  fields: [data.result]
  expect: |
    The same series and values as Loki, and Loki's 400 where its window holds
    a rejected value, for every window of a refresh and a re-run sequence; a
    refresh reads only the new buckets from VictoriaLogs.
gap:
  status: fixed
  fixed_by: TBD-PR
  impact: explore-visible
  visual: none
  visual_reason: 'a latency change: every answer and error is byte for byte the uncached one, proven by the cache-on/off differential; the refresh time is measured by the A/B refresh pass'
  effort: S
  area: 'internal/proxy backend.go vlPostHTTP (the counting rewrite ahead of statsExtentPost); stats_extent_cache.go statsExtentPost'
  loki: 'pkg/storage/chunk/cache/resultscache/cache.go Do, handleHit; pkg/logql/evaluator.go:726-733'
  proxy: 'unwrap stats_query_range calls answered from stored buckets of the counting query, the counter read off the assembled answer'
  planned_test: 'TestUnwrapExtentCacheMatchesUncached (internal/proxy); the unwrap conversion-error e2e run twice with moved windows'
  clusters: []

Proofs on a stack

Colima VM (8 CPU, 14 GB), Loki 3.7.9, VictoriaLogs v1.49.0 and v1.53.0, one stack at a time. Stack-proof heads: c538f410 (the branch this one is stacked on) and bfaedadd (this branch), cut from 250c5d3c; rebased onto 507915a5 they are 93af511d and de6fadb7 (main gained only #883, a transport retry; gates, unit run and the e2e regex group repeated on the rebased heads, all green; the other numbers carry over). The head 0f75dcee is de6fadb7 plus a text-only amend that adds the sub-step refresh limitation to the CHANGELOG entry and the registry row (found by the last run below; conformance, changelog, build, vet and lint gates rerun). VictoriaLogs and Loki restart counts were read before and after the e2e, A/B, 24h, 7d (VictoriaLogs) and load runs and were 0 every time; the visual runs did not record them.

Status against the bar (warm and refresh of every unwrap row at most 2x Loki, target Loki + 5 ms): not met. 20 of 190 cells fail (E2, U9, W1 to W4, W7, and W8 which is not an unwrap query), and the sub-step refresh below means a dashboard that auto-refreshes faster than its step gets no benefit from this branch at any range: the 7d E1, E3, E4, E8, E9 refresh gap stays open for that pattern, and a 15 s refresh at 24h/60 s is 75 to 600x Loki (measured). What the branch does deliver: a refresh that moves a whole step or more is served from stored buckets (24h: 9 to 39 ms against Loki's 34 to 310 ms; 7d: 5 to 15 ms), with the same answers and the same 400s.

e2e against Loki

go test -tags e2e -count=1 -run '^(TestSetup_IngestLogs|TestCompat_UnwrapExtentCacheRefresh|TestCompat_UnwrapConversionErrorParity|TestCompat_UnwrapOverTimeRangeWindows|TestCompat_UnwrapValueFormsParity|TestCompat_UnwrapOverflowParity)$' ./test/e2e-compat/ on an isolated compose project:

VictoriaLogs group tests passed (incl. subtests) failed skipped (documented open forms) seconds VL / Loki restarts before-after uptime before
v1.49.0 regex group 571 0 87 27 0/0 - 0/0 21:45 load 4.46 5.44 7.48
v1.53.0 regex group 571 0 87 24 0/0 - 0/0 21:47 load 7.22 6.26 7.49

Named tests: TestCompat_UnwrapConversionErrorParity PASS, TestCompat_UnwrapExtentCacheRefresh PASS, TestCompat_UnwrapOverTimeRangeWindows PASS, TestCompat_UnwrapOverflowParity PASS, TestCompat_UnwrapValueFormsParity PASS, TestSetup_IngestLogs PASS

Rebased head de6fadb7: 571 passed, 0 failed, 87 skipped on v1.49.0 and v1.53.0. The skips are the open forms of TestCompat_UnwrapValueFormsParity and TestCompat_UnwrapConversionErrorParity/open (registered, by design).

A/B: the stacked base, this branch and Loki

pr_smoke.py --base perf/unwrap-conversion-check --head perf/unwrap-extent-cache --ranges 1h,6h --refresh-s 60 --refresh-runs 5 over unwrap-conversion-error, unwrap-gate, unwrap-validity, unwrap-ungrouped-sum, unwrap-sum-without and control (132 rows, load average 6.3 at the start, 27.2 minutes). Base c538f410, head bfaedadd.

Verdict: faster, no regression. 8 faster, 124 within noise; results against Loki: 120 same, 0 new differences, 12 pre-existing. Above 2x Loki: 0 new, 0 worsened, 22 on the committed list, 28 cleared. No cell (cold, warm or refresh) is slower than the base by more than 50 ms and 25%; 66 are faster by that much.

The verdict-level movers (6h): E2 403 to 161 ms, E3 443 to 3 ms, E8 190 to 5 ms, E9 274 to 1 ms, G10 402 to 2 ms, U4 284 to 2 ms, U12 230 to 3 ms, U13 415 to 2 ms.

The bar: unwrap warm and refresh at most 2x Loki (target Loki + 5 ms). Over the 190 warm and refresh cells of the five unwrap sets: 170 meet 2x (or sit within the 50 ms floor) and 160 meet Loki + 5 ms. The 20 that do not, by form:

form cells PR vs Loki route
E2 quantile_over_time(0.95, ... unwrap ...) 6h warm, refresh 161 / 154 ms against 3 ms the quantile route (fetchQuantileCounts), not the stats extent cache
U9 rate_counter folded per series, 1h and 6h warm, refresh 160 to 180 ms against 3 to 22 ms the per-series fold route
W1, W2, W3, W4, W7 sum/max without (pod) over an unwrap function, 6h warm, refresh 113 to 143 ms against 3 to 18 ms the sliding-window route of the stacked branch (proxyManualRangeMetricRange), which reads its buckets from VictoriaLogs on every request
W8 sum without (pod) (count_over_time(...)), 1h and 6h (not an unwrap query) warm, refresh 1.3 to 1.6 s at 6h against 10 to 87 ms the same route; the per-stream matrix is built before the fold

Every other unwrap row (E1, E3 to E9, G1 to G12, U1 to U8, U10 to U14) meets the 2x bar on warm and refresh; 10 of those cells are between Loki + 5 ms and 2x. The forms above are not served by the stats extent cache at all, so they are outside the claim of this change; they stay above 2x and are the next item (route the window and quantile reads through the extent cache, and fold from stored buckets).

Rows above 2x Loki (PR ÷ Loki above 2 and more than 50 ms over)

The tool's "Above 2x Loki" section for this run lists 0 new and 0 worsened rows (22 are on the committed list, 28 cleared). All 25 cells above 2x are below; the base column shows that none was above 2x by less on the base:

set shape range mode base PR Loki PR÷Loki base÷Loki
unwrap-conversion-error E2 json duration_ms p95 by service 6h cold 450 ms 190 ms 3 ms 63.3x 150.00x
unwrap-conversion-error E2 json duration_ms p95 by service 6h warm 403 ms 161 ms 3 ms 53.7x 134.33x
unwrap-conversion-error E2 json duration_ms p95 by service 6h refresh 397 ms 154 ms 3 ms 51.3x 132.33x
unwrap-ungrouped-sum U9 logfmt amount rate_counter (folded per series) 1h warm 159 ms 167 ms 22 ms 7.6x 7.23x
unwrap-ungrouped-sum U9 logfmt amount rate_counter (folded per series) 1h refresh 166 ms 161 ms 4 ms 40.2x 41.50x
unwrap-ungrouped-sum U9 logfmt amount rate_counter (folded per series) 6h warm 186 ms 179 ms 22 ms 8.1x 8.45x
unwrap-ungrouped-sum U9 logfmt amount rate_counter (folded per series) 6h refresh 193 ms 180 ms 3 ms 60.0x 64.33x
unwrap-sum-without W1 sum without pod, max_over_time 6h warm 146 ms 122 ms 17 ms 7.2x 8.59x
unwrap-sum-without W1 sum without pod, max_over_time 6h refresh 172 ms 113 ms 4 ms 28.2x 43.00x
unwrap-sum-without W2 sum without pod, sum_over_time 6h cold 175 ms 150 ms 71 ms 2.1x 2.46x
unwrap-sum-without W2 sum without pod, sum_over_time 6h warm 158 ms 116 ms 18 ms 6.4x 8.78x
unwrap-sum-without W2 sum without pod, sum_over_time 6h refresh 155 ms 117 ms 5 ms 23.4x 31.00x
unwrap-sum-without W3 sum without pod, rate 6h cold 190 ms 197 ms 66 ms 3.0x 2.88x
unwrap-sum-without W3 sum without pod, rate 6h warm 196 ms 142 ms 12 ms 11.8x 16.33x
unwrap-sum-without W3 sum without pod, rate 6h refresh 180 ms 143 ms 4 ms 35.7x 45.00x
unwrap-sum-without W4 max without pod, max_over_time 6h warm 161 ms 114 ms 12 ms 9.5x 13.42x
unwrap-sum-without W4 max without pod, max_over_time 6h refresh 153 ms 117 ms 3 ms 39.0x 51.00x
unwrap-sum-without W7 topk 5 of sum without pod 6h warm 156 ms 114 ms 12 ms 9.5x 13.00x
unwrap-sum-without W7 topk 5 of sum without pod 6h refresh 163 ms 116 ms 4 ms 29.0x 40.75x
unwrap-sum-without W8 sum without pod, count_over_time (production) 1h warm 135 ms 136 ms 50 ms 2.7x 2.70x
unwrap-sum-without W8 sum without pod, count_over_time (production) 1h refresh 130 ms 122 ms 7 ms 17.4x 18.57x
unwrap-sum-without W8 sum without pod, count_over_time (production) 6h cold 1.27 s 1.29 s 576 ms 2.2x 2.20x
unwrap-sum-without W8 sum without pod, count_over_time (production) 6h warm 1.60 s 1.62 s 87 ms 18.6x 18.43x
unwrap-sum-without W8 sum without pod, count_over_time (production) 6h refresh 1.31 s 1.29 s 10 ms 129.0x 131.20x
control topk rate by app 6h refresh 82 ms 82 ms 9 ms 9.1x 9.11x

25 mode-rows above 2x of 132 shape x range rows.

Long ranges

Same stack and table as the base branch's description (E1 E3 E4 E8 E9, main, the stacked base, this branch, Loki). 24h (step 60 s, refresh = one step): cold equals the base, warm 1 ms (response cache), refresh drops from 0.29 to 2.94 s on the base to 9 to 39 ms here, at or below Loki's 34 to 310 ms:

shape refresh stacked base refresh this branch Loki
E1 763 ms 10 ms 110 ms
E3 2.94 s 18 ms 307 ms
E4 288 ms 22 ms 34 ms
E8 1.27 s 39 ms 310 ms
E9 1.68 s 9 ms 194 ms

Answers equal to Loki's on all of them (E3 has the pre-existing second series).

7d (VictoriaLogs only, Loki infeasible at this length on the stack, see the base branch): a refresh that moves a whole step (600 s) is served from stored buckets, 5 to 15 ms against 0.78 to 8.4 s on the base:

shape mode main branch 1 branch 2
E1 json duration_ms sum by service cold 4.10 s 1.25 s 1.33 s
E1 json duration_ms sum by service warm 0 ms 0 ms 0 ms
E1 json duration_ms sum by service refresh 2.98 s 1.26 s 5 ms
E3 logfmt latency_ms rate, production cold 13.69 s 7.65 s 7.79 s
E3 logfmt latency_ms rate, production warm 0 ms 0 ms 0 ms
E3 logfmt latency_ms rate, production refresh 14.87 s 8.41 s 15 ms
E4 logfmt size_bytes max by service cold 1.97 s 1.12 s 861 ms
E4 logfmt size_bytes max by service warm 0 ms 0 ms 0 ms
E4 logfmt size_bytes max by service refresh 1.58 s 780 ms 6 ms
E8 json duration_ms sum by service, na cold 7.64 s 4.38 s 3.89 s
E8 json duration_ms sum by service, na warm 0 ms 0 ms 0 ms
E8 json duration_ms sum by service, na refresh 7.02 s 4.25 s 14 ms
E9 logfmt latency_ms sum by service, n cold 11.51 s 5.80 s 5.72 s
E9 logfmt latency_ms sum by service, n warm 1 ms 1 ms 1 ms
E9 logfmt latency_ms sum by service, n refresh 10.48 s 5.79 s 11 ms

A refresh that moves less than a step is not cached. At 7d, windows moving 60 s on a 600 s step (inside one step bucket): this branch equals its base within noise (E1 1.00 s against 1.32 s, E3 9.27 s against 9.22 s, E4 0.70 s against 0.74 s, E8 3.75 s against 3.95 s, E9 5.90 s against 5.73 s). The same at 24h, measured against Loki (step 60 s, windows moving 15 s, five runs, same stack as the 24h table):

shape mode main branch 1 branch 2 Loki branch 1 ÷ Loki branch 2 ÷ Loki main ÷ Loki
E1 json duration_ms sum by service refresh 454 ms 505 ms 564 ms 5 ms 101.00x 112.80x 90.80x
E3 logfmt latency_ms rate, production refresh 2.32 s 2.45 s 2.34 s 4 ms 612.00x 584.25x 581.00x
E4 logfmt size_bytes max by service refresh 239 ms 228 ms 225 ms 3 ms 76.00x 75.00x 79.67x
E8 json duration_ms sum by service, na refresh 1.02 s 975 ms 957 ms 4 ms 243.75x 239.25x 254.50x
E9 logfmt latency_ms sum by service, n refresh 1.46 s 1.49 s 1.38 s 4 ms 373.25x 345.50x 365.00x

Loki answers a refresh inside a step in 3 to 5 ms (its results cache, with align_queries_with_step: true in the stack's Loki config); the three proxy builds read VictoriaLogs again (0.23 to 2.3 s), this branch included, so a 5 s, 10 s or 30 s auto-refresh gets nothing from the extent cache at 24h and at 7d. The cause is not isolated (the bucket edges probably follow the request's start). The CHANGELOG entry and the registry row now say so; it is the first follow-up.

Load: the backend-safety test

The ABBA run of the base branch's description is this branch's (main, this branch, this branch, main; this branch contains the other):

slot build requests 200 400 429 502 p50 p95 peak RSS RSS at end /labels probe (n, non-200, max) VL / Loki restarts before-after
0 main 958 896 50 2 10 5.419 s 17.181 s 3136.3 MB 3136.3 MB 93, 0, 1.408 s 0/0 - 0/0
1 branch 2 937 874 49 5 9 5.586 s 19.117 s 2412.0 MB 1770.5 MB 91, 0, 0.729 s 0/0 - 0/0
2 branch 2 969 902 52 5 10 5.227 s 17.558 s 2578.7 MB 2578.7 MB 94, 0, 1.628 s 0/0 - 0/0
3 main 682 639 30 5 8 6.651 s 25.213 s 3073.3 MB 3071.2 MB 88, 0, 2.291 s 0/0 - 0/0

Same status counts, p50 and p95 as main within noise, /labels 200 on every probe, peak RSS of this branch 2.4 to 2.6 GB against main's 3.1 GB, no restarts.

Visual

ci_run.py --base perf/unwrap-conversion-check (the stacked base against this branch against Loki), 23 captures (the unwrap pages, the core Explore pages, Drilldown landing, service logs and fields; 15m, 1h, 6h): base and branch are identical on every request of every page (main = PR on 23 of 23). Against Loki the panel data is identical; the index/stats difference is listed unexplained on this stack only because its Loki counted every line twice (entries 99,772 against 49,886, chunks 3,922 against 1,961, the same streams); the metric queries are identical.

Uptime and restarts

run load average at start VictoriaLogs / Loki restarts
e2e (4 runs) 4.5 to 7.2 0 / 0 to 0 / 0
A/B 6.3 0 / 0 to 0 / 0
24h 13.8 0 / 0 to 0 / 0
7d, whole-step refresh 8.3 0 to 0
load 5.7 0 / 0 to 0 / 0 per slot
visual 5.6 stack removed after the run

Not covered

  • A refresh that moves less than a step at 7d (step 600 s) is not served from the extent cache (nor at 24h with step 60 s and a 15 s move), while Loki answers it in 3 to 5 ms. Measured above; first follow-up.
  • The routes listed in the A/B bar table (the quantile route, rate_counter per series, the without sliding-window route and the non-unwrap without fold of W8) do not use the stats extent cache.
  • /select/logsql/query calls are not cached by the extent cache: the sliding-window recurrence grids of topk, and the unwrap lookups. Separate item.
  • stats_query calls (instant queries) are not cached by the extent cache, as before.

@github-actions github-actions Bot added size/L Large change scope/proxy Proxy core scope/docs Documentation scope/tests Tests performance Performance labels Oct 28, 2026
@szibis
szibis force-pushed the perf/unwrap-conversion-check branch 3 times, most recently from da1b25e to 44ca788 Compare October 28, 2026 09:51
@szibis
szibis force-pushed the perf/unwrap-extent-cache branch from 0f75dce to ea111ef Compare October 28, 2026 10:12
@github-actions github-actions Bot added size/L Large change and removed size/L Large change labels Oct 28, 2026
Base automatically changed from perf/unwrap-conversion-check to main October 28, 2026 10:16
…ache

Every query holding unwrap carries the conversion-error check: it counts the
values Loki's conversion rejects (Loki's 400, pkg/logql/evaluator.go:726-733)
and the nan values, even with `| __error__=""`. statsExtentPost refused every
stats_query_range call of such a request (stats_extent_cache.go, "checked
unwraps are not cached"), so a refresh read the whole window from
VictoriaLogs: on the A/B refresh pass all 77 unwrap rows were slower than
Loki, up to 544 ms against 3 ms (G10 6h). Dropping the refusal alone would
have been wrong: vlPostHTTP called the cache before the counting rewrite, so
the cache would have sent and stored the plain query, no hit would have been
recorded, and Loki's 400 would have become a 200.

vlPostHTTP now rewrites the query to its counting form (countingStatsQuery)
before statsExtentPost, which keys and stores that query's buckets: the
rejected (flag 1) and nan (flag 2) values are groups of their own, local to
their bucket like every series of the answer, which the cache rebuilds
exactly. The strip hook runs on the answer the cache assembles, stored buckets
and fresh ones alike, records the hits and applies the nan groups; the lookup
of a hit reads its bucket's rows as before. A read the cache sends itself
(statsExtentFetchKey) carries the rewritten query and is not rewritten again.

TestUnwrapExtentCacheMatchesUncached runs five unwrap metrics (with and
without the error filter, max by, avg over [1m], a [30s] range at step 2m)
over a refresh moving a step at a time, a re-run, a window widened back and
one moved, cache on against cache off over the same emulated VictoriaLogs:
every answer byte for byte the same, Loki's 400 for a rejected value read only
in the fresh part (6 answers) and stored in a bucket (18, two answered from
stored buckets alone), NaN windows for a nan, and 570 buckets read against
2760. The emulator now models the counting gate's flags, the lookup of the
flagged rows and the stored-row read. The e2e
TestCompat_UnwrapExtentCacheRefresh asks Loki and the proxy the same moved,
repeated and widened windows.
@szibis
szibis force-pushed the perf/unwrap-extent-cache branch from ea111ef to 8ca2b16 Compare October 28, 2026 10:19
@github-actions github-actions Bot added size/L Large change and removed size/L Large change labels Oct 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⚪ Performance A/B: no change beyond noise

19 shape×range — 19 ⚪ same · results vs Loki: 15 same, 0 new difference(s), 4 pre-existing

Loki profiles compared (like for like):

  • aligned: not run (no build supports -align-queries-with-step, so every aligned row would be not like-for-like)
  • loki-defaults: 19 shape×range; proxy flag base off, pr off

Every shape is within noise of the base build (±30% and 100 ms).

19 unchanged shape×range (within noise)
shape range Loki profile base p50 PR p50 change Loki p50 PR ÷ Loki result vs Loki
⚪ drilldown service landing 1h defaults 11 ms 16 ms ⚪ +45% 113 ms 0.14× ✅ same
⚪ instant ungrouped rate instant defaults 6 ms 6 ms ⚪ +0% 125 ms 0.05× ✅ same
⚪ json rate by level (no filter, no drop) 1h defaults 400 400 ⚪ +0% 400 — ✅ same
⚪ json volume no filters 1h defaults 86 ms 87 ms ⚪ +1% 359 ms 0.24× ✅ same
⚪ logs plain 1h defaults 114 ms 110 ms ⚪ -4% 582 ms 0.19× ✅ same
⚪ logs | json 1h defaults 257 ms 251 ms ⚪ -2% 1.80 s 0.14× ✅ same
⚪ stream-label count by app 1h defaults 9 ms 9 ms ⚪ +0% 313 ms 0.03× ✅ same
⚪ topk rate by app 1h defaults 44 ms 50 ms ⚪ +14% 181 ms 0.28× ➖ differs (as base)
⚪ drilldown detected fields 1h defaults 1 ms 1 ms ⚪ +0% 15 ms 0.07× ➖ differs (as base)
⚪ drilldown detected labels 1h defaults 1 ms 1 ms ⚪ +0% 2 ms 0.50× ➖ differs (as base)
⚪ drilldown landing volume 1h defaults 58 ms 56 ms ⚪ -3% 28 ms 2.00× ➖ differs (as base)
⚪ explore label values env 1h defaults 1 ms 1 ms ⚪ +0% 12 ms 0.08× ✅ same
⚪ explore label values service_name 1h defaults 1 ms 1 ms ⚪ +0% 17 ms 0.06× ✅ same
⚪ explore labels (label browser) 1h defaults 1 ms 1 ms ⚪ +0% 2 ms 0.50× ✅ same
⚪ explore labels with selector 1h defaults 1 ms 1 ms ⚪ +0% 17 ms 0.06× ✅ same
⚪ refresh label values env 1h defaults 25 ms 22 ms ⚪ -12% 14 ms 1.57× ✅ same
⚪ refresh label values service_name 1h defaults 9 ms 10 ms ⚪ +11% 17 ms 0.59× ✅ same
⚪ refresh labels (label browser) 1h defaults 14 ms 15 ms ⚪ +7% 2 ms 7.50× ✅ same
⚪ refresh labels with selector 1h defaults 15 ms 14 ms ⚪ -7% 18 ms 0.78× ✅ same
Cold (first-run) timings — what a user opening a panel sees
shape range Loki profile base cold PR cold Loki cold PR ÷ Loki cold
drilldown service landing 1h defaults 38 ms 45 ms 592 ms 0.08×
instant ungrouped rate instant defaults 6 ms 7 ms 82 ms 0.09×
json rate by level (no filter, no drop) 1h defaults 400 400 400 —
json volume no filters 1h defaults 239 ms 210 ms 1.95 s 0.11×
logs plain 1h defaults 180 ms 146 ms 1.09 s 0.13×
logs | json 1h defaults 411 ms 446 ms 1.60 s 0.28×
stream-label count by app 1h defaults 28 ms 19 ms 1.23 s 0.02×
topk rate by app 1h defaults 46 ms 45 ms 1.73 s 0.03×
drilldown detected fields 1h defaults 25 ms 24 ms 20 ms † —
drilldown detected labels 1h defaults 20 ms 18 ms 2 ms † —
drilldown landing volume 1h defaults 58 ms 57 ms 31 ms 1.84×
explore label values env 1h defaults 81 ms 75 ms 14 ms † —
explore label values service_name 1h defaults 39 ms 39 ms 19 ms † —
explore labels (label browser) 1h defaults 30 ms 30 ms 3 ms † —
explore labels with selector 1h defaults 63 ms 61 ms 18 ms † —
refresh label values env 1h defaults 1 ms 1 ms 13 ms † —
refresh label values service_name 1h defaults 1 ms 1 ms 15 ms † —
refresh labels (label browser) 1h defaults 1 ms 1 ms 2 ms † —
refresh labels with selector 1h defaults 1 ms 1 ms 17 ms † —

† Loki's results cache answered this cold run (its own counters, or identical answers for windows a minute apart): not a computed time, so it is in no ratio.

Why these shapes
  • internal/proxy/backend.go → 11: metadata-inventory/drilldown detected fields, metadata-inventory/drilldown detected labels, metadata-inventory/drilldown landing volume, metadata-inventory/explore label values env, metadata-inventory/explore label values service_name, metadata-inventory/explore labels (label browser), metadata-inventory/explore labels with selector, metadata-inventory/refresh label values env …
  • control smoke → 8: control/drilldown service landing, control/instant ungrouped rate, control/json rate by level (no filter, no drop), control/json volume no filters, control/logs plain, control/logs | json, control/stream-label count by app, control/topk rate by app

base 10c702f4c8 → PR 0309390a4e · 4 runs (first is cold), warm p50 · ranges 1h · noise ±30% and 100 ms · VictoriaLogs restarts 0→0 · 2.1 min · phases stack_up 36s, seed_and_build 40s, loki-defaults:loki_profile 0s, loki-defaults:proxies 2s, loki-defaults:matrix 31s. Same runner, one fresh stack, targets interleaved per request. How to read and reproduce: bench/ab/README.md.

@github-actions

Copy link
Copy Markdown
Contributor

✅ Visual smoke: passed with warnings

Base 10c702f4c8 vs PR 8ca2b16ef9, Grafana through the proxy builds and Loki on one fresh stack; run.
Core set: 5 capture(s) at 1h. Detailed set: 0 entries (nothing visual touched). 5 capture(s), 15m/1h/6h, 24h and 7d stay out of PR runs.

Fix proof

Captures of the exact cases this pull request adds or fixes (registry case, base | PR | Loki). The verdict compares the PR with Loki where the base did not match. The gate fails a case unless one capture is fixed or improved, or, beyond the Loki data window, the base failed where Loki and the PR both answered with data (the label does not excuse it; only visual: none on the case does).

  • quality/unwrap-query-range-extent-cache: no visual proof, exempt: a latency change: every answer and error is byte for byte the uncached one, proven by the cache-on/off differential; the refresh time is measured by the A/B refresh pass

Regression check

page range set base = PR (data) vs Loki (PR) pixel diff result
dd-fields 1h core ✅ identical identical (4 explained, 1 history-dependent) 0.12% ⚠️ history-dependent difference, not gated (1): resource /api/datasources/uid/*/res
dd-landing 1h core ✅ identical identical (3 explained) 2.14% ✅
dd-service-logs 1h core ✅ identical identical (3 explained, 1 history-dependent) 0.12% ⚠️ history-dependent difference, not gated (1): resource /api/datasources/uid/*/res
explore-logs-plain 1h core ✅ identical identical (1 explained) 0.07% ✅
explore-metric-plain 1h core ✅ identical identical (1 explained) 0.04% ✅

Gate: a base-vs-PR data difference passes when the PR is closer to Loki, and fails when the PR diverges from Loki or Loki cannot decide it (at a range Loki does not hold, a difference passes when the same page proved that query to answer like Loki at a shorter range; otherwise label the pull request visual-change-expected to accept an intended change). Also failing: a panel empty on the PR but not on the base, an error banner, panel error, error answer or new "No data" panel on the PR, a PR side that never settled, a difference that did not reproduce on the recapture. Pixel differences only warn.

dd-fields-1h: history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-28T10%3A20%3
  • history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-28T10%3A20%3A00.000Z&query=%7Bservic: body: content differs (rows 4 vs 6)
  • vs Loki, explained: query ('sum by (service_name_extracted) (count_over_time({service_name="otel-collector"} | servi: request only on one side (right) -- explained: Loki's _extracted suffix for a key named like a stream label, where the build does not return it (detected_fields: open, semantics/detected-fields-extracted-suffix; service/service.name: owner decision)
  • vs Loki, explained: resource /api/datasources/uid/*/resources/detected_fields?end=2026-10-28T10%3A20%3A00.000Z&query=%7: body: content differs (rows 13 vs 15) -- explained: Loki's _extracted suffix for a key named like a stream label, where the build does not return it (detected_fields: open, semantics/detected-fields-extracted-suffix; service/service.name: owner decision); the proxy also returns data, status; Loki lists fields in no fixed order
  • vs Loki, explained: resource /api/datasources/uid/*/resources/detected_labels?end=2026-10-28T10%3A20%3A00.000Z&query=%7: body: content differs (rows 9 vs 9) -- explained: sampled cardinality: Loki counts every stream its ingesters hold, not the requested window (pkg/ingester/instance.go LabelsWithValues), the proxy the window's last 5 minutes (metadataMaxFieldNamesWindow); the label names match; the proxy also returns data, limit, status; Loki lists labels in no fixed order
  • vs Loki, explained: resource /api/datasources/uid/*/resources/drilldown-limits: body: content differs (rows 0 vs 0) -- explained: configuration probe: each backend publishes its own limits, version and settings (the proxy's pattern persistence)

dd-fields-1h

dd-service-logs-1h: history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-28T10%3A20%3
  • history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-28T10%3A20%3A00.000Z&query=%7Bservic: body: content differs (rows 4 vs 6)
  • vs Loki, explained: resource /api/datasources/uid/*/resources/detected_fields?end=2026-10-28T10%3A20%3A00.000Z&query=%7: body: content differs (rows 13 vs 15) -- explained: Loki's _extracted suffix for a key named like a stream label, where the build does not return it (detected_fields: open, semantics/detected-fields-extracted-suffix; service/service.name: owner decision); the proxy also returns data, status; Loki lists fields in no fixed order
  • vs Loki, explained: resource /api/datasources/uid/*/resources/detected_labels?end=2026-10-28T10%3A20%3A00.000Z&query=%7: body: content differs (rows 9 vs 9) -- explained: sampled cardinality: Loki counts every stream its ingesters hold, not the requested window (pkg/ingester/instance.go LabelsWithValues), the proxy the window's last 5 minutes (metadataMaxFieldNamesWindow); the label names match; the proxy also returns data, limit, status; Loki lists labels in no fixed order
  • vs Loki, explained: resource /api/datasources/uid/*/resources/drilldown-limits: body: content differs (rows 0 vs 0) -- explained: configuration probe: each backend publishes its own limits, version and settings (the proxy's pattern persistence)

dd-service-logs-1h

Core set montages (3): base | PR | Loki

dd-landing-1h

dd-landing-1h

explore-logs-plain-1h

explore-logs-plain-1h

explore-metric-plain-1h

explore-metric-plain-1h

All montages of this run: pr-visuals/pr-886.

@github-actions

Copy link
Copy Markdown
Contributor

PR Quality Report

Compared against base branch main.

Coverage and tests

Signal Base PR Delta
Test count 15614 15619 5
Coverage 88.6% 88.6% 0.0% (stable)

Compatibility

Track Base PR Delta
Loki API 100.0% 11/11 (100.0%) 0.0% (stable)
Logs Drilldown 100.0% 17/17 (100.0%) 0.0% (stable)
VictoriaLogs 100.0% 11/11 (100.0%) 0.0% (stable)

Performance smoke

Lower CPU cost (ns/op) is better. Lower benchmark memory cost (B/op, allocs/op) is better. Higher throughput is better. Lower load-test memory growth is better. Benchmark rows are medians from repeated samples.

Signal Base PR Delta
QueryRange cache-hit CPU cost 2103.0 ns/op 2101.0 ns/op -0.1% (stable)
QueryRange cache-hit memory 1240.0 B/op 1240.0 B/op 0.0% (stable)
QueryRange cache-hit allocations 27.0 allocs/op 27.0 allocs/op 0.0% (stable)
QueryRange cache-bypass CPU cost 2356.0 ns/op 2345.0 ns/op -0.5% (stable)
QueryRange cache-bypass memory 1282.0 B/op 1280.0 B/op -0.2% (stable)
QueryRange cache-bypass allocations 27.0 allocs/op 27.0 allocs/op 0.0% (stable)
Labels cache-hit CPU cost 593.3 ns/op 621.5 ns/op +4.8% (stable)
Labels cache-hit memory 48.0 B/op 48.0 B/op 0.0% (stable)
Labels cache-hit allocations 3.0 allocs/op 3.0 allocs/op 0.0% (stable)
Labels cache-bypass CPU cost 980.7 ns/op 957.6 ns/op -2.4% (stable)
Labels cache-bypass memory 396.0 B/op 396.0 B/op 0.0% (stable)
Labels cache-bypass allocations 14.0 allocs/op 14.0 allocs/op 0.0% (stable)

State

  • Coverage, compatibility, and sampled performance are reported here from the same PR workflow.
  • This is a delta report, not a release gate by itself. Required checks still decide merge safety.
  • Performance is a smoke comparison, not a full benchmark lab run.
  • Delta states use the same noise guards as the quality gate (percent + absolute + low-baseline checks), so report labels match merge-gate behavior.

@szibis
szibis merged commit 1312f5a into main Oct 28, 2026
48 checks passed
@szibis
szibis deleted the perf/unwrap-extent-cache branch October 28, 2026 10:31
szibis added a commit that referenced this pull request Oct 28, 2026
…ding sums in VictoriaLogs

On the #886 stack A/B the unwrap warm and refresh cells above 2x Loki made
no VictoriaLogs call: the extent cache answered them, and the proxy spent
110-170 ms (W1-W4, W7 at 6h; E2; U9 rate_counter) and 1.1-1.4 s (W8 at 6h)
evaluating the window again, where Loki answers from its results cache in
3-17 ms.

Settled steps (query_range_steps.go): the window evaluator's answers are
kept per evaluation time. A step's sample is a function of its window
(t-range, t] alone (pkg/logql/range_vector.go:102-189) and the folds and
topk work per step (pkg/logql/evaluator.go VectorAggEvaluator.Next), so a
request whose start is on the grid of an earlier one takes those steps
and evaluates its missing ranges through the handler, one after another,
and merges them by series labels, as Loki's results cache merges extents
(pkg/storage/chunk/cache/resultscache/cache.go Do, handleHit;
queryrangebase/query_range.go:310-350 matrixMerge). Steps are kept with
the stats extent cache's rules and storage: older than
-query-range-freshness, per tenant, query, step and grid phase, in the
response cache tiers (memory, disk, peers), expiring with the oldest
bucket they were evaluated from. The merged answer is written in the
evaluator's envelope, headers and series order; Loki's series limit and
the size limit apply to it; answers of different routes are never merged;
anything else evaluates the whole window. Inner evaluations are not
counted as requests. Two defects of the first version were found by the
fleet proof and are fixed: a missing range was evaluated with bare
headers (no nosniff where the stored steps had it), so the merge refused
the pieces and every refresh evaluated the whole window; and the headers
a writer below the handler adds when the answer goes out (Content-Encoding,
Vary and Content-Length of a gzip client) made the answer unkeepable, so a
client that accepts gzip, as Grafana does, left no steps.

VictoriaLogs folds `sum without (l...)` over count_over_time or
bytes_over_time of a selector and line filters on a sliding window bucket
by bucket (withSlidingWithoutCut, translator.WithoutStreamCut): the
excluded pairs are cut out of _stream and the buckets summed per remaining
stream and level, instead of a series per stream folded by the proxy.
rate and bytes_rate stay per stream: Loki sums each stream's rate, and the
sum of the counts divided once differs in the last bits. This supersedes
the unmerged perf/sliding-without branch; its tests, e2e, A/B set and
visual page are brought over.

The bucket matrix skips to the steps whose windows can hold a series'
next bucket (nextHitsStep); quantile_over_time takes each window from a
sliding Fenwick count of its buckets' values (same values bit for bit);
the per-series evaluator checks cancellation every 256 series; the extent
cache's partial store runs off the request path, or at once when no store
slot is free.

Measured (isolated stacks, Loki 3.7.9, VictoriaLogs v1.52.0, against
origin/main, like for like with Loki's defaults): 328 shape x range cells,
23 faster, 141 same, 0 slower, 0 new differences; 92% of 777 cells below
Loki; the committed worst rows at 24h warm E2 119x, G10 56x, U4 39.5x,
W3 54.7x are now 0.57x, 0.15x, 0.08x, 0.43x. Backend-safety load ABBA:
proxy CPU 28 s against 395-419 s at equal offered load, no VictoriaLogs or
Loki restart, /labels 200 throughout.

Hardening: edge-case tables and Loki-reference differentials over random
windows and steps (TestQueryRangeStepsLokiDifferential,
TestQueryRangeStepsMatchUncached, TestQueryStepsMergeMatchesWhole,
TestSlidingSumWithoutCut*, TestQuantileSliderMatchesLoki,
TestQueryRangeStepsBehindMiddleware), fuzz targets FuzzQueryStepsRoundTrip,
FuzzSlidingWithoutCut and FuzzQuantileSlider in CI's fuzz-smoke; e2e
TestCompat_QueryRangeSettledSteps and TestCompat_SlidingSumWithoutStreamFold;
the fleet job runs the settled steps layer of TestFleetPeerCacheProof (peer
read, disk loss, owner down, slow peer, all lost equal to Loki) and
TestFleet_QueryRangeSettledStepsReachPeers.

Registry: quality/query-range-settled-steps,
semantics/without-sliding-sum-folded-buckets (locked); effort exceptions on
six unwrap items for the rows that stay above Loki.
szibis added a commit that referenced this pull request Oct 28, 2026
…ding sums in VictoriaLogs

On the #886 stack A/B the unwrap warm and refresh cells above 2x Loki made
no VictoriaLogs call: the extent cache answered them, and the proxy spent
110-170 ms (W1-W4, W7 at 6h; E2; U9 rate_counter) and 1.1-1.4 s (W8 at 6h)
evaluating the window again, where Loki answers from its results cache in
3-17 ms.

Settled steps (query_range_steps.go): the window evaluator's answers are
kept per evaluation time. A step's sample is a function of its window
(t-range, t] alone (pkg/logql/range_vector.go:102-189) and the folds and
topk work per step (pkg/logql/evaluator.go VectorAggEvaluator.Next), so a
request whose start is on the grid of an earlier one takes those steps
and evaluates its missing ranges through the handler, one after another,
and merges them by series labels, as Loki's results cache merges extents
(pkg/storage/chunk/cache/resultscache/cache.go Do, handleHit;
queryrangebase/query_range.go:310-350 matrixMerge). Steps are kept with
the stats extent cache's rules and storage: older than
-query-range-freshness, per tenant, query, step and grid phase, in the
response cache tiers (memory, disk, peers), expiring with the oldest
bucket they were evaluated from. The merged answer is written in the
evaluator's envelope, headers and series order; Loki's series limit and
the size limit apply to it; answers of different routes are never merged;
anything else evaluates the whole window. Inner evaluations are not
counted as requests. Two defects of the first version were found by the
fleet proof and are fixed: a missing range was evaluated with bare
headers (no nosniff where the stored steps had it), so the merge refused
the pieces and every refresh evaluated the whole window; and the headers
a writer below the handler adds when the answer goes out (Content-Encoding,
Vary and Content-Length of a gzip client) made the answer unkeepable, so a
client that accepts gzip, as Grafana does, left no steps.

VictoriaLogs folds `sum without (l...)` over count_over_time or
bytes_over_time of a selector and line filters on a sliding window bucket
by bucket (withSlidingWithoutCut, translator.WithoutStreamCut): the
excluded pairs are cut out of _stream and the buckets summed per remaining
stream and level, instead of a series per stream folded by the proxy.
rate and bytes_rate stay per stream: Loki sums each stream's rate, and the
sum of the counts divided once differs in the last bits. This supersedes
the unmerged perf/sliding-without branch; its tests, e2e, A/B set and
visual page are brought over.

The bucket matrix skips to the steps whose windows can hold a series'
next bucket (nextHitsStep); quantile_over_time takes each window from a
sliding Fenwick count of its buckets' values (same values bit for bit);
the per-series evaluator checks cancellation every 256 series; the extent
cache's partial store runs off the request path, or at once when no store
slot is free.

Measured (isolated stacks, Loki 3.7.9, VictoriaLogs v1.52.0, against
origin/main, like for like with Loki's defaults): 328 shape x range cells,
23 faster, 141 same, 0 slower, 0 new differences; 92% of 777 cells below
Loki; the committed worst rows at 24h warm E2 119x, G10 56x, U4 39.5x,
W3 54.7x are now 0.57x, 0.15x, 0.08x, 0.43x. Backend-safety load ABBA:
proxy CPU 28 s against 395-419 s at equal offered load, no VictoriaLogs or
Loki restart, /labels 200 throughout.

Hardening: edge-case tables and Loki-reference differentials over random
windows and steps (TestQueryRangeStepsLokiDifferential,
TestQueryRangeStepsMatchUncached, TestQueryStepsMergeMatchesWhole,
TestSlidingSumWithoutCut*, TestQuantileSliderMatchesLoki,
TestQueryRangeStepsBehindMiddleware), fuzz targets FuzzQueryStepsRoundTrip,
FuzzSlidingWithoutCut and FuzzQuantileSlider in CI's fuzz-smoke; e2e
TestCompat_QueryRangeSettledSteps and TestCompat_SlidingSumWithoutStreamFold;
the fleet job runs the settled steps layer of TestFleetPeerCacheProof (peer
read, disk loss, owner down, slow peer, all lost equal to Loki) and
TestFleet_QueryRangeSettledStepsReachPeers.

Registry: quality/query-range-settled-steps,
semantics/without-sliding-sum-folded-buckets (locked); effort exceptions on
six unwrap items for the rows that stay above Loki.
szibis added a commit that referenced this pull request Oct 29, 2026
…ding sums in VictoriaLogs

On the #886 stack A/B the unwrap warm and refresh cells above 2x Loki made
no VictoriaLogs call: the extent cache answered them, and the proxy spent
110-170 ms (W1-W4, W7 at 6h; E2; U9 rate_counter) and 1.1-1.4 s (W8 at 6h)
evaluating the window again, where Loki answers from its results cache in
3-17 ms.

Settled steps (query_range_steps.go): the window evaluator's answers are
kept per evaluation time. A step's sample is a function of its window
(t-range, t] alone (pkg/logql/range_vector.go:102-189) and the folds and
topk work per step (pkg/logql/evaluator.go VectorAggEvaluator.Next), so a
request whose start is on the grid of an earlier one takes those steps
and evaluates its missing ranges through the handler, one after another,
and merges them by series labels, as Loki's results cache merges extents
(pkg/storage/chunk/cache/resultscache/cache.go Do, handleHit;
queryrangebase/query_range.go:310-350 matrixMerge). Steps are kept with
the stats extent cache's rules and storage: older than
-query-range-freshness, per tenant, query, step and grid phase, in the
response cache tiers (memory, disk, peers), expiring with the oldest
bucket they were evaluated from. The merged answer is written in the
evaluator's envelope, headers and series order; Loki's series limit and
the size limit apply to it; answers of different routes are never merged;
anything else evaluates the whole window. Inner evaluations are not
counted as requests. Two defects of the first version were found by the
fleet proof and are fixed: a missing range was evaluated with bare
headers (no nosniff where the stored steps had it), so the merge refused
the pieces and every refresh evaluated the whole window; and the headers
a writer below the handler adds when the answer goes out (Content-Encoding,
Vary and Content-Length of a gzip client) made the answer unkeepable, so a
client that accepts gzip, as Grafana does, left no steps.

VictoriaLogs folds `sum without (l...)` over count_over_time or
bytes_over_time of a selector and line filters on a sliding window bucket
by bucket (withSlidingWithoutCut, translator.WithoutStreamCut): the
excluded pairs are cut out of _stream and the buckets summed per remaining
stream and level, instead of a series per stream folded by the proxy.
rate and bytes_rate stay per stream: Loki sums each stream's rate, and the
sum of the counts divided once differs in the last bits. This supersedes
the unmerged perf/sliding-without branch; its tests, e2e, A/B set and
visual page are brought over.

The bucket matrix skips to the steps whose windows can hold a series'
next bucket (nextHitsStep); quantile_over_time takes each window from a
sliding Fenwick count of its buckets' values (same values bit for bit);
the per-series evaluator checks cancellation every 256 series; the extent
cache's partial store runs off the request path, or at once when no store
slot is free.

Measured (isolated stacks, Loki 3.7.9, VictoriaLogs v1.52.0, against
origin/main, like for like with Loki's defaults): 328 shape x range cells,
23 faster, 141 same, 0 slower, 0 new differences; 92% of 777 cells below
Loki; the committed worst rows at 24h warm E2 119x, G10 56x, U4 39.5x,
W3 54.7x are now 0.57x, 0.15x, 0.08x, 0.43x. Backend-safety load ABBA:
proxy CPU 28 s against 395-419 s at equal offered load, no VictoriaLogs or
Loki restart, /labels 200 throughout.

Hardening: edge-case tables and Loki-reference differentials over random
windows and steps (TestQueryRangeStepsLokiDifferential,
TestQueryRangeStepsMatchUncached, TestQueryStepsMergeMatchesWhole,
TestSlidingSumWithoutCut*, TestQuantileSliderMatchesLoki,
TestQueryRangeStepsBehindMiddleware), fuzz targets FuzzQueryStepsRoundTrip,
FuzzSlidingWithoutCut and FuzzQuantileSlider in CI's fuzz-smoke; e2e
TestCompat_QueryRangeSettledSteps and TestCompat_SlidingSumWithoutStreamFold;
the fleet job runs the settled steps layer of TestFleetPeerCacheProof (peer
read, disk loss, owner down, slow peer, all lost equal to Loki) and
TestFleet_QueryRangeSettledStepsReachPeers.

Registry: quality/query-range-settled-steps,
semantics/without-sliding-sum-folded-buckets (locked); effort exceptions on
six unwrap items for the rows that stay above Loki.
szibis added a commit that referenced this pull request Oct 29, 2026
… Loki's value (#915)

* perf: window-evaluated metrics reuse their settled steps and fold sliding sums in VictoriaLogs

On the #886 stack A/B the unwrap warm and refresh cells above 2x Loki made
no VictoriaLogs call: the extent cache answered them, and the proxy spent
110-170 ms (W1-W4, W7 at 6h; E2; U9 rate_counter) and 1.1-1.4 s (W8 at 6h)
evaluating the window again, where Loki answers from its results cache in
3-17 ms.

Settled steps (query_range_steps.go): the window evaluator's answers are
kept per evaluation time. A step's sample is a function of its window
(t-range, t] alone (pkg/logql/range_vector.go:102-189) and the folds and
topk work per step (pkg/logql/evaluator.go VectorAggEvaluator.Next), so a
request whose start is on the grid of an earlier one takes those steps
and evaluates its missing ranges through the handler, one after another,
and merges them by series labels, as Loki's results cache merges extents
(pkg/storage/chunk/cache/resultscache/cache.go Do, handleHit;
queryrangebase/query_range.go:310-350 matrixMerge). Steps are kept with
the stats extent cache's rules and storage: older than
-query-range-freshness, per tenant, query, step and grid phase, in the
response cache tiers (memory, disk, peers), expiring with the oldest
bucket they were evaluated from. The merged answer is written in the
evaluator's envelope, headers and series order; Loki's series limit and
the size limit apply to it; answers of different routes are never merged;
anything else evaluates the whole window. Inner evaluations are not
counted as requests. Two defects of the first version were found by the
fleet proof and are fixed: a missing range was evaluated with bare
headers (no nosniff where the stored steps had it), so the merge refused
the pieces and every refresh evaluated the whole window; and the headers
a writer below the handler adds when the answer goes out (Content-Encoding,
Vary and Content-Length of a gzip client) made the answer unkeepable, so a
client that accepts gzip, as Grafana does, left no steps.

VictoriaLogs folds `sum without (l...)` over count_over_time or
bytes_over_time of a selector and line filters on a sliding window bucket
by bucket (withSlidingWithoutCut, translator.WithoutStreamCut): the
excluded pairs are cut out of _stream and the buckets summed per remaining
stream and level, instead of a series per stream folded by the proxy.
rate and bytes_rate stay per stream: Loki sums each stream's rate, and the
sum of the counts divided once differs in the last bits. This supersedes
the unmerged perf/sliding-without branch; its tests, e2e, A/B set and
visual page are brought over.

The bucket matrix skips to the steps whose windows can hold a series'
next bucket (nextHitsStep); quantile_over_time takes each window from a
sliding Fenwick count of its buckets' values (same values bit for bit);
the per-series evaluator checks cancellation every 256 series; the extent
cache's partial store runs off the request path, or at once when no store
slot is free.

Measured (isolated stacks, Loki 3.7.9, VictoriaLogs v1.52.0, against
origin/main, like for like with Loki's defaults): 328 shape x range cells,
23 faster, 141 same, 0 slower, 0 new differences; 92% of 777 cells below
Loki; the committed worst rows at 24h warm E2 119x, G10 56x, U4 39.5x,
W3 54.7x are now 0.57x, 0.15x, 0.08x, 0.43x. Backend-safety load ABBA:
proxy CPU 28 s against 395-419 s at equal offered load, no VictoriaLogs or
Loki restart, /labels 200 throughout.

Hardening: edge-case tables and Loki-reference differentials over random
windows and steps (TestQueryRangeStepsLokiDifferential,
TestQueryRangeStepsMatchUncached, TestQueryStepsMergeMatchesWhole,
TestSlidingSumWithoutCut*, TestQuantileSliderMatchesLoki,
TestQueryRangeStepsBehindMiddleware), fuzz targets FuzzQueryStepsRoundTrip,
FuzzSlidingWithoutCut and FuzzQuantileSlider in CI's fuzz-smoke; e2e
TestCompat_QueryRangeSettledSteps and TestCompat_SlidingSumWithoutStreamFold;
the fleet job runs the settled steps layer of TestFleetPeerCacheProof (peer
read, disk loss, owner down, slow peer, all lost equal to Loki) and
TestFleet_QueryRangeSettledStepsReachPeers.

Registry: quality/query-range-settled-steps,
semantics/without-sliding-sum-folded-buckets (locked); effort exceptions on
six unwrap items for the rows that stay above Loki.

* fix: sum of rate_counter over one-sample parsed series answers Loki's zeros from stats

`sum(rate_counter({service_name="payment-service"} | logfmt | unwrap amount [5m]))`
answered 16,471/s at 1h where Loki answers 0, and 502 at 6h and 24h
("manual metric response exceeds 67108864 bytes"): the raw evaluator read
every line (`app:="..." | unpack_logfmt | limit 1000001`, over 64 MiB at 6h)
and rated all of them as one series.

Loki rates each series of the sample extractor, the stream plus every parsed
label less the unwrapped one (pkg/logql/log/metrics_extraction.go:160-166,
labels.go:664-688); a sum does not hand rate_counter its grouping
(pkg/logql/syntax/ast.go:1630-1640), and a series with fewer than two samples
in a window rates 0 (pkg/logql/range_vector.go:286-291). With a per-line key
(a transaction id) every window is 0.

The proxy now proves that case in VictoriaLogs before reading any row: the
lines carrying the unwrapped field are counted next to count_uniq_hash of
(_stream, the parsed keys of a 1,000-line field_names sample). The tuple is
never finer than Loki's series, so equal counts mean every series holds one
sample; a collision or a missing key only fails the proof. Then the unwrap
rate route's bucket presence answers, every value 0, with its conversion
error detection. Otherwise the per-row evaluator answers as before.

* perf: a heavy unwrap read reaches Loki's conversion error from its first window

E6 and E7 (Loki 400 on a unit string or a hex value) took 81 ms against
Loki's 19 at 6h cold and 106-116 ms against 45-58 at 24h: the answer waited
for the metric's counting stats read over the whole range before the lookup
that builds Loki's text. Loki stops at the first rejected value
(pkg/logql/evaluator.go:726-733).

When the metric's first counting stats query reads
-backend-heavy-query-min-range or more, the conversion check now looks up the
first evaluation window (start-range, start] beside it (at most three flagged
rows). A row Loki fails on there answers first, in confirm() too, so the
answer is the same whether the lookup or the stats read finishes first; the
metric's queries are cancelled once it is found. A clean first window leaves
the earlier rule (the earliest flagged bucket of the stats read) unchanged.

* perf: settled steps evaluate a moved window's light gaps at the same time

A moved window misses its old edge and its fresh steps; the two were
evaluated one after another, so G7 and D2 at 24h warm made two sequential
VictoriaLogs calls (32-37 ms against Loki's 9-15 ms). Gaps that each read
less than -backend-heavy-query-min-range are now evaluated concurrently; a
heavy gap keeps the sequential order, so a request never holds more than the
one heavy-query slot its whole window would.

* perf: quantile_over_time reads VictoriaLogs' value counts in place

E2 (`quantile_over_time(0.95, ... | json | unwrap duration_ms [1m]) by
(service_name)`) at 24h cold spent 260-270 ms in the proxy on top of
VictoriaLogs' 330-780 ms (603 ms against Loki's 299). The 9 MB count answer
was parsed into a DOM, the 430k samples sorted with sort.Slice and every
window's values ranked by a sort of all samples.

The counts are now read in place (jsonparser, eachBucketPoint, a pooled parser
for each series' labels, as collectRangeMetricHits reads buckets), placed in
time order by a counting sort on the bucket grid, and ranked by the sorted
distinct values. Fake-VictoriaLogs benchmark of the 24h shape: 204 ms to
83-106 ms per request.

* fix: the rate_counter one-sample proof parses the line itself

The first version built the distinct tuple from field_names of the base
query, which mixes stored VictoriaLogs fields with the base's own unpack: a
stored per-line field Loki's `| logfmt` never extracts (a line ingested with
its keys as fields) made the tuple finer than Loki's series and could give
the stats-only 0 for one series holding many samples.

The tuple is now (_stream, the line's logfmt keys parsed again under a prefix
of their own), less the unwrapped label and every key whose Loki label name
(sanitizeLabelKey, pkg/logql/log/util.go:22-39) another key shares. The
proof covers one parameterless `| logfmt` only: `| json` (arrays rendered as
text by VictoriaLogs, skipped by Loki), line_format and other parsers keep
the per-row evaluator. Edge tests: a unique stored field, keys Loki names
alike, a digit-leading key and its twin, a shared stored field, no line.

* test: lock the rate_counter one-sample answer and the first-window 400 against Loki

- registry: semantics/unwrap-rate-counter-one-sample-series (fixed, perf block),
  with the U9 shape covering it and a visual page reproducing it;
- e2e (group core): TestCompat_UnwrapRateCounterOneSampleSeries (own logfmt
  fixture with a transaction id per line: sum and sum by, sliding, tumbling,
  shorter than the step, against Loki's zeros) and
  TestCompat_UnwrapConversionErrorFirstWindowHeavyRead (a 7h read whose only
  rejected value is in the first window: Loki's 400 naming that line);
- fuzz-smoke: FuzzSortQuantileSamples and FuzzRateCounterOneSample;
- generated registry outputs and the README quality section regenerated.

* perf: read every point of an instant value-count answer in place

An instant quantile over value counts reads a stats_query answer: a vector's
value, and a matrix's values when the backend writes one, each at the
evaluation time, as the DOM read did (TestSeriesLimit_EveryMetricRouteFollowsLoki).
Adds BenchmarkQuantileCounts24h and BenchmarkRateCounterUnderSum6h.

* refactor: move the rate_counter proof hooks out of the window evaluator

golangci-lint gocyclo: proxyManualRangeMetricRange went over 30 with the
proof's branches; startRateCounterProof, rateCounterBucketRoute and
rateCounterRawSpec hold them. No behaviour change.

* perf: reuse a query's rate_counter proof keys for its moved windows

A moved window proved its missing steps with two sequential VictoriaLogs
reads (the key sample, then the count); the keys of the query are now kept
for 10 minutes (1,024 entries, per proxy, backend and tenant), so a refresh
proves with one read beside the presence buckets. Keys an entry lacks only
merge series. On a 22h window moved by a step: 58 ms to 8 ms.

* docs: changelog and registry for the rate_counter, first-window and quantile changes

CHANGELOG entries under Unreleased (Fixed: the rate_counter one-sample answer;
Performance: the first-window lookup, concurrent settled-step gaps, the
in-place quantile read). Registry: the U9 effort exception is removed (the row
answers Loki's value below Loki), the conversion-error exception keeps only
E2 cold (1-2x, VictoriaLogs' value-count read), and the gate,
duration-seconds and sample-validity exceptions cover their 1-2x rows only,
with the measured reason (a VictoriaLogs stream-filter search costs about
15 ms however small its range). Regenerated performance report.

* test: the early first-window answer may cancel a stats call before the backend sees it

The early-route assertion required the emulator to observe the cancellation;
under -race the 400 can come before the stats call reaches the emulator. The
test now requires that no stats call answered.

* test: drop an unused slice in the rate_counter key reuse test (staticcheck SA4010)

* perf: look the first window up beside a heavy read only for a query that erred lately

The first-window lookup started beside every heavy counting read, so a clean
query paid an extra VictoriaLogs search (on the A/B stack E1 6h cold went from
one call in 132 ms to two in 587 ms). The lookup now follows a heavy read's
hits (before them, so the reported line is the same either way), and starts
beside the read only when the same tenant's query answered Loki's conversion
error within 10 minutes (1,024 entries). A query that never errs pays nothing;
TestUnwrapOverTimeShortRangeStaysOnStepBuckets is back to its original
assertion.

* docs: final-head measurements in the changelog and registry

The first-window lookup is gated on a recent error, so the E6/E7 entries cite
the final-head A/B (first heavy read: 1.2x and 1.6x Loki at 6h cold, was 2.0x
and 3.1x) next to the early answer of a repeated read; the quantile entry
cites benchstat; the U9 entry gives Loki's time next to the moved window and
the key-sample collision it does not cover; the sample-validity exception no
longer claims U14 6h (2.2x cold, 2.0x warm, open).

* docs: the CHANGELOG entries under Unreleased and the regenerated reports after the rebase

* refactor: the sliding stats route writes through one helper after the ranked route

* fix: bound settled-step allocation and secure JSON responses

* docs(changelog): keep PR entries under Unreleased after rebase

* fix: bound peer ring allocation and seed replay scope

* Keep query-range security headers and cache parity

* Preserve Loki plain-text errors through steps cache tee

* Allow race-covered proxy suite to finish in CI

* Keep settled-steps race proof within CI budget

* Regenerate conformance wiring after main rebase

* Restore released changelog after main rebase

* Skip bounded visual publishing for full baseline captures

* Distinguish delayed visual requests from value flips

* Replay interrupted test recorder queries once

* Refresh conformance gaps after main rebase

* Refresh quality summary after main rebase

* Refresh conformance evidence after main rebase

* Allow proxy race shard to complete on hosted runners

---------

Co-authored-by: szibis <szibis@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Performance scope/docs Documentation scope/proxy Proxy core scope/tests Tests size/L Large change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant