Repository navigation
fix: answer metric queries over the series limit like Loki instead of cutting them silently - #581
Merged
Merged
Conversation
Contributor
PR Quality ReportCompared against base branch Coverage and tests
Compatibility
Performance smokeLower CPU cost (
State
|
szibis
force-pushed
the
fix/series-cap-and-window-alignment
branch
from
September 22, 2026 10:15
928c4b6 to
433e9a4
Compare
szibis
force-pushed
the
fix/series-cap-and-window-alignment
branch
2 times, most recently
from
September 22, 2026 11:51
e5a7d93 to
22b605f
Compare
…hem on VictoriaLogs buckets A grouped range metric whose grouping had more values than -max-stats-query-series was answered with a silent cut: the busiest 200 values on the short-range path and on the two-phase path used from two hours on, with no signal that the rest existed. Loki fails such a query with `maximum number of series (N) reached for a single query` (HTTP 400) and only gives Grafana Logs Drilldown a partial result, so the proxy now does the same, on range and instant queries alike. The message stays Loki's word for word — Grafana renders it — and the log line for the rejected request names the flag that set the limit. The limit is enforced while the VictoriaLogs response is read, so an oversized answer is not buffered in full, and it never turns into a raw log scan. The Drilldown paths lose their hardcoded 500: they read the same flag, ask VictoriaLogs for one series over it so a truncated answer is distinguishable from an exact fit, and report the cut as Loki's partial-result warning instead of dropping series silently. Where Loki keeps the first series it encounters, the proxy keeps the busiest by total count, which is what a Drilldown panel renders. Also in this change: - `offset` samples are stamped at Loki's evaluation time instead of the shifted time, on range and instant queries, and are no longer cached with the shifted timestamps. Only sample responses are rewritten, so a log query still streams. - An unknown backend version (failed or pending probe) keeps anchored stats buckets instead of falling back to raw rows: the offset arg is ignored by a backend that predates it, which leaves the epoch-aligned buckets it would have had anyway, while raw rows scale with the stored data. A version known to be older than v1.45 still uses epoch buckets only where they match the query's windows. - Logs Drilldown panels with a range shorter than the step use the window evaluator, so they no longer count the whole step bucket. - The window-sampled /hits rewrite is limited to Drilldown: a Grafana dashboard or Explore panel grouping by a high-cardinality field gets the exact series Loki returns. - The top-value `in()` filter is budgeted against VictoriaLogs' default 16 KiB query length and never ends up empty. - Single-field groupings over two hours read the range once instead of ranking and then scanning it again; values are ranked only once the result exceeds the series limit. - The ordered JSON metric path failed one series early: Loki rejects only above the limit. The e2e compat suite gains a semantics case that compares offset stamping and both series-limit behaviours against live Loki. The compose proxies carry the series limit their Loki is configured with so parity tests compare answers rather than limits, with one variant left at the default so the limit itself stays under test. The conformance registry gains the cases this proves, and the docs and chart values describe the Loki error instead of the removed 502/503 messages.
CodeQL flagged the capacity passed to make() in addSeriesLimitWarning: len(body)+len(warning)+14 has no overflow guard. The body is bounded by the response caps long before that matters, but bytes.Join sizes the result once and checks the sum itself, so the response is still copied a single time and no unchecked addition is left here. Add a table test for the injected warning; it passes against both implementations.
szibis
force-pushed
the
fix/series-cap-and-window-alignment
branch
from
September 23, 2026 09:07
22b605f to
9f636ca
Compare
szibis
added a commit
that referenced
this pull request
Sep 23, 2026
#581 merged after v1.88.0 was cut, from a head that predated the cut, and the merge placed its seven entries inside the released 1.88.0 section, which only ever contained #594 and #603. Restore 1.88.0 to what the release published and list the entries under Unreleased, where the next release will pick them up. Co-authored-by: szibis <szibis@users.noreply.github.com>
szibis
added a commit
that referenced
this pull request
Sep 23, 2026
…omputed from (#606) prepare-release re-reads main before computing the version, so a queued run releases what main holds when its turn comes. validate-release and publish-release still checked out the triggering commit, so a queued or re-run release computed its version from current main while validating, tagging and building an older commit. Re-running the release for #581 failed its Unreleased check on the original commit for exactly this reason. Record the commit prepare-release read and use it for every later checkout, the tag, and the revision stamped into binaries and images. Co-authored-by: szibis <szibis@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-up to #562, from a post-merge review of the paths it touched. The headline problem: a grouped range metric whose grouping had more values than
-max-stats-query-serieswas answered with a silent cut — the busiest 200 values on the short-range path and on the two-phase path used from two hours on — so a chart or an alert read a subset as if it were the whole answer, and the number 200 had nothing to do with the configured limit.Loki's behaviour is the reference (
pkg/logql/engine.go,logqlmodel.NewSeriesLimitError): a query overmax_query_seriesfails withas HTTP 400, and only a Logs Drilldown request (
X-Query-Tags: Source=grafana-lokiexplore-app) gets a partial result with the warning... reached for a single query; returning partial resultsin the body.The proxy now does the same, on range and instant queries. The message is Loki's word for word, since Grafana renders it; the flag that set the limit is named in the proxy's log line for the rejected request and in the docs. The limit is applied while the VictoriaLogs response is being read, so an oversized answer is no longer buffered in full first, and it is never converted into a raw log scan.
The Drilldown paths lose their hardcoded
500: they read the same flag, ask VictoriaLogs for one series over it so a truncated answer is distinguishable from an exact fit, and report the cut as the partial-result warning instead of dropping series quietly. Where Loki keeps the first series it encounters, the proxy keeps the busiest by total count — the ones a Drilldown panel renders — which is stated in the registry case and in KNOWN_ISSUES.Also in this change
stats_query_rangeoffsetarg is unavailable, so any range whose start was not aligned to the window fell back to reading raw log rows — data read then scaled with the logs storedcount_over_time({app="x"}[5m] offset 5m)reported its sample atT-5minstead of Loki'sT, shifting the series left by the offset, and cached it that way[1m]at step 300) counted the whole step bucket: 30 where Loki answers 6sum by (trace_id) (…)) was rewritten to the window-sampled/hitspath, which returns a bounded sample rather than Loki's seriesin()filter was cut at a fixed count: long values could still exceed VictoriaLogs' default 16 KiB query length and be rejected, and filtering could leave an emptyin()that matches nothingsum by (namespace) (…)scanned the range twicelen(series) >= limitStart/end step alignment, the sixth review item, is not here: #574 covers it behind
-align-queries-with-step, matching Loki's own knob.Review round 2
Every item from the review is addressed:
topk();writeErroraddslimit_flag=-max-stats-query-seriesto the log line instead, and the three tests that pinned the suffix now assert its absence.handleQueryattaches the scope andproxyStatsQueryapplies the limit; a unit subtest covers both branches (plain 400, Drilldown 200 + warning + capped vector).capSeriesToLimit, and the VictoriaLogs-side| limitislimit+1so an exact-fit answer is distinguishable from a truncated one. The batched path cuts per request, so each caller's response carries its own warning.maxDrilldownSeriesis gone; every Drilldown path reads-max-stats-query-series, so raising it to 2000 yields 2000 and a warning that says 2000.loki-vl-proxy-patterns-autodetectkeeps the default 500 and the e2e limit subtests point at it (PROXY_SERIES_LIMIT_URL). The Drilldown partial subtest runs rather than skips.charts/.../values.yamldescribe the 400 and the Drilldown warning; the version-gate paragraphs describe the new unknown-version behaviour.Minors: the rewriter is now scoped to metric expressions (log queries stream);
shiftLokiResultTimestampswritesstatus, warnings, datain Loki's order and keeps unknown keys underdata;addSeriesLimitWarningparses for a top-levelwarningskey instead of scanning bytes; the busiest-vs-first difference is documented.seriesCountingReaderstill counts VictoriaLogs result entries (two per series when byte presence rides along, which it accounts for) — it is an early abort ahead of the exact proxy-side count, so a query within the limit is never rejected by it.Related finding (byte cap): with the series limit raised,
sum by (pod) (rate({service_name="api-gateway"}[5m]))over 24h and 7d answers200on this stack (1.7 MB at 7d), so the 64 MB manual cap does not bite here; making that cap an operator flag belongs to #585.Conformance registry
loki_api_v1_queryloki_api_v1_query_rangeexplore-logs-volume-jsonbehaviours/series-limits-and-partial-resultsandbehaviours/window-bounds-and-step-alignmentmove fromgaptopartial, with three new case files —limits/series-limit-error,limits/drilldown-partial-with-warning,semantics/offset-sample-timestamps— declared by the unit and e2e tests through// conformance:markers.explore-logs-volume-jsonis touched only because this change edits files it lists; it is not part of this PR's contract.python3 scripts/ci/check_conformance.pypasses.Verification
go test -race ./internal/...,go vet ./...(+-tags=e2e),gofmt, golangci-lint (0 issues), changelog gate, conformance gate.TestRangeMetricCompatibility(TumblingAndShortWindows|Sliding*|GroupedTumblingLongRange|BareParserWindows|EmptyWindowAggregations|OffsetAndSeriesLimit)all pass, including the Drilldown partial subtest (500 series, each equal to Loki's, plus the warning) and offset stamping on range and instant queries.json metric groupedis 400 on both), and at 7d the proxy answers in 0.3–1.2s where Loki takes 16–107s./labels200 throughout. No load test.Rebased onto
mainafter #590, #591, #592, #593 and #595. The CHANGELOG was resolved by hand: the released sections are byte-identical tomainand only the[Unreleased]/### Fixedsection carries this PR's entries (a plain rebase had dropped them into the 1.83.0 section). Two tests needed follow-up for main's newer topk route, which buckets inside the query (stats by (_time:… offset …)) instead of using the requestoffset, and for the service_name derivation landed by #579; both are updated and the whole unit suite, both vet builds, gofmt, golangci-lint, the changelog gate andscripts/ci/check_conformance.pypass on the rebased commit. The e2e semantics cases and the range matrix were re-run live against this commit with the same two-proxy arrangement.behaviours/series-limits-and-partial-resultsnow carries both #590's cases and this PR's in one state file, andpython3 scripts/ci/check_conformance.pypasses with every declared id backed by a case file.One matrix pass hit a
502oncount by (level)at 7d while the shared VictoriaLogs container was restarted from another session mid-run (exit code 0, not OOM); the query returns200in 0.3–0.4s on retry and the following full pass was clean, withRestartCountunchanged (18 → 18) and the container up throughout.