Repository navigation
Conversation
🟢 Performance A/B: faster, no regression91 shape×range — 6 🟢 faster · 85 ⚪ same · results vs Loki: 76 same, 0 new difference(s), 15 pre-existing
¹ re-measured with 7 runs, on windows the first pass did not use, after it moved beyond noise. 85 unchanged shape×range (within noise)
Cold (first-run) timings — what a user opening a panel sees
Why these shapes
base |
PR Quality ReportCompared against base branch Coverage and tests
Compatibility
Performance smokeLower CPU cost (
State
|
✅ Visual smoke: passed with warningsBase Fix proofCaptures 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
Regression check
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 it closer to Loki at a shorter range; otherwise label the pull request explore-json-error-filter-metric-6h: base failed where Loki and the PR answered; values beyond the Loki window not compared (1): query ('sum by (app) (count_
Core set montages (5): base | PR | Lokidd-fields-1h dd-landing-1h dd-service-logs-1h explore-logs-plain-1h explore-metric-plain-1h All montages of this run: |
98d6c25 to
b6f4edc
Compare
…, reading only the doubtful lines raw
sum by (app) (count_over_time({namespace="prod"} | json | __error__="" [5m]))
and the same shape with rate, bytes, a label filter or a grouping by a
parsed key, a stream label or service_name read every selected line as a
raw row: 0.5-2.4 s at 1 h (Loki 0.01-0.05 s) and a 502 on the row or byte
limit at 6-24 h. VictoriaLogs attaches no parser error, and Loki's
JSONParserErr depends on the query (JSONParser.Process walks a nested
object only when a required label starts with its key path).
The stats pushdown now splits the lines. A line not opening with a brace
after JSON whitespace fails Loki's parser and is dropped. A line
VictoriaLogs parses as one JSON object (a sentinel member spliced after
the brace and read back with unpack_json) and that holds none of the
constructs fastjson accepts and jsonparser rejects (a backslash in a key,
a value starting with . e E +, NaN or Inf) is accepted by Loki under every
hint set and counted by VictoriaLogs. Every other brace-opened line is
counted per bucket in the same scan and read as a raw row only from those
buckets, then evaluated by the row evaluator. A cached limit-1 probe looks
for the rejected constructs; only when it finds one is the scan repeated
with such lines moved to the raw rows. The parse-risk probes check only
lines VictoriaLogs parses. Window sums add bucket counts and raw lines at
each evaluation point before rate divides once. service_name is taken from
the first source field that is a stream label of the row, as Loki assigns
it at ingest, never from structured metadata.
The row evaluator's raw read keeps only _time, _stream, _msg, level and
the stored fields that can expose a required label, and its guards now
apply only to the raw-row set. The one-row pipeline-error lookup uses
Loki's JSON whitespace instead of RE2 \s, which includes the form feed.
Registry: semantics/json-error-filter-metric-split-pushdown (fixed);
open semantics/json-empty-value-label-series.
Follow-up changes folded into this commit:
- the split counts only the series the answer holds: label filters run before
the stats pipe, so groups made of filtered-out or unparseable lines no longer
count against -max-stats-query-series, and the limit applies to the merged
answer
- the lines read as raw rows lose the grouped fields in the stats query, so
they form one group; a stats response too large to read is left to the row
evaluator
- the series limit is left to the merged answer: the stats read keeps at most
twice the limit in groups as a memory guard, and a read past it goes to the
row evaluator
- a split plan over the series limit ends with Loki's limit error, not a size
error, and a merged answer over the limit fails before the raw rows are read
- e2e: the split answers under a low series limit as Loki does
- refreshed A/B results for J1-J5 at 1 h against Loki, 6 h and 24 h, and the
control set
A split plan with no grouped or filtered label (an ungrouped sum over `| json | __error__=""`) reached the unpack pipe with an empty field list and sent `unpack_json fields () keep_original_fields`. The unpack is written only when there is a label to read; the sentinel splice is the plan's parse.
…level fields The row projection kept _time, _stream, _msg, level and the fields that can expose a label of the plan. The detected_level derivation reads the stored level fields and the OTLP severity_text, severity_number and severity, which no label names, so a row whose level sits there lost it and fell to the keyword scan or unknown. A plan that derives the level reads whole rows.
… level fields or not at all A `| json | __error__=""` plan grouped by detected_level asks VictoriaLogs for the stored detected_level and level fields alone (copied before the sentinel splice), validated by storedLevelSeries, and never builds the derivation chain, its presence probe or its witness. It is served only when every counted group has a stored level and no other stored level field (severity, lvl, the OTLP severity fields) can outrank it. A level only the body holds, a counted line without one, a stored detected_level label, another stored level field, a read past the group guard and a response too large to read leave the query to the row evaluator, which derives the level of every line itself, as it does for the lines read as raw rows. A filter on detected_level is not split.
af954ea to
a5ba251
Compare








Summary
| json | __error__=""metrics are counted by one VictoriaLogs stats scaninstead of reading every selected line as a raw row. Only the brace-opened
lines VictoriaLogs cannot vouch for are read raw, and only from the buckets
that hold them. Before this change these shapes took 0.5-2.4 s at 1 h (Loki
0.01-0.05 s) and failed with a 502 on the row or byte limit at 6-24 h.
Why VictoriaLogs cannot do it alone
Loki counts the lines its JSON parser accepts (
pkg/logql/log/parser.go:74-135JSONParser.Process). The parser walks a nested object only when a requiredlabel starts with its key path (
parser_hints.goShouldExtractPrefix), sowhether a line fails with
JSONParserErrdepends on the query. VictoriaLogsattaches no parser error, and its parser (fastjson) accepts constructs that
Loki's (grafana/jsonparser) rejects, and the other way round. No single LogsQL
filter matches Loki's error.
What VictoriaLogs does and what the proxy adds
The pushdown splits the selected lines into three sets:
{after space, tab, line feedor carriage return fails ObjectEach (jsonparser
nextToken), so Loki dropsit.
object and that hold none of the rejected constructs (
jsonLokiRejectHazard)."__vlp":1,after the opening brace andreading the key back with
unpack_json. The splice cannot repair a brokenline; wrapping the line in a new object could be closed by trailing text.
with
. e E +, and NaN or Inf.count is exact.
the same scan (
count() if (-__vlp:*)). It is read raw only from thosebuckets, with a
| fieldsprojection, and evaluated by the row evaluator,whose parser follows Loki's hints.
How the parts fit together:
| limit 1probe looks for the rejectedconstructs. Only when it finds one does the scan run again with such lines
moved to the raw rows.
VictoriaLogs parses, because every other line is evaluated raw.
summed (a raw line at
tis a sample att-1ns, the same window rule as thebucket labels).
metricWindowValuethen runs once.them says how many series the answer holds. It emits a group, with zero
counts, for every row that reaches
stats; it groups the spellings of onelevel apart (
info,INFO,infandinformationare four groups and onedetected_level series); it groups an empty service name apart from
unknown_service; and with a step wider than the range it groups lines thatfall between two evaluation windows, which have no point in the answer. The
split therefore never ends with the series limit error from a group count.
(
| filter (<raw> or (<filters>))), and the lines read as raw rows losethe grouped fields. They are marked once
(
| format if (<doubtful line>) "1" as __vlr) and blanked on the marker(
| format if (__vlr:*) "" as <field>), so they are counted per bucketwhatever their labels (the row evaluator derives those itself) and form one
group. The marker never becomes a grouped field or a label.
-max-stats-query-series, withLoki's error: after the groups are keyed by their Loki labels
(
lokiStatsSeries), the series without a point in any evaluation windoware dropped (
dropSeriesWithoutPoints), and again once the raw rows areadded. The first check runs before any raw row is read.
(
splitGroupSlack) as a memory guard: a 300,000 group response is notparsed whole (+2.2 GiB heap before; bounded now). A read past it
(
errStatsGroupGuard), or a response too large to read(
maxStatsBucketResponseBytes), only means the split does not answer. Therow evaluator does, exactly as on main: its own row and byte limits
(
-manual-range-metric-row-limit,-ordered-json-metric-max-bytes) apply,and Loki's series limit error where its own series exceed the limit. Only a
split plan falls back on a response too large to read; every other
pushdown, and the logs volume route, keep main's behaviour byte for byte.
over-limit answer) and groupings by a field starting with
_(_msg,_time,_stream: not fields to blank) do not take the split; they behaveas on main.
takes the first non-empty source field that is a stream label of the row (a
stream filter
{app!=""}informat if, asderiveServiceNamereads it),never structured metadata, as the row evaluator does (
ensureSyntheticServiceNameover_stream).TestJSONValidSplitServiceNameFromMetadatapins it.its fallback paths) keeps
_time, _stream, _msg, levelplus every storedfield that can expose a required label: its own name, its
ToVLspelling, acustom mapping, or a field it sanitizes from (
<leading letters>*). Planswithout parser hints keep every field.
\s, whichincludes the form feed. It now uses Loki's JSON whitespace.
Behaviour change: the row evaluator's guards (nesting depth 64, 1024 fields,
1 MiB line, 64 KiB value) now apply only to the raw-row set. A line VictoriaLogs
vouches for is counted as Loki counts it, however wide and up to 300 levels deep
(VictoriaLogs' JSON parser limit); a deeper line is read raw and keeps the
row evaluator's depth guard.
Limits: the raw rows share
-manual-range-metric-row-limitand-ordered-json-metric-max-bytes, with their existing errors. The raw reads areat most 16 time ranges, then their hull.
VictoriaLogs support (read in the v1.40.0, v1.47.0 and v1.52.0 sources): every
pipe and filter used (
replace_regexp ... at,copy,unpack_json from ... fields (...),stats ... if (...),format if (...), stream filters{f!=""},fields prefix*) exists in all three,format if (...) "" as <field>included (pipe_format_test.go:16,28parse it in v1.40.0, v1.47.0 and v1.52.0;pipe_format.gowriteBlockat :121, :125, :125 writes the empty string on matching rows and keeps the original value on the others); the service_name derivation is a chain offormat ifpipes, no capability-gated pipe is used.unpack_jsondiffers: v1.40and v1.47 require
{as the first byte of the value, v1.52 trims JSON whitespace(
trimJSONWhitespace) before that check. The split holds on both: the sentinelsplice is anchored at the line start, so a line with leading whitespace never
gets the sentinel on either version and is read raw, and the hazard probe uses
the same brace-start filter as the split. fastjson is v1.6.4 in v1.40.0 and
v1.6.10 in v1.47.0 and v1.52.0, MaxDepth 300 in all.
detected_level
Loki sets detected_level at ingest, normalized (
pkg/distributor/field_detection.goextractLogLevel,normalizeLogLevel). How asum by (detected_level)over aJSON pipeline derives it, for the row evaluator and for the stats pushdown, is
#760's. This change adds the split on top of it and keeps its answer Loki's:
detected_levelasks VictoriaLogs for the storedlevel fields alone (
copy detected_level as __dl_d, level as __dl_l, takenbefore the sentinel splice, the marker or any unpack), validated by
storedLevelSeries. It never builds the derivation chain, the presence probeor the witness.
detected_level; the spellings of one level are separate groups and oneseries, merged after the read, so the series limit counts series.
never by the copies (the marker blanks them for those lines).
line itself: a level only the body holds, a counted line without one, a stored
detected_levellabel, another stored level field (severity,lvl, OTLPseverity: the copy of
levelcannot tell a stream label from metadata, whichLoki ranks, found by a
| limit 1probe), a read past the group guard, aresponse too large to read. A filter on
detected_levelis not split. Speed never outranks Loki'sanswer.
(the projection to the plan's labels would hide the stored
severity,severity_textandseverity_numberthe derivation reads).TestJSONValidSplitDetectedLevelWindowsMatchTheRowEvaluator(mixedstored and body-only windows, a stored
detected_levellabel, unnormalisedspellings, OTLP severity only, raw lines with and without a stored level: the
split's answer equals the row evaluator's and the path taken is asserted),
TestJSONValidSplitMatchesRawEvaluator(seeded, both fixtures),TestJSONValidSplitLevelSpellingsMergeUnderTheLimit,TestOrderedJSONRowProjectionKeepsTheStoredLevelFields, and the e2eTestRangeMetricCompatibilityJSONErrorFilterSplitDetectedLevel(CI groupsemantics, against real Loki: body levels, stream levels in mixed spellings,
a counted line without a stored level, lines nested deeper than VictoriaLogs
reads).
Tests
TestJSONValidSplitPredicateDifferentialandFuzzJSONValidSplitPredicate(wired into the fuzz smoke job). They check the LogsQL the pushdown sends,
evaluated with VictoriaLogs semantics (fastjson), against a port of Loki's
acceptance under four hint sets:
The corpus is 200k seeded lines, plus 90 s of fuzzing. With the hazard
exclusion removed, the same corpus finds about 10k violations.
TestJSONLokiRejectHazardTable: a unit table of the rejected constructs.TestJSONValidSplitEligibility: which shapes take the split.TestJSONValidSplitMatchesRawEvaluator: a differential of the hybrid againstthe proxy's row evaluator.
label, by service_name, with a label filter, and by detected_level.
backend with fastjson semantics.
Series limit and probes:
TestJSONValidSplitFilteredOutGroupsStayUnderSeriesLimitand...FilteredOutGroupsUnderSizeCap(a filter keeping 2 of 20 paths underlimit 5, count and bytes, with the response cap at 1200, 2000 and 4000
bytes; both fail with the pre-filter line removed).
TestJSONValidSplitRawOnlyGroupIsNotASeries(exactly the limit's paths plusan unparseable line answer; one more is Loki's error).
TestJSONValidSplitRawCandidatesFormOneGroup(50 hazard lines over distinctservice_name, level and parsed key: one raw group in the stats response, a
limit of 10 answers the three real series) and
...RawCandidatesOverManyStreamsFormOneGroup(20 truncated-JSON lines over20 apps in the first scan, no rescan, limit 3 answers). Both fail with the
blanking removed; the second fails with it applied to the hazard scan only.
TestJSONValidSplitReadStopsAtTwiceTheSeriesLimit: 300,000 groups, limit500: a plain pushdown stops with Loki's error, a split read with
errStatsGroupGuard; both allocate under 8 MiB for a 47.5 MiB body.TestJSONValidSplitHugeResponseFallsBackWithBoundedMemoryruns it throughthe handler: the row evaluator answers (empty here), 200, under 24 MiB.
Both fail with the guard removed.
TestJSONValidSplitMergedAnswerOverTheLimitReturnsLokiError: 8 groups,limit 5 (within the guard), count, bytes_over_time and bytes_rate, with
and without a raw candidate: Loki's 400 from the merged answer, no raw
read. Fails with the early cap removed (raw candidate) and with the final
cap removed (none).
TestJSONValidSplitLimitPlusOneSeriesWithoutRawRows: 6 series, limit 5,no raw line: the 400 (fails with the final cap removed); limit 6 answers.
TestJSONValidSplitSeriesLimitFailsBeforeRawRows: five real paths, a validline without the key and an unparseable line pass the read and exceed the
limit once merged; the query ends with the 400 and issues no raw read
(fails with the early cap removed; limit 6 answers as the row evaluator).
TestJSONValidSplitOverTwiceTheLimitGroupsFallBackToRowEvaluator: 60groups, limit 5, with the guard first, the size cap before it, a response
too large to read and one smaller than a group: always the row evaluator's
answer (the 400 here, 200 at limit 60 with all 60 series). Fails with the
old stop (limit plus one group) restored.
TestJSONValidSplitLevelSpellingsMergeUnderTheLimit: 8 level spellings(info, INFO, Info, inf, information, warn, WARN, warning), as a stream label
(served by the split) or in the body (the row evaluator answers), with and
without a raw candidate. Limits 5 and 10: Loki's two series, no raw read
without a candidate when the level is stored; limit 3: past the guard, the
row evaluator answers the same two series. The expected answer is counted
independently of both evaluators.
TestJSONValidSplitGapGroupsAreNoSeries: step 5m, range 1m, limit 5: 3series with a point, 4 groups only in the gaps and a raw candidate in a
gap; the row evaluator's 3 series answer (before, a 400). Fails with
dropSeriesWithoutPointsremoved.TestJSONValidSplitOversizedResponseOnlyFallsBackForSplitPlans: the sameoversized response is a fallback for a split plan and
errBodyTooLarge,as on main, for a plain pushdown plan (fails with the gate on
validOnlyremoved).
TestJSONValidSplitDrilldownRequestsKeepTheRowEvaluator: a Drilldownrequest gets the busiest 5 of 8 series with no stats query sent (fails with
the exclusion removed).
TestJSONValidSplitEligibilitygainedsum by (_msg),(_stream),(app, _time)and a_msgfilter: no pushdown (fails with the exclusionremoved).
TestJSONValidSplitMarksRawCandidatesOnce: the doubtful-line conditionappears once in each stats query (count and bytes, with a hazard rescan),
the marker is no grouped field and no label (fails with the per-field
condition restored).
TestJSONValidSplitResponseTooLargeFallsBackToRawEvaluator: a response overthe cap within the limit answers as the row evaluator.
TestJSONValidSplitHazardProbeUsesTheSplitBraceFilter,TestJSONValidSplitHazardProbeCancelsTheFirstScan(it failed 1 run in 20on the previous head too: the first scan could be cancelled before its
request left the proxy; that is a valid cancel now, 60 runs pass),
TestJSONValidSplitServiceNameFromStreamLabelWithNewline.ifconditionfails, as VictoriaLogs writes every column for every group, models v1.52's
JSON whitespace trim, and evaluates
format if (...) "" as <field>and the"1"marker.TestJSONValidSplitReadsOnlyRawRowBuckets,TestJSONValidSplitCountsDeepLinesLikeLoki,TestJSONValidSplitServiceNameFromMetadata,TestOrderedJSONRowProjection,TestOrderedJSONRowSpans,TestOrderedJSONPipelineErrorFormFeedLine.e2e:
TestRangeMetricCompatibilityJSONErrorFilterSplit(group semantics),against real Loki on a crafted fixture.
.5,+1, an invalid key escape, trailing text,truncated JSON,
{}, leading whitespace, plain and logfmt lines.by service_name, by three labels (level, service_name, status), by two
labels with a filter, and with a filter.
(Loki, VictoriaLogs and proxy readiness checked before the first test):
TestSetup_IngestLogs, everyTestRangeMetricCompatibility*(including...JSONFilterPushdownand...JSONLogsVolume),TestCompat_ExtractedLabelMetricPushdown,TestCompat_SanitizedJSONKey*and
TestCompat_ExtractedSuffixOnParsedCollision: 227 passed, 0 failed, 1skipped on each (the Drilldown partial-result subtest, skipped before this
change too), VictoriaLogs restart count 0 -> 0.
TestRangeMetricCompatibilityJSONErrorFilterSplitSeriesLimit: an in-processproxy with
-max-stats-query-series=5against real Loki. A label filterkeeping 2 of 20 paths, and exactly 5 paths plus a trailing-text line that
VictoriaLogs cannot parse, answer as Loki does; one path more is Loki's
series limit error, from the row evaluator the 20 groups are handed to (the last subtest also asserts Loki holds all 20 paths,
its own limit being higher). The last subtest used a step of 300 s with a
1 m range, so most evaluation points saw no line; the previous commit's 400
counted VictoriaLogs groups that no evaluation point saw, and the proxy now
returns the series it has points for. (Loki's answer to that exact query was
not checked: its compose config aligns queries with the step.) The subtest
now uses a step of the range (60 s), where every point sees lines, and Loki's
20 series are asserted.
TestRangeMetricCompatibilityJSONErrorFilterSplitDetectedLevel:sum by (detected_level)of count and bytes against real Loki on fourfixtures: the level only in the body (the row evaluator answers), as a
stream label in 11 spellings (the stats over the stored level fields
answer, asserted from the backend calls: no chain, no row read), a counted
line without a stored level among stored ones (the row evaluator), and the
level in the body of lines nested deeper than VictoriaLogs reads (stored
level fields for the counted lines, the row evaluator for the raw rows).
Targets: the stack proxy, an in-process proxy, and an in-process proxy with
-max-stats-query-series=5where the groups pass the guard and the rowevaluator answers. Each target equals Loki's series.
A 3000-line crafted corpus on the same stack: this branch equals main on
every shape. It equals Loki except a parsed key with an empty value (see
Registry).
A/B
Measured before the rebase onto #760; re-measured next.
Head
0bdb38c9(the last commit only adds these results) againstorigin/main(
1dcb79e0), run withbench/ab/pr_smoke.pyon fresh, isolated stacks(Loki 3.7.7, VictoriaLogs v1.52.0), both builds as host processes with their
tree's stack flags, the targets interleaved per request. Commands:
The 1 h run has 87 min of generator data in both backends; the 6 h and 24 h
runs have it in VictoriaLogs only, so those rows have no Loki column. Every
request carries a client timeout of 120 s; main answered a 502 (the row or
byte limit), never a timeout, on the shapes marked 502. VictoriaLogs restart
count: 0 -> 0 on every run, and 0 after the fallback measurements. Summaries:
bench/ab/results/2026-10-13-json-error-split-{1h,long,control-1h}.json.json-error-split — json-error-split-1h (2026-10-13, 5 runs, warm p50; VictoriaLogs restarts 0→0)
Verdict: 5 shape×range — 5 faster; results: 0 new difference(s) from loki, 0 pre-existing (also on base).
json-error-split — json-error-split-long (2026-10-13, 5 runs, warm p50; VictoriaLogs restarts 0→0)
Verdict: 10 shape×range — 8 fixed, 2 faster; results: 0 new difference(s) from loki, 0 pre-existing (also on base).
Control set at 1 h (no degradation; no shape of it uses the split):
control — json-error-split-control-1h (2026-10-13, 5 runs, warm p50; VictoriaLogs restarts 0→0)
Verdict: 20 shape×range — 20 same; results: 0 new difference(s) from loki, 1 pre-existing (also on base).
The two instant shapes (
instant json volume,instant json filtered) answerin half the time of main for the raw-row projection; the row evaluator is
otherwise unchanged by this PR except for the detected_level derivation (own
commit).
Where the split falls back to the row evaluator
A read of more than twice
-max-stats-query-seriesin groups (or a responsetoo large to read) is answered by the row evaluator, as on main. Host proxies
of both builds with the shown limit against the same stack, warm p50 of 3-5
runs on distinct windows (
bench/ab/results/oneoff/2026-10-13-json-error-split-fallback.json):sum by (session_id)(many groups), fallbacksum by (service_name), 7 groups, fallbacksum by (detected_level), 11 spellings (12 groups, 5 series), fallbacksum by (user_id), within twice the limit, no fallbackThe fallback is not free, and not "no slower than main". It is one
VictoriaLogs stats scan (which aggregates every group before it answers, so
stopping the read saves the proxy's memory, not VictoriaLogs' time) followed by
exactly main's row evaluator: 0.1 s more at 1 h on 150k lines (F4), and where
main's row evaluator ends at once with the series limit error because the first
rows already exceed the limit (F1, F2), the scan makes the same 400 take 0.07 to
0.15 s at 1 h and 1.5 to 4.8 s at 24 h. Main's pushdown for the shapes it
already served (
| json | drop __error__) has the same cost profile for anover-limit grouping. The memory guard cannot be tighter without turning a
legitimate level-spelling query into a fallback. Not done: a cheap pre-flight
that would send an obviously over-limit grouping straight to the row evaluator.
benchstat, main vs branch, 10 interleaved runs each (
BenchmarkOrderedJSON*,the branch's benchmark file copied to the main tree):
B/op is equal (11.33 MiB, 43.78 MiB); allocs/op differ by under 0.01% (243.8k
and 657.2k).
Backend-safety load test: the numbers below are from the previous head and were
not re-run for this round (the group guard and the fallback changed what a
heavy query does under load; a rerun is owed before merge).
/labelsprobe56/56 OK, at most 7.3 s; VictoriaLogs restarts 0 -> 0.
0 -> 0.
Registry
The rows below are new.
conformance/registry/cases/semantics/json-error-filter-metric-split-pushdown.yaml(new):conformance/registry/cases/semantics/json-empty-value-label-series.yaml(new):Changed rows:
Visual proof and owed proofs
bench/visual/spec.jsonaddsexplore-json-error-filter-metric, which liststhe fixed case
semantics/json-error-filter-metric-split-pushdown. The visual-smoke job captures main vs PR vs Loki.Still owed before merge:
sum by (app) (count_over_time({namespace="prod"} | json | __error__="" [$__auto]))at 24 h;