From d12bb8c9e092d7d7bfffda206a4f748393c88d8e Mon Sep 17 00:00:00 2001 From: szibis Date: Sun, 18 Oct 2026 12:21:03 +0200 Subject: [PATCH 1/7] build: raise the VictoriaLogs support floor to v1.49.0 Supported VictoriaLogs is v1.49.0 (the latest v1.4x release) plus every v1.5x release. The -backend-min-version default, its Go fallback, the Helm value and logsql.MinSupportedMinor move to v1.49.0, the manifest support window and tested matrix become v1.49.0, v1.50.0, v1.51.1, v1.52.0 and v1.53.0, and the README names the supported and tested versions. logsql.Capabilities drops the flags for features at or before the floor (rate_sum, ipv4_range, metadata substring): the zero value is the floor, which is also what an unknown backend version gets. BestIPv4Range emits ipv4_range() for IPv4 unconditionally. --- README.md | 15 ++++ charts/loki-vl-proxy/values.yaml | 4 +- cmd/proxy/main.go | 2 +- docs/reference/configuration-reference.md | 2 +- internal/logsql/builder.go | 25 +++--- internal/logsql/builder_test.go | 30 +++---- internal/logsql/capabilities.go | 30 +++---- internal/logsql/capabilities_test.go | 34 ++++---- internal/logsql/edge_cases_test.go | 30 ++----- internal/logsql/support_policy_test.go | 101 ++++++++++++++-------- internal/proxy/backend.go | 2 +- internal/proxy/backend_test.go | 56 +++++++++++- internal/proxy/proxy.go | 2 +- internal/translator/coverage_test.go | 25 +++--- internal/translator/translator.go | 2 +- test/e2e-compat/compatibility-matrix.json | 10 +-- 16 files changed, 218 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index a90302eaf..c0c593140 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,21 @@ Full cost worksheet, scaling projections, and EC2/GCP sizing tables: [Cost Model --- +## Supported versions + + +| VictoriaLogs | Version | +|---|---| +| Supported | `v1.49.0` (the latest v1.4x release) and every v1.5x release | +| Tested in CI | `v1.49.0`, `v1.50.0`, `v1.51.1`, `v1.52.0`, `v1.53.0` | +| Pull-request CI pin | `v1.52.0` | +| Startup floor (`-backend-min-version`) | `v1.49.0` | + + +The proxy refuses to start against a VictoriaLogs older than the floor unless `-backend-allow-unsupported-version=true` is set; such a version is unsupported and untested. Every supported minor is tested at its latest patch release that has a published image; the weekly matrix run includes the range-metric table compared with Loki. Loki, Grafana, Logs Drilldown and the Loki datasource plugin versions are listed in the [compatibility matrix](docs/compatibility-matrix.md); the VictoriaLogs policy and capability bands are in [VictoriaLogs compatibility](docs/compatibility-victorialogs.md). + +--- + ## Quick Start ### Docker diff --git a/charts/loki-vl-proxy/values.yaml b/charts/loki-vl-proxy/values.yaml index af48023ba..4b9a89320 100644 --- a/charts/loki-vl-proxy/values.yaml +++ b/charts/loki-vl-proxy/values.yaml @@ -135,7 +135,7 @@ extraArgs: # 120s matches the binary default; explicit here so operators can widen it for # very long-range log queries without hunting for the flag name. backend-timeout: "120s" - backend-min-version: "v1.40.0" + backend-min-version: "v1.49.0" backend-allow-unsupported-version: "false" backend-version-check-timeout: "5s" # Compression negotiation with VictoriaLogs backend. @@ -338,7 +338,7 @@ extraArgs: # compat-cache-enabled: "true" # enable Tier0 compatibility-edge cache # compat-cache-max-percent: "10" # Tier0 share of L1 cache budget (max 50) # backend-timeout: "120s" # upstream request timeout - # backend-min-version: "v1.40.0" # startup minimum supported VictoriaLogs version + # backend-min-version: "v1.49.0" # startup minimum supported VictoriaLogs version # backend-allow-unsupported-version: "false" # allow older backend at your own risk # backend-version-check-timeout: "5s" # startup backend version check timeout # backend-version-strict: "false" # when true, /health failure, non-2xx, or missing/sub-min backend semver fails startup (default false: warn only); overrides --backend-allow-unsupported-version diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index 33e3649e4..a9105111f 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -552,7 +552,7 @@ func run( cbFailThreshold := fs.Int("cb-fail-threshold", 5, "Circuit breaker: failures within -cb-window-duration before opening") cbOpenDuration := fs.Duration("cb-open-duration", 10*time.Second, "Circuit breaker: how long to stay open before allowing probe requests") cbWindowDuration := fs.Duration("cb-window-duration", 30*time.Second, "Circuit breaker: sliding window for failure counting; failures older than this are discarded") - backendMinVersion := fs.String("backend-min-version", "v1.40.0", "Minimum VictoriaLogs version considered fully supported at startup (first minor of the oldest supported line; v1.4x and v1.5x are supported)") + backendMinVersion := fs.String("backend-min-version", "v1.49.0", "Minimum VictoriaLogs version considered fully supported at startup (v1.49.0, the latest v1.4x release; every v1.5x release is supported)") backendAllowUnsupportedVersion := fs.Bool("backend-allow-unsupported-version", false, "Allow startup with backend versions lower than -backend-min-version (at your own risk). Ignored when --backend-version-strict=true.") backendVersionCheckTimeout := fs.Duration("backend-version-check-timeout", 5*time.Second, "Timeout for startup backend version compatibility check") backendVersionStrict := fs.Bool("backend-version-strict", false, "When true, /health failure, non-2xx response, or missing/sub-min backend semver causes startup to fail. Default false (warn only). Overrides --backend-allow-unsupported-version when both are set.") diff --git a/docs/reference/configuration-reference.md b/docs/reference/configuration-reference.md index 1c9b6bdbf..f57951608 100644 --- a/docs/reference/configuration-reference.md +++ b/docs/reference/configuration-reference.md @@ -73,7 +73,7 @@ Helm passes any flag through `extraArgs.`; the chart sets a few of them fr | Flag | Type | Default | Helm value | Description | |---|---|---|---|---| | `-backend-allow-unsupported-version` | bool | `false` | `extraArgs.backend-allow-unsupported-version` | Allow startup with backend versions lower than -backend-min-version (at your own risk). Ignored when --backend-version-strict=true. | -| `-backend-min-version` | string | `"v1.40.0"` | `extraArgs.backend-min-version` | Minimum VictoriaLogs version considered fully supported at startup (first minor of the oldest supported line; v1.4x and v1.5x are supported) | +| `-backend-min-version` | string | `"v1.49.0"` | `extraArgs.backend-min-version` | Minimum VictoriaLogs version considered fully supported at startup (v1.49.0, the latest v1.4x release; every v1.5x release is supported) | | `-backend-version-strict` | bool | `false` | `extraArgs.backend-version-strict` | When true, /health failure, non-2xx response, or missing/sub-min backend semver causes startup to fail. Default false (warn only). Overrides --backend-allow-unsupported-version when both are set. | | `-derived-fields` | string | (empty) | `extraArgs.derived-fields` | name | | `-detected-level-body-scan` | bool | `true` | `extraArgs.detected-level-body-scan` | Derive detected_level from the log line (JSON, logfmt, keywords) when a row has no stored level field, as Loki does; false uses stored level fields only with an unknown fallback | diff --git a/internal/logsql/builder.go b/internal/logsql/builder.go index faed62765..5a41d082c 100644 --- a/internal/logsql/builder.go +++ b/internal/logsql/builder.go @@ -52,7 +52,7 @@ type Builder struct{ caps Capabilities } func NewBuilder(caps Capabilities) *Builder { return &Builder{caps: caps} } // BestTopN returns pipe stages that keep the top-K entries by the named field. -// On all supported versions (v1.40+) this is | sort by (field desc) | limit K. +// On all supported versions (v1.49+) this is | sort by (field desc) | limit K. func (b *Builder) BestTopN(k int, field string) []Pipe { return []Pipe{ PipeSort{By: []SortField{{Field: field, Desc: true}}}, @@ -61,21 +61,18 @@ func (b *Builder) BestTopN(k int, field string) []Pipe { } // BestIPv4Range returns a filter that matches field against the CIDR range. -// On v1.45+ it emits the native ipv4_range() field filter. -// On earlier versions it falls back to a regexp approximation. +// It emits the native ipv4_range() field filter and falls back to a regexp +// approximation for CIDRs ipv4_range() cannot express (IPv6, unparsable). func (b *Builder) BestIPv4Range(field, cidr string) FilterExpr { - if b.caps.FieldIPv4Range { - first, last, ok := cidrToRange(cidr) - if !ok { - return FieldFilter{Field: field, Op: FieldOpRegexp, Value: cidrToRegexp(cidr)} - } - return FieldFilter{ - Field: field, - Op: FieldOpIPv4Range, - Value: first + ", " + last, - } + first, last, ok := cidrToRange(cidr) + if !ok { + return FieldFilter{Field: field, Op: FieldOpRegexp, Value: cidrToRegexp(cidr)} + } + return FieldFilter{ + Field: field, + Op: FieldOpIPv4Range, + Value: first + ", " + last, } - return FieldFilter{Field: field, Op: FieldOpRegexp, Value: cidrToRegexp(cidr)} } // cidrToRange parses a CIDR and returns the first and last IP as strings. diff --git a/internal/logsql/builder_test.go b/internal/logsql/builder_test.go index d607a3cbf..0aabd0ffc 100644 --- a/internal/logsql/builder_test.go +++ b/internal/logsql/builder_test.go @@ -89,24 +89,18 @@ func TestBestTopN_v150(t *testing.T) { } } -func TestBestIPv4Range_pre145(t *testing.T) { - b := logsql.NewBuilder(logsql.CapabilitiesFor("v1.44.0")) - f := b.BestIPv4Range("client_ip", "192.168.1.0/24") - // VictoriaLogs unquotes with Go rules, so the pattern's backslashes are - // escaped in the literal it receives (v1.52.0 rejects the single-backslash - // form with "compound token cannot start with"). - want := `client_ip:~"^192\\.168\\.1\\."` - if got := f.String(); got != want { - t.Errorf("v1.44 fallback = %q, want %q", got, want) - } -} - -func TestBestIPv4Range_v145(t *testing.T) { - b := logsql.NewBuilder(logsql.CapabilitiesFor("v1.45.0")) - f := b.BestIPv4Range("client_ip", "192.168.1.0/24") - want := "client_ip:ipv4_range(192.168.1.0, 192.168.1.255)" - if got := f.String(); got != want { - t.Errorf("got %q, want %q", got, want) +// The native ipv4_range() filter is emitted for every backend version, the +// unknown one (zero capabilities) included. +func TestBestIPv4Range(t *testing.T) { + for _, semver := range []string{"", "v1.49.0", "v1.52.0"} { + t.Run(semver, func(t *testing.T) { + b := logsql.NewBuilder(logsql.CapabilitiesFor(semver)) + f := b.BestIPv4Range("client_ip", "192.168.1.0/24") + want := "client_ip:ipv4_range(192.168.1.0, 192.168.1.255)" + if got := f.String(); got != want { + t.Errorf("got %q, want %q", got, want) + } + }) } } diff --git a/internal/logsql/capabilities.go b/internal/logsql/capabilities.go index e2ef9df07..f02e9ad0c 100644 --- a/internal/logsql/capabilities.go +++ b/internal/logsql/capabilities.go @@ -7,26 +7,24 @@ import ( ) // MinSupportedMajor and MinSupportedMinor define the oldest VictoriaLogs -// release that this package targets. Queries targeting earlier versions -// must fall back to the proxy's string-concatenation translator. +// release that this package targets (v1.49.0). Queries targeting earlier +// versions must fall back to the proxy's string-concatenation translator. const ( MinSupportedMajor = 1 - MinSupportedMinor = 40 + MinSupportedMinor = 49 ) // Capabilities describes which optional LogsQL constructs are available // in a specific VictoriaLogs deployment. // -// The package targets v1.4x and v1.5x families only. All features listed -// in the v1.40 baseline (PipeHits, PipeRunning, PipeBlock, PipeUniq, -// PipeTop, StatsHistogram) are assumed always available; they are not -// represented as flags because every supported version includes them. +// The package targets v1.49.0 and newer. Everything introduced up to v1.49.0 +// (PipeHits, PipeRunning, PipeBlock, PipeUniq, PipeTop, StatsHistogram, +// rate_sum(), ipv4_range(), the metadata substring filter) is assumed always +// available; it is not represented as a flag because every supported version +// includes it. An unknown version gets the zero value, the v1.49.0 floor. // -// Features that vary within the v1.40–v1.5x range are: +// Features that vary within the v1.49–v1.5x range are: // -// StatsRateSum — rate_sum() stats function. Added in v1.44. -// FieldIPv4Range — ipv4_range() field filter. Added in v1.45. -// MetadataSubstring — substring in metadata fields. Added in v1.49. // DensePatternWindowing — dense windowing for patterns. Added in v1.50. // PipeCoalesce — coalesce pipe. Added in v1.51. // @@ -34,17 +32,14 @@ const ( // compatible but slower alternative (regexp approximation, proxy-side // post-filtering, etc.). type Capabilities struct { - StatsRateSum bool // v1.44+ - FieldIPv4Range bool // v1.45+ - MetadataSubstring bool // v1.49+ DensePatternWindowing bool // v1.50+ PipeCoalesce bool // v1.51+ } // CapabilitiesFor returns the Capabilities for the given VictoriaLogs semver -// string (e.g. "v1.50.0" or "1.44.2"). +// string (e.g. "v1.50.0" or "1.52.1"). // -// Versions earlier than v1.40 are not supported; they return the zero value +// Versions earlier than v1.49 are not supported; they return the zero value // (all features false) rather than panicking, so callers always get a safe // degraded capability set. func CapabilitiesFor(semver string) Capabilities { @@ -53,9 +48,6 @@ func CapabilitiesFor(semver string) Capabilities { return Capabilities{} } return Capabilities{ - StatsRateSum: atLeast(maj, min, 1, 44), - FieldIPv4Range: atLeast(maj, min, 1, 45), - MetadataSubstring: atLeast(maj, min, 1, 49), DensePatternWindowing: atLeast(maj, min, 1, 50), PipeCoalesce: atLeast(maj, min, 1, 51), } diff --git a/internal/logsql/capabilities_test.go b/internal/logsql/capabilities_test.go index 5067569be..35fe33690 100644 --- a/internal/logsql/capabilities_test.go +++ b/internal/logsql/capabilities_test.go @@ -12,30 +12,26 @@ func TestCapabilitiesFor(t *testing.T) { semver string want logsql.Capabilities }{ - // Versions below v1.40 are not supported; return baseline (all false). - {"v1.30.0", logsql.Capabilities{}}, - {"v1.39.9", logsql.Capabilities{}}, - // v1.40 baseline — all optional features false. - {"v1.40.0", logsql.Capabilities{}}, - {"v1.43.9", logsql.Capabilities{}}, - // v1.44 adds rate_sum. - {"v1.44.0", logsql.Capabilities{StatsRateSum: true}}, - // v1.45 adds ipv4_range. - {"v1.45.0", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true}}, - {"v1.48.9", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true}}, - // v1.49 adds metadata substring. - {"v1.49.0", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true, MetadataSubstring: true}}, + // The floor (v1.49.0) is the zero value: every feature at or before it is + // always on and has no flag. + {"v1.49.0", logsql.Capabilities{}}, + {"v1.49.9", logsql.Capabilities{}}, // v1.50 adds dense pattern windowing. - {"v1.50.0", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true, MetadataSubstring: true, DensePatternWindowing: true}}, + {"v1.50.0", logsql.Capabilities{DensePatternWindowing: true}}, + {"v1.50.9", logsql.Capabilities{DensePatternWindowing: true}}, // v1.51 adds the coalesce pipe. - {"v1.51.0", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true, MetadataSubstring: true, DensePatternWindowing: true, PipeCoalesce: true}}, - {"v1.99.0", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true, MetadataSubstring: true, DensePatternWindowing: true, PipeCoalesce: true}}, - // Empty string — safe baseline. + {"v1.51.0", logsql.Capabilities{DensePatternWindowing: true, PipeCoalesce: true}}, + {"v1.99.0", logsql.Capabilities{DensePatternWindowing: true, PipeCoalesce: true}}, + // An unknown or unparsable version behaves as the floor. {"", logsql.Capabilities{}}, + {"not-a-version", logsql.Capabilities{}}, + // Below the floor is unsupported and gets no extra capabilities. + {"v1.48.9", logsql.Capabilities{}}, + {"v1.40.0", logsql.Capabilities{}}, // no "v" prefix - {"1.44.0", logsql.Capabilities{StatsRateSum: true}}, + {"1.51.0", logsql.Capabilities{DensePatternWindowing: true, PipeCoalesce: true}}, // two-component version (no patch) - {"v1.40", logsql.Capabilities{}}, + {"v1.50", logsql.Capabilities{DensePatternWindowing: true}}, } for _, tc := range tests { t.Run(tc.semver, func(t *testing.T) { diff --git a/internal/logsql/edge_cases_test.go b/internal/logsql/edge_cases_test.go index 211c5b192..18c00713b 100644 --- a/internal/logsql/edge_cases_test.go +++ b/internal/logsql/edge_cases_test.go @@ -229,34 +229,22 @@ func TestPipeStatsNoBy(t *testing.T) { } } -// TestCapabilitiesAllMinorVersions tests every minor version v1.40–v1.50. +// TestCapabilitiesAllMinorVersions tests every minor version from the floor +// (v1.49) through v1.53 and the boundaries between them. func TestCapabilitiesAllMinorVersions(t *testing.T) { tests := []struct { semver string want logsql.Capabilities }{ - {"v1.40.0", logsql.Capabilities{}}, - {"v1.41.0", logsql.Capabilities{}}, - {"v1.42.0", logsql.Capabilities{}}, - {"v1.43.0", logsql.Capabilities{}}, - // boundary: v1.43.9 vs v1.44.0 - {"v1.43.9", logsql.Capabilities{}}, - {"v1.44.0", logsql.Capabilities{StatsRateSum: true}}, - // boundary: v1.44.9 vs v1.45.0 - {"v1.44.9", logsql.Capabilities{StatsRateSum: true}}, - {"v1.45.0", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true}}, - {"v1.46.0", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true}}, - {"v1.47.0", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true}}, - {"v1.48.0", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true}}, - // boundary: v1.48.9 vs v1.49.0 - {"v1.48.9", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true}}, - {"v1.49.0", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true, MetadataSubstring: true}}, + {"v1.49.0", logsql.Capabilities{}}, // boundary: v1.49.9 vs v1.50.0 - {"v1.49.9", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true, MetadataSubstring: true}}, - {"v1.50.0", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true, MetadataSubstring: true, DensePatternWindowing: true}}, + {"v1.49.9", logsql.Capabilities{}}, + {"v1.50.0", logsql.Capabilities{DensePatternWindowing: true}}, // boundary: v1.50.9 vs v1.51.0 - {"v1.50.9", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true, MetadataSubstring: true, DensePatternWindowing: true}}, - {"v1.51.0", logsql.Capabilities{StatsRateSum: true, FieldIPv4Range: true, MetadataSubstring: true, DensePatternWindowing: true, PipeCoalesce: true}}, + {"v1.50.9", logsql.Capabilities{DensePatternWindowing: true}}, + {"v1.51.0", logsql.Capabilities{DensePatternWindowing: true, PipeCoalesce: true}}, + {"v1.52.0", logsql.Capabilities{DensePatternWindowing: true, PipeCoalesce: true}}, + {"v1.53.0", logsql.Capabilities{DensePatternWindowing: true, PipeCoalesce: true}}, } for _, tc := range tests { t.Run(tc.semver, func(t *testing.T) { diff --git a/internal/logsql/support_policy_test.go b/internal/logsql/support_policy_test.go index 278f18dd2..ab69a2caf 100644 --- a/internal/logsql/support_policy_test.go +++ b/internal/logsql/support_policy_test.go @@ -11,11 +11,11 @@ import ( "github.com/ReliablyObserve/Loki-VL-proxy/internal/logsql" ) -// The VictoriaLogs support policy (owner decision 2026-10-05): the latest minor -// line and the previous one are fully supported (today v1.5x and v1.4x), and the -// tested matrix holds at most the latest 3 releases of each line. When a new line -// appears the window shifts one line; this test fails until the matrix, the -// -backend-min-version default and logsql.MinSupportedMinor move together. +// The VictoriaLogs support policy: the latest v1.4x release and every v1.5x +// release are supported, and the tested matrix holds each supported minor at its +// latest patch with a published image. The floor is the one v1.4x entry; this +// test fails until the manifest, the -backend-min-version default (flag, Go +// fallback, Helm), logsql.MinSupportedMinor and the README table agree. var lineRE = regexp.MustCompile(`^v1\.(\d)x(?:\.x)?$`) var versionRE = regexp.MustCompile(`^v1\.(\d+)\.(\d+)$`) @@ -33,7 +33,6 @@ func TestVictoriaLogsSupportPolicy(t *testing.T) { CurrentFamily string `json:"current_family"` PreviousFamily string `json:"previous_family"` AdditionalFamilies []string `json:"additional_families"` - MaxPerFamily int `json:"max_versions_per_family"` MinimumSupported string `json:"minimum_supported_version"` } `json:"support_window"` MatrixVersions []string `json:"matrix_versions"` @@ -61,16 +60,17 @@ func TestVictoriaLogsSupportPolicy(t *testing.T) { if len(w.AdditionalFamilies) != 0 { t.Fatalf("only the latest two lines are supported; additional_families = %v", w.AdditionalFamilies) } - if w.MaxPerFamily != 3 { - t.Fatalf("max_versions_per_family must be 3, got %d", w.MaxPerFamily) - } - minMinor := prevLine * 10 - if want := "v1." + strconv.Itoa(minMinor) + ".0"; w.MinimumSupported != want { - t.Fatalf("minimum_supported_version = %q, want %q (first minor of the oldest supported line)", w.MinimumSupported, want) + floor := versionRE.FindStringSubmatch(w.MinimumSupported) + if floor == nil { + t.Fatalf("minimum_supported_version %q is not vMAJOR.MINOR.PATCH", w.MinimumSupported) + } + floorMinor, _ := strconv.Atoi(floor[1]) + if floorMinor/10 != prevLine { + t.Fatalf("minimum_supported_version = %q must be a release of the previous line %s", w.MinimumSupported, w.PreviousFamily) } - if logsql.MinSupportedMajor != 1 || logsql.MinSupportedMinor != minMinor { - t.Fatalf("logsql.MinSupported = %d.%d, want 1.%d", logsql.MinSupportedMajor, logsql.MinSupportedMinor, minMinor) + if logsql.MinSupportedMajor != 1 || logsql.MinSupportedMinor != floorMinor { + t.Fatalf("logsql.MinSupported = %d.%d, want 1.%d", logsql.MinSupportedMajor, logsql.MinSupportedMinor, floorMinor) } main, err := os.ReadFile("../../cmd/proxy/main.go") @@ -129,8 +129,10 @@ func TestVictoriaLogsSupportPolicy(t *testing.T) { } } + // The tested matrix: exactly one entry of the previous line (the floor) and + // every minor of the current line from its first one to the newest, each once. minors := map[int]string{} - perLine := map[int]int{} + lowest, highest := 1<<30, 0 for _, v := range m.Stack.VL.MatrixVersions { sm := versionRE.FindStringSubmatch(v) if sm == nil { @@ -141,35 +143,66 @@ func TestVictoriaLogsSupportPolicy(t *testing.T) { t.Fatalf("matrix lists minor v1.%d twice (%s, %s); keep the latest patch only", minor, other, v) } minors[minor] = v - line := minor / 10 - if line != curLine && line != prevLine { + switch minor / 10 { + case prevLine: + if v != w.MinimumSupported { + t.Fatalf("the only %s entry in matrix_versions must be the floor %s, found %s", w.PreviousFamily, w.MinimumSupported, v) + } + case curLine: + lowest, highest = min(lowest, minor), max(highest, minor) + default: t.Fatalf("matrix version %s is outside the supported lines %s and %s", v, w.CurrentFamily, w.PreviousFamily) } - perLine[line]++ } - for _, line := range []int{curLine, prevLine} { - if perLine[line] == 0 || perLine[line] > w.MaxPerFamily { - t.Fatalf("line v1.%dx has %d matrix versions, want 1..%d", line, perLine[line], w.MaxPerFamily) - } + if minors[floorMinor] != w.MinimumSupported { + t.Fatalf("matrix_versions %v must contain the floor %s", m.Stack.VL.MatrixVersions, w.MinimumSupported) } - if _, tested := minors[pinMinorOf(t, m.Stack.VL.PinnedVersion)]; !tested || minors[pinMinorOf(t, m.Stack.VL.PinnedVersion)] != m.Stack.VL.PinnedVersion { - t.Fatalf("pinned_version %q must be one of the tested matrix_versions %v", m.Stack.VL.PinnedVersion, m.Stack.VL.MatrixVersions) + if lowest != curLine*10 { + t.Fatalf("matrix_versions %v must start the %s line at v1.%d", m.Stack.VL.MatrixVersions, w.CurrentFamily, curLine*10) + } + for minor := lowest; minor <= highest; minor++ { + if _, ok := minors[minor]; !ok { + t.Fatalf("matrix_versions %v skips supported minor v1.%d", m.Stack.VL.MatrixVersions, minor) + } } pin := versionRE.FindStringSubmatch(m.Stack.VL.PinnedVersion) if pin == nil { t.Fatalf("pinned_version %q is not vMAJOR.MINOR.PATCH", m.Stack.VL.PinnedVersion) } - if pinMinor, _ := strconv.Atoi(pin[1]); pinMinor/10 != curLine { + pinMinor, _ := strconv.Atoi(pin[1]) + if minors[pinMinor] != m.Stack.VL.PinnedVersion { + t.Fatalf("pinned_version %q must be one of the tested matrix_versions %v", m.Stack.VL.PinnedVersion, m.Stack.VL.MatrixVersions) + } + if pinMinor/10 != curLine { t.Fatalf("pinned_version %q must stay in the current line %s", m.Stack.VL.PinnedVersion, w.CurrentFamily) } -} -func pinMinorOf(t *testing.T, v string) int { - t.Helper() - sm := versionRE.FindStringSubmatch(v) - if sm == nil { - t.Fatalf("%q is not vMAJOR.MINOR.PATCH", v) - } - minor, _ := strconv.Atoi(sm[1]) - return minor + // The README names the same matrix, pin and floor between its markers. + readme, err := os.ReadFile("../../README.md") + if err != nil { + t.Fatal(err) + } + block := regexp.MustCompile(`(?s)(.*?)`).FindStringSubmatch(string(readme)) + if block == nil { + t.Fatal("README.md has no ... table") + } + rows := map[string][]string{} // first cell -> versions named in the second cell + for _, line := range strings.Split(block[1], "\n") { + cells := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|") + if len(cells) == 2 { + rows[strings.TrimSpace(cells[0])] = regexp.MustCompile(`v\d+\.\d+\.\d+`).FindAllString(cells[1], -1) + } + } + for row, want := range map[string][]string{ + "Tested in CI": m.Stack.VL.MatrixVersions, + "Pull-request CI pin": {m.Stack.VL.PinnedVersion}, + "Startup floor (`-backend-min-version`)": {w.MinimumSupported}, + } { + if got := rows[row]; strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("README supported-versions row %q lists %v, want %v", row, got, want) + } + } + if got := rows["Supported"]; len(got) == 0 || got[0] != w.MinimumSupported { + t.Fatalf("README supported-versions row \"Supported\" lists %v, want it to start with the floor %s", got, w.MinimumSupported) + } } diff --git a/internal/proxy/backend.go b/internal/proxy/backend.go index d71369505..25f8db992 100644 --- a/internal/proxy/backend.go +++ b/internal/proxy/backend.go @@ -313,7 +313,7 @@ func (p *Proxy) storeBackendVersion(raw, semver string) { "backend version is below the supported VictoriaLogs lines", "backend.version.semver", semver, "minimum_supported_version", fmt.Sprintf("v%d.%d.0", logsql.MinSupportedMajor, logsql.MinSupportedMinor), - "note", "VictoriaLogs v1.4x and v1.5x are supported; this version is unsupported and untested", + "note", "VictoriaLogs v1.49.0 and every v1.5x release are supported; this version is unsupported and untested", ) } } diff --git a/internal/proxy/backend_test.go b/internal/proxy/backend_test.go index cb200fb50..9cba40c11 100644 --- a/internal/proxy/backend_test.go +++ b/internal/proxy/backend_test.go @@ -244,7 +244,7 @@ func TestBackendVersionStrict_OverridesAllowUnsupported(t *testing.T) { } } -// A detected backend below the supported VictoriaLogs lines (v1.40) is warned +// A detected backend below the supported VictoriaLogs versions (v1.49.0) is warned // about at startup whatever -backend-min-version says: Helm values or an // operator can still carry v1.30.0 explicitly. func TestBackendVersionBelowSupportedLinesWarns(t *testing.T) { @@ -253,8 +253,8 @@ func TestBackendVersionBelowSupportedLinesWarns(t *testing.T) { wantWarn bool }{ {"VictoriaLogs/v1.35.0", true}, - {"VictoriaLogs/v1.39.9", true}, - {"VictoriaLogs/v1.40.0", false}, + {"VictoriaLogs/v1.48.9", true}, + {"VictoriaLogs/v1.49.0", false}, {"VictoriaLogs/v1.52.0", false}, } { t.Run(tc.server, func(t *testing.T) { @@ -271,3 +271,53 @@ func TestBackendVersionBelowSupportedLinesWarns(t *testing.T) { }) } } + +// With the default -backend-min-version (v1.49.0) a v1.48.0 backend blocks +// startup with an error naming the override flag, v1.49.0 and a v1.5x release +// start, and the override flag lets the unsupported version through. +func TestBackendVersionCompatibilityGate_DefaultFloorIsV149(t *testing.T) { + for _, tc := range []struct { + version string + allow bool + wantFail bool + }{ + {"v1.48.0", false, true}, + {"v1.48.0", true, false}, + {"v1.49.0", false, false}, + {"v1.52.0", false, false}, + } { + t.Run(tc.version, func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Server", "VictoriaLogs/"+tc.version) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer backend.Close() + + p, err := New(Config{ + BackendURL: backend.URL, + Cache: cache.New(60*time.Second, 1000), + BackendVersionCheckTimeout: time.Second, + BackendAllowUnsupportedVersion: tc.allow, + }) + if err != nil { + t.Fatalf("new proxy: %v", err) + } + err = p.ValidateBackendVersionCompatibility(context.Background()) + if !tc.wantFail { + if err != nil { + t.Fatalf("expected startup to pass, got: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected startup to be blocked for %s", tc.version) + } + for _, want := range []string{"backend-allow-unsupported-version", "v1.49.0", tc.version} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("expected %q in the startup error, got: %v", want, err) + } + } + }) + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 421dea32c..e89508d2c 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -920,7 +920,7 @@ func New(cfg Config) (*Proxy, error) { } backendMinVersion := normalizeSemverString(cfg.BackendMinVersion) if backendMinVersion == "" { - backendMinVersion = "v1.40.0" + backendMinVersion = "v1.49.0" } if _, _, _, ok := parseSemverTriplet(backendMinVersion); !ok { return nil, fmt.Errorf("invalid backend minimum version %q", backendMinVersion) diff --git a/internal/translator/coverage_test.go b/internal/translator/coverage_test.go index 8f3840ba4..c2c6ba16e 100644 --- a/internal/translator/coverage_test.go +++ b/internal/translator/coverage_test.go @@ -157,20 +157,23 @@ func TestDecolorizePipelineStage(t *testing.T) { } } -// TestIPFilterTranslation verifies that | ip("cidr") stages emit VL-native -// ipv4_range() on v1.45+ and regexp fallback on older versions. +// TestIPFilterTranslation verifies that | ip("cidr") stages emit the VL-native +// ipv4_range() for IPv4, whatever capabilities are known (an unknown backend +// version has none), and the regexp approximation only for IPv6. func TestIPFilterTranslation(t *testing.T) { - // With zero capabilities (pre-v1.45): regexp fallback via | filter _msg:~"..." - got := translatePipelineStage(`ip("10.0.0.0/8")`, nil, logsql.Capabilities{}) - if !strings.HasPrefix(got, "| filter _msg:~") { - t.Fatalf("expected regexp fallback for ip() without FieldIPv4Range cap, got: %q", got) + for name, caps := range map[string]logsql.Capabilities{ + "unknown version": {}, + "v1.52.0": logsql.CapabilitiesFor("v1.52.0"), + } { + got := translatePipelineStage(`ip("192.168.0.0/16")`, nil, caps) + if got != `| filter _msg:ipv4_range(192.168.0.0, 192.168.255.255)` { + t.Fatalf("%s: expected native ipv4_range() filter, got: %q", name, got) + } } - // With v1.45+ capabilities: native ipv4_range() filter - caps := logsql.Capabilities{FieldIPv4Range: true} - got = translatePipelineStage(`ip("192.168.0.0/16")`, nil, caps) - if got != `| filter _msg:ipv4_range(192.168.0.0, 192.168.255.255)` { - t.Fatalf("expected native ipv4_range() filter, got: %q", got) + got := translatePipelineStage(`ip("2001:db8::/32")`, nil, logsql.Capabilities{}) + if !strings.HasPrefix(got, "| filter _msg:~") { + t.Fatalf("expected regexp fallback for an IPv6 ip() filter, got: %q", got) } } diff --git a/internal/translator/translator.go b/internal/translator/translator.go index 8ab42fa78..671e1d93d 100644 --- a/internal/translator/translator.go +++ b/internal/translator/translator.go @@ -1234,7 +1234,7 @@ func translatePipelineStage(stage string, labelFn LabelTranslateFunc, caps logsq return logsql.PipeDecolorize{}.String() // "| decolorize" } - // ip() CIDR pipeline filter — emit VL-native ipv4_range() (v1.45+) or regexp fallback. + // ip() CIDR pipeline filter — emit VL-native ipv4_range(), or the regexp approximation for IPv6. if strings.HasPrefix(stage, `ip("`) && strings.HasSuffix(stage, `")`) { cidr := stage[4 : len(stage)-2] filter := logsql.NewBuilder(caps).BestIPv4Range("_msg", cidr) diff --git a/test/e2e-compat/compatibility-matrix.json b/test/e2e-compat/compatibility-matrix.json index a2542a6a8..60da9f17b 100644 --- a/test/e2e-compat/compatibility-matrix.json +++ b/test/e2e-compat/compatibility-matrix.json @@ -42,17 +42,15 @@ "image_repo": "victoriametrics/victoria-logs", "pinned_version": "v1.52.0", "support_window": { - "policy": "latest_two_lines_three_releases_each", - "description": "Fully support the latest VictoriaLogs minor line and the previous one (today v1.5x and v1.4x); older lines are unsupported. Test at most the latest 3 releases of each supported line, using the latest patch of each minor. When a new line appears (for example v1.6x) it becomes current, the previous current line becomes previous, and the oldest line and its matrix_versions drop out. The pinned_version (PR CI image) must be one of the tested versions and moves separately, with its own parity proof. Moving the window also means: update the -backend-min-version default (Go fallback, flag and Helm value: first minor of the oldest supported line), logsql.MinSupportedMinor and, only if a capability boundary moves, the capability profiles below (their names describe where a capability starts, not the support floor).", + "policy": "latest_1_4x_release_plus_every_1_5x_release", + "description": "Fully support the latest VictoriaLogs v1.4x release (v1.49.0) and every v1.5x release; anything older is unsupported. Test every supported minor at its latest patch that has a published Docker image: v1.49.0 for the v1.4x line and each v1.5x minor. A new minor joins matrix_versions as soon as it is released. The pinned_version (PR CI image) must be one of the tested versions and moves separately, with its own parity proof. Moving the floor also means: update the -backend-min-version default (Go fallback, flag and Helm value), logsql.MinSupportedMinor, the supported-versions table in README.md and, only if a capability boundary moves, the capability profiles below (their names describe where a capability starts, not the support floor).", "current_family": "v1.5x.x", "previous_family": "v1.4x.x", - "max_versions_per_family": 3, - "minimum_supported_version": "v1.40.0" + "minimum_supported_version": "v1.49.0" }, "matrix_versions": [ - "v1.47.0", - "v1.48.0", "v1.49.0", + "v1.50.0", "v1.51.1", "v1.52.0", "v1.53.0" From 0b0d3be602e58c2da57109f022e3e2d0fa8401a2 Mon Sep 17 00:00:00 2001 From: szibis Date: Sun, 18 Oct 2026 12:21:11 +0200 Subject: [PATCH 2/7] ci: run the range-metric table in the VictoriaLogs version matrix The weekly matrix job ran the compatibility score and three version-sensitive checks. The range-metric table compared with Loki (unwrap, range metric compatibility, Grafana click-outs) is what broke on older backends, so it now runs on every supported VictoriaLogs version too, with an explicit test and job timeout. --- .github/workflows/compat-vl.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/compat-vl.yaml b/.github/workflows/compat-vl.yaml index 845a4eda2..14ed004e7 100644 --- a/.github/workflows/compat-vl.yaml +++ b/.github/workflows/compat-vl.yaml @@ -69,6 +69,9 @@ jobs: if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' needs: vl-matrix-source runs-on: ubuntu-latest + # Each version runs the range-metric table on top of the compatibility + # checks; bound a hung stack well below the 6 hour default. + timeout-minutes: 40 strategy: fail-fast: false matrix: @@ -102,10 +105,11 @@ jobs: - name: Run VictoriaLogs compatibility score env: VICTORIALOGS_IMAGE: victoriametrics/victoria-logs:${{ matrix.vl_version }} - # The score plus the two checks whose outcome depends on how the + # The score plus the checks whose outcome depends on how the # VictoriaLogs version words or stores things: OTLP severity as - # detected_level, and the status of rejected queries (parse errors). - run: go test -v -tags=e2e -run '^(TestSetup_IngestLogs|TestVLTrackScore|TestCompat_DetectedLevelQueryRange|TestCompat_RejectedQueryStatusParity)$' ./test/e2e-compat/ + # detected_level, the status of rejected queries (parse errors), and the + # range-metric table compared with Loki (bucket edges, offsets, unwrap). + run: go test -v -tags=e2e -timeout=25m -run '^(TestSetup_IngestLogs|TestVLTrackScore|TestCompat_DetectedLevelQueryRange|TestCompat_RejectedQueryStatusParity|TestCompat_Unwrap.*|TestRangeMetric.*|TestGrafanaClickout_.*)$' ./test/e2e-compat/ - name: Tear down matrix stack if: always() working-directory: test/e2e-compat From 426ecaf67ab8bc2eae084631d97119aa44508095 Mon Sep 17 00:00:00 2001 From: szibis Date: Sun, 18 Oct 2026 12:24:29 +0200 Subject: [PATCH 3/7] refactor(proxy): drop the stats offset and metadata substring version gates The supported floor is v1.49.0, so the gates and fallbacks that only served older backends are removed: - stats_query_range offset (v1.45): buckets are always anchored with the offset arg; the epoch-aligned-only tumbling and sliding grids, the raw-row route for unaligned grids and the ordered-JSON pushdown gate are gone. Raw evaluators that do not depend on the version (sub-millisecond buckets, byte and row limits, more than 11,000 buckets) stay. - metadata substring filter (v1.49): q and filter=substring are always forwarded. The proxy-side search window stays, it also serves the label values browse index and offset/limit windowing. An unknown backend version behaves as the floor, and the background /metrics probe that supportsStatsRangeOffset used to trigger now runs from any upstream response without a version while it is unknown. Tests that only exercised the removed fallbacks are rewritten to assert anchored buckets on v1.49.0, v1.52.0 and an unknown version; the raw-sample evaluator coverage of the unwrap rate window is kept by forcing that route without a version gate. --- internal/proxy/backend.go | 48 +++----- internal/proxy/backend_test.go | 39 +++++++ .../proxy/bare_parser_sliding_parity_test.go | 33 ++---- internal/proxy/drilldown_burst_coalescer.go | 2 +- internal/proxy/label_metadata.go | 2 +- internal/proxy/metric_binary.go | 15 +-- .../ordered_json_extracted_pushdown_test.go | 23 ++-- internal/proxy/ordered_json_metric.go | 9 +- internal/proxy/proxy.go | 3 - internal/proxy/proxy_test.go | 108 ++++++++---------- internal/proxy/query_translation.go | 8 +- internal/proxy/range_metric_compat.go | 62 ++++------ internal/proxy/sliding_window_parity_test.go | 66 ++++------- internal/proxy/sliding_window_stats.go | 3 - internal/proxy/state.go | 1 - internal/proxy/tumbling_axis_parity_test.go | 48 ++++---- .../unwrap_ungrouped_sum_differential_test.go | 85 +++++++++----- 17 files changed, 256 insertions(+), 299 deletions(-) diff --git a/internal/proxy/backend.go b/internal/proxy/backend.go index 25f8db992..4fc480c43 100644 --- a/internal/proxy/backend.go +++ b/internal/proxy/backend.go @@ -145,6 +145,16 @@ func (p *Proxy) observeBackendVersionFromHeaders(headers http.Header) { p.storeBackendVersion(candidate, semver) return } + // No version in the response: while it stays unknown (failed or pending + // startup probe) retry the metrics probe in the background. An unknown + // version behaves as the v1.49.0 floor; only the v1.50+ behaviour waits for + // the probe. + p.backendVersionMu.RLock() + known := p.backendVersionSemver != "" + p.backendVersionMu.RUnlock() + if !known { + p.maybeReprobeBackendVersion(time.Now()) + } } func extractBackendSemver(raw string) string { @@ -257,20 +267,19 @@ type backendCapabilities struct { profile string supportsStreamMetadata bool supportsDensePatternWin bool - supportsMetadataSubstring bool supportsColumnFieldValues bool // v1.50+: stream fields stored in column index; field_values == stream_field_values but 20x faster } func deriveBackendCapabilities(semver string) backendCapabilities { switch { case semverAtLeast(semver, 1, 50, 0): - return backendCapabilities{"vl-v1.50-plus", true, true, true, true} + return backendCapabilities{"vl-v1.50-plus", true, true, true} case semverAtLeast(semver, 1, 49, 0): - return backendCapabilities{"vl-v1.49-plus", true, false, true, false} + return backendCapabilities{"vl-v1.49-plus", true, false, false} case semverAtLeast(semver, 1, 30, 0): - return backendCapabilities{"vl-v1.30-plus", true, false, false, false} + return backendCapabilities{"vl-v1.30-plus", true, false, false} default: - return backendCapabilities{"legacy-pre-v1.30", false, false, false, false} + return backendCapabilities{"legacy-pre-v1.30", false, false, false} } } @@ -290,9 +299,7 @@ func (p *Proxy) storeBackendVersion(raw, semver string) { p.backendCapabilityProfile = caps.profile p.backendSupportsStreamMetadata = caps.supportsStreamMetadata p.backendSupportsDensePatternWindowing = caps.supportsDensePatternWin - p.backendSupportsMetadataSubstring = caps.supportsMetadataSubstring p.backendSupportsColumnFieldValues = caps.supportsColumnFieldValues - p.backendSupportsStatsRangeOffset = semverAtLeast(semver, 1, 45, 0) if !p.backendVersionLogged { p.backendVersionLogged = true p.log.Info( @@ -301,7 +308,6 @@ func (p *Proxy) storeBackendVersion(raw, semver string) { "backend.version.semver", semver, "backend.capability_profile", p.backendCapabilityProfile, "metadata.stream_endpoints", p.backendSupportsStreamMetadata, - "metadata.substring_filter", p.backendSupportsMetadataSubstring, "patterns.dense_windowing", p.backendSupportsDensePatternWindowing, "field_values.column_index", p.backendSupportsColumnFieldValues, "service_name.derivation", serviceNameDerivationForm(logsql.CapabilitiesFor(semver)), @@ -372,12 +378,6 @@ func (p *Proxy) supportsDensePatternWindowingForRequest(r *http.Request) bool { return true } -func (p *Proxy) supportsMetadataSubstringFilter() bool { - p.backendVersionMu.RLock() - defer p.backendVersionMu.RUnlock() - return p.backendSupportsMetadataSubstring -} - // supportsColumnIndexedFields returns true on VL v1.50+ where stream fields are // stored in the column index. On these backends field_values is equivalent to // stream_field_values but ~20x faster, so we prefer it for label value lookups. @@ -391,24 +391,6 @@ func (p *Proxy) supportsColumnIndexedFields() bool { // version is still unknown. const backendVersionReprobeInterval = 5 * time.Minute -// supportsStatsRangeOffset reports whether stats_query_range honours the offset -// arg (VictoriaLogs v1.45+). Older releases silently ignore it and align buckets -// to the epoch. An unknown version (failed or pending probe) is treated as -// supported: the arg is then ignored rather than rejected, which is no worse -// than the epoch-aligned buckets such a backend has anyway, while buffering raw -// log rows instead would scale the query with the stored data. While the version -// is unknown, the metrics probe is retried in the background. -func (p *Proxy) supportsStatsRangeOffset() bool { - p.backendVersionMu.RLock() - known, supported := p.backendVersionSemver != "", p.backendSupportsStatsRangeOffset - p.backendVersionMu.RUnlock() - if !known { - p.maybeReprobeBackendVersion(time.Now()) - return true - } - return supported -} - // maybeReprobeBackendVersion starts at most one background metrics probe per // backendVersionReprobeInterval, bounded by the version check timeout, so a // startup probe that failed (backend not ready, /metrics not routed) does not @@ -465,7 +447,6 @@ func (p *Proxy) storeBackendCapabilityProbe(source string, supportsStreamMetadat p.backendCapabilityProfile = "legacy-probed-pre-v1.30" } p.backendSupportsStreamMetadata = supportsStreamMetadata - p.backendSupportsMetadataSubstring = supportsMetadataSubstring // Keep dense-windowing and column-field-values conservative without explicit semver evidence. p.backendSupportsDensePatternWindowing = false p.backendSupportsColumnFieldValues = false @@ -476,7 +457,6 @@ func (p *Proxy) storeBackendCapabilityProbe(source string, supportsStreamMetadat "backend.version.source", source, "backend.capability_profile", p.backendCapabilityProfile, "metadata.stream_endpoints", p.backendSupportsStreamMetadata, - "metadata.substring_filter", p.backendSupportsMetadataSubstring, "patterns.dense_windowing", p.backendSupportsDensePatternWindowing, ) } diff --git a/internal/proxy/backend_test.go b/internal/proxy/backend_test.go index 9cba40c11..9ad5fb6d7 100644 --- a/internal/proxy/backend_test.go +++ b/internal/proxy/backend_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "time" @@ -321,3 +322,41 @@ func TestBackendVersionCompatibilityGate_DefaultFloorIsV149(t *testing.T) { }) } } + +// A response without a version header retries the /metrics version probe only +// while the version is unknown: once it is known, the probe never runs again. +func TestBackendVersionReprobe_OnlyWhileVersionUnknown(t *testing.T) { + var probes atomic.Int32 + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/metrics" { + probes.Add(1) + } + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + waitProbes := func(want int32) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for probes.Load() < want && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + time.Sleep(50 * time.Millisecond) // let a probe that should not run show up + if got := probes.Load(); got != want { + t.Fatalf("expected %d /metrics probes, got %d", want, got) + } + } + elapsed := func(p *Proxy) { + p.backendVersionProbedAt.Store(time.Now().Add(-backendVersionReprobeInterval).UnixNano()) + } + + p := newTestProxy(t, backend.URL) + elapsed(p) + p.observeBackendVersionFromHeaders(http.Header{"X-Other": {"1"}}) + waitProbes(1) + + p.storeBackendVersion("VictoriaLogs/v1.52.0", "v1.52.0") + elapsed(p) + p.observeBackendVersionFromHeaders(http.Header{"X-Other": {"1"}}) + waitProbes(1) +} diff --git a/internal/proxy/bare_parser_sliding_parity_test.go b/internal/proxy/bare_parser_sliding_parity_test.go index 263a170a8..b8a001bf7 100644 --- a/internal/proxy/bare_parser_sliding_parity_test.go +++ b/internal/proxy/bare_parser_sliding_parity_test.go @@ -215,40 +215,31 @@ func TestBareParserRangeMetric_GapsAndEmptyLines(t *testing.T) { } } -// VictoriaLogs before v1.45 ignores the offset arg, and an unknown version is -// treated the same: epoch-aligned grids keep buckets without offset (fixture -// lines sit off the window edges), other grids use the raw evaluator. -func TestBareParserRangeMetric_BackendWithoutOffsetSupport(t *testing.T) { +// Bucket edges come from the stats_query_range offset arg on every supported +// backend, and an unknown version (failed or pending probe) behaves as the +// v1.49.0 floor: epoch-aligned and unaligned grids alike use anchored buckets, +// never the raw evaluator. +func TestBareParserRangeMetric_AnchoredBucketsOnFloorAndUnknownVersion(t *testing.T) { s0 := time.Unix(1700000400, 0).UTC() lines := tickLines(s0, 3*time.Second) aligned, alignedEnd := s0.Add(5*time.Minute), s0.Add(20*time.Minute) unaligned, unalignedEnd := s0.Add(5*time.Minute+30*time.Second), s0.Add(20*time.Minute+30*time.Second) - for _, version := range []string{"v1.44.0", ""} { + for _, version := range []string{"v1.49.0", ""} { for _, streamFields := range [][]string{nil, {"app"}} { t.Run(fmt.Sprintf("version=%q/streamFields=%v", version, streamFields), func(t *testing.T) { countRoute := "stats" if streamFields != nil { countRoute = "hits" } - // An unknown version (failed or pending probe) keeps anchored - // buckets: a backend that ignores the arg has the epoch-aligned - // buckets it would have had anyway, while raw rows would scale - // the query with the stored data. - unalignedRoute, unalignedStep, unalignedOffset := "raw", "", "" - alignedOffset, gcdOffset := "none", "" - if version == "" { - unalignedRoute, unalignedStep, unalignedOffset = "stats", "60s", "-30000000001ns" - alignedOffset, gcdOffset = "-1ns", "-10000000001ns" - } runBareParserCases(t, lines, version, streamFields, []bareParserCase{ - {query: `count_over_time({app=~"bare-.*"} | logfmt [2m])`, fn: "count_over_time", window: 2 * time.Minute, start: aligned, end: alignedEnd, route: countRoute, wantStep: "60s", wantOffset: alignedOffset}, - {query: `count_over_time({app=~"bare-.*"} | logfmt [2m])`, fn: "count_over_time", window: 2 * time.Minute, start: unaligned, end: unalignedEnd, route: map[bool]string{true: countRoute, false: "raw"}[version == ""], wantStep: unalignedStep, wantOffset: unalignedOffset}, - {query: `bytes_over_time({app=~"bare-.*"} | logfmt [1m])`, fn: "bytes_over_time", window: time.Minute, start: aligned, end: alignedEnd, route: "stats", wantStep: "60s", wantOffset: alignedOffset}, - {query: `bytes_over_time({app=~"bare-.*"} | logfmt [90s])`, fn: "bytes_over_time", window: 90 * time.Second, start: s0.Add(5*time.Minute + 10*time.Second), end: alignedEnd, route: map[bool]string{true: "stats", false: "raw"}[version == ""], wantStep: map[bool]string{true: "30s", false: ""}[version == ""], wantOffset: gcdOffset}, + {query: `count_over_time({app=~"bare-.*"} | logfmt [2m])`, fn: "count_over_time", window: 2 * time.Minute, start: aligned, end: alignedEnd, route: countRoute, wantStep: "60s", wantOffset: "-1ns"}, + {query: `count_over_time({app=~"bare-.*"} | logfmt [2m])`, fn: "count_over_time", window: 2 * time.Minute, start: unaligned, end: unalignedEnd, route: countRoute, wantStep: "60s", wantOffset: "-30000000001ns"}, + {query: `bytes_over_time({app=~"bare-.*"} | logfmt [1m])`, fn: "bytes_over_time", window: time.Minute, start: aligned, end: alignedEnd, route: "stats", wantStep: "60s", wantOffset: "-1ns"}, + {query: `bytes_over_time({app=~"bare-.*"} | logfmt [90s])`, fn: "bytes_over_time", window: 90 * time.Second, start: s0.Add(5*time.Minute + 10*time.Second), end: alignedEnd, route: "stats", wantStep: "30s", wantOffset: "-10000000001ns"}, }) runBareParserCases(t, numberLines(lines), version, streamFields, []bareParserCase{ - {query: `sum_over_time({app=~"bare-.*"} | logfmt | unwrap n [2m])`, fn: "sum_over_time", window: 2 * time.Minute, start: aligned, end: alignedEnd, route: "stats", wantStep: "60s", wantOffset: alignedOffset}, - {query: `max_over_time({app=~"bare-.*"} | logfmt | unwrap n [2m])`, fn: "max_over_time", window: 2 * time.Minute, start: unaligned, end: unalignedEnd, route: unalignedRoute, wantStep: unalignedStep, wantOffset: unalignedOffset}, + {query: `sum_over_time({app=~"bare-.*"} | logfmt | unwrap n [2m])`, fn: "sum_over_time", window: 2 * time.Minute, start: aligned, end: alignedEnd, route: "stats", wantStep: "60s", wantOffset: "-1ns"}, + {query: `max_over_time({app=~"bare-.*"} | logfmt | unwrap n [2m])`, fn: "max_over_time", window: 2 * time.Minute, start: unaligned, end: unalignedEnd, route: "stats", wantStep: "60s", wantOffset: "-30000000001ns"}, }) }) } diff --git a/internal/proxy/drilldown_burst_coalescer.go b/internal/proxy/drilldown_burst_coalescer.go index 707c31ea7..797da0bd6 100644 --- a/internal/proxy/drilldown_burst_coalescer.go +++ b/internal/proxy/drilldown_burst_coalescer.go @@ -230,7 +230,7 @@ func (p *Proxy) fusedFieldHits( params := url.Values{} params.Set("query", fusedQuery) - p.setSlidingStatsRangeParams(params, start, end, step) + setSlidingStatsRangeParams(params, start, end, step) // Acquire the same concurrency slot used by individual stats_query_range calls // so burst-fused calls don't bypass the back-pressure contract. diff --git a/internal/proxy/label_metadata.go b/internal/proxy/label_metadata.go index 1bedf18a2..618992d64 100644 --- a/internal/proxy/label_metadata.go +++ b/internal/proxy/label_metadata.go @@ -188,7 +188,7 @@ func (p *Proxy) metadataQueryParams(ctx context.Context, candidate, start, end, if strings.TrimSpace(limit) != "" { params.Set("limit", limit) } - if search = strings.TrimSpace(search); search != "" && p.supportsMetadataSubstringFilter() { + if search = strings.TrimSpace(search); search != "" { params.Set("q", search) params.Set("filter", "substring") } diff --git a/internal/proxy/metric_binary.go b/internal/proxy/metric_binary.go index 7eb839951..cae12664a 100644 --- a/internal/proxy/metric_binary.go +++ b/internal/proxy/metric_binary.go @@ -72,11 +72,8 @@ func (p *Proxy) proxyStatsQueryRange(w http.ResponseWriter, r *http.Request, log // Range == step: Loki's sample at T covers (T-W, T] while VL's bucket // labelled T covers [T, T+W). Fetch from start-W on a grid whose buckets are // (edge, edge+W] and relabel every bucket to its Loki evaluation timestamp - // (relabelSnappedTumblingStatsQueryRange). A backend known to be without the - // offset arg has only epoch-aligned buckets, which match no window of an - // unaligned start; tumblingBucketsAligned sends those requests to the window - // evaluator instead. - if origSpec, origStartNs, ok := statsRateRangeEqualsStepShift(originalLogql, r); ok && p.tumblingBucketsAligned(r, origSpec.Window) { + // (relabelSnappedTumblingStatsQueryRange). + if origSpec, origStartNs, ok := statsRateRangeEqualsStepShift(originalLogql, r); ok { buf := &bufferedResponseWriter{} shiftedR := r.Clone(r.Context()) _ = shiftedR.ParseForm() @@ -133,8 +130,8 @@ func (p *Proxy) proxyStatsQueryRangeDirect(w http.ResponseWriter, r *http.Reques } // proxyStatsQueryRangeDirectAnchored is proxyStatsQueryRangeDirect with an -// optional tumbling window. When tumblingWindow > 0 and the backend honours the -// offset arg, buckets of that width are anchored at the request start with +// optional tumbling window. When tumblingWindow > 0, buckets of that width are +// anchored at the request start (stats_query_range offset arg) with // Loki's (edge, edge+window] boundaries: the start is exclusive and the end // inclusive. // @@ -297,11 +294,11 @@ func (p *Proxy) fetchStatsQueryRangeBody(r *http.Request, logsqlQuery string, tu // Keep metric query_range as a single backend request. Window splitting and // window-level cache reuse are for raw log queries only. params := buildStatsQueryRangeParams(logsqlQuery, r.FormValue("start"), r.FormValue("end"), r.FormValue("step")) - if tumblingWindow > 0 && p.supportsStatsRangeOffset() { + if tumblingWindow > 0 { startNs, startOK := parseLokiTimeToUnixNano(r.FormValue("start")) endNs, endOK := parseLokiTimeToUnixNano(r.FormValue("end")) if startOK && endOK { - p.setSlidingStatsRangeParams(params, time.Unix(0, startNs), time.Unix(0, endNs), tumblingWindow) + setSlidingStatsRangeParams(params, time.Unix(0, startNs), time.Unix(0, endNs), tumblingWindow) // The first window is (start, start+window]: a line exactly at start // belongs to the window before, which no sample covers. params.Set("start", time.Unix(0, startNs+1).UTC().Format(time.RFC3339Nano)) diff --git a/internal/proxy/ordered_json_extracted_pushdown_test.go b/internal/proxy/ordered_json_extracted_pushdown_test.go index 86e6807c1..4c01c6be2 100644 --- a/internal/proxy/ordered_json_extracted_pushdown_test.go +++ b/internal/proxy/ordered_json_extracted_pushdown_test.go @@ -210,27 +210,23 @@ func TestOrderedJSONExtractedMetricStructuredMetadataCollision(t *testing.T) { } } -// The name_extracted pushdown needs the stats range offset arg (VictoriaLogs -// v1.45+) for its bucket edges; an older backend keeps the raw-row evaluator -// these shapes always had, with the same answer, and an unknown version -// stays on the pushdown. +// The name_extracted pushdown runs on every supported backend and on an unknown +// version (which behaves as the v1.49.0 floor): the stats range offset arg +// places its bucket edges, so it never falls back to the raw-row evaluator. // conformance: parser-error-and-label-collision, semantics/json-extracted-metric-pushdown, versions/stats-bucket-label-v1.45 -func TestOrderedJSONExtractedMetricPushdownNeedsStatsRangeOffset(t *testing.T) { +func TestOrderedJSONExtractedMetricPushdownOnSupportedBackends(t *testing.T) { s0 := time.Unix(1700000400, 0).UTC() rows := extractedPushdownFixture(s0) start, end := s0.Add(time.Minute), s0.Add(10*time.Minute) query := `sum by (level) (count_over_time({app="api"} | json | level_extracted!="" [1m]))` - for _, tc := range []struct { - version string - pushed bool - }{{"", true}, {"v1.40.0", false}, {"v1.44.2", false}, {"v1.45.0", true}, {"v1.52.0", true}} { - t.Run("backend "+tc.version, func(t *testing.T) { + for _, version := range []string{"", "v1.49.0", "v1.52.0"} { + t.Run("backend "+version, func(t *testing.T) { srv, fake := newPushdownFakeVL(t, rows, nil) p := newFilterPushdownProxy(t, srv.URL) fake.stored = p.labelTranslator.ToVL // newSlidingTestProxy stored v1.50.0 first and the first version wins. p.backendVersionMu.Lock() - p.backendVersionSemver, p.backendSupportsStatsRangeOffset = tc.version, tc.version != "" && semverAtLeast(tc.version, 1, 45, 0) + p.backendVersionSemver = version p.backendVersionMu.Unlock() plan, ok := compileOrderedJSONMetric(query) if !ok || !plan.pushdown { @@ -243,12 +239,9 @@ func TestOrderedJSONExtractedMetricPushdownNeedsStatsRangeOffset(t *testing.T) { } fake.mu.Lock() defer fake.mu.Unlock() - if tc.pushed && (fake.raw != 0 || len(fake.stats) != 1) { + if fake.raw != 0 || len(fake.stats) != 1 { t.Fatalf("expected the stats pushdown: raw=%d stats=%d", fake.raw, len(fake.stats)) } - if !tc.pushed && (fake.raw != 1 || len(fake.stats) != 0) { - t.Fatalf("expected the raw evaluator below v1.45: raw=%d stats=%d", fake.raw, len(fake.stats)) - } }) } } diff --git a/internal/proxy/ordered_json_metric.go b/internal/proxy/ordered_json_metric.go index ee438fafc..ae3358b32 100644 --- a/internal/proxy/ordered_json_metric.go +++ b/internal/proxy/ordered_json_metric.go @@ -958,13 +958,6 @@ func (p *Proxy) orderedJSONStatsBuckets(ctx context.Context, plan *orderedJSONMe // and the parse-risk probes read both keys, the probes with the stored // value of name ignored because the scratch unpack reads the body anyway. plain, allKeys, extracted, extractedKeys := splitPushdownKeys(unpack) - // The name_extracted pushdown needs the bucket edges of the stats range - // offset arg (VictoriaLogs v1.45+): without it a line on a bucket edge - // lands one bucket late, so older backends keep the raw evaluator these - // shapes always had (versions/stats-bucket-label-v1.45). - if len(extracted) > 0 && !p.supportsStatsRangeOffset() { - return nil, false, false, nil - } // A stream label or structured metadata VictoriaLogs stores under another // spelling (service_version as service.version) is read from that field: // Loki gives such a label precedence over a parsed key of the same name. @@ -974,7 +967,7 @@ func (p *Proxy) orderedJSONStatsBuckets(ctx context.Context, plan *orderedJSONMe stored[field] = vl } } - bucket, ok := p.slidingStatsBucket(start, step, plan.window) + bucket, ok := slidingStatsBucket(step, plan.window) if !ok { return nil, false, false, nil } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index e89508d2c..561b65dc4 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -672,9 +672,7 @@ type Proxy struct { backendCapabilityProfile string backendSupportsStreamMetadata bool backendSupportsDensePatternWindowing bool - backendSupportsMetadataSubstring bool backendSupportsColumnFieldValues bool - backendSupportsStatsRangeOffset bool backendVersionProbedAt atomic.Int64 // Unix ns of the last metrics version probe backendVersionLogged bool labelValuesIndexWarmReady atomic.Bool @@ -1471,7 +1469,6 @@ func New(cfg Config) (*Proxy, error) { backendCapabilityProfile: &p.backendCapabilityProfile, backendSupportsStreamMetadata: &p.backendSupportsStreamMetadata, backendSupportsDensePatternWindowing: &p.backendSupportsDensePatternWindowing, - backendSupportsMetadataSubstring: &p.backendSupportsMetadataSubstring, backendVersionLogged: &p.backendVersionLogged, labelValuesIndexMu: &p.labelValuesIndexMu, labelValuesIndex: p.labelValuesIndex, diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 4499ee213..d7e397dec 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -290,43 +290,6 @@ func TestContract_LabelValues_ForwardsSubstringFilter_OnV149Plus(t *testing.T) { } } -func TestContract_LabelValues_DoesNotForwardSubstringFilter_OnV148(t *testing.T) { - var receivedQ, receivedFilter string - vlBackend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/select/logsql/field_names": - writeVLFieldNames(w, []fieldHit{{"app", 1}}) - case "/select/logsql/stream_field_names": - writeVLFieldNames(w, []fieldHit{{"app", 1}}) - case "/select/logsql/stream_field_values": - // v1.48 supports stream_field_values (stream endpoints exist on every supported version) - // but NOT the substring filter=substring param (added in v1.49). - receivedQ = r.URL.Query().Get("q") - receivedFilter = r.URL.Query().Get("filter") - writeVLFieldValues(w, []fieldHit{{"argocd", 1}}) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer vlBackend.Close() - - p := newTestProxy(t, vlBackend.URL) - h := http.Header{} - h.Set("Server", "VictoriaLogs/v1.48.0") - p.observeBackendVersionFromHeaders(h) - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/loki/api/v1/label/app/values?search=arg", nil) - p.handleLabelValues(w, r) - - if receivedQ != "" { - t.Fatalf("expected q to stay unset for VictoriaLogs < v1.49.0, got %q", receivedQ) - } - if receivedFilter != "" { - t.Fatalf("expected filter to stay unset for VictoriaLogs < v1.49.0, got %q", receivedFilter) - } -} - func TestContract_LabelValues_JoinsAmbiguousStreamAliases(t *testing.T) { vlBackend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -1493,32 +1456,11 @@ func TestBackendVersionDetection_OlderVersionKeepsConservativeProfile(t *testing if !p.supportsStreamMetadataEndpoints() { t.Fatalf("expected stream metadata endpoints enabled for VictoriaLogs >= v1.30.0") } - if !p.supportsMetadataSubstringFilter() { - t.Fatalf("expected metadata substring filter enabled for VictoriaLogs >= v1.49.0") - } if got := p.backendCapabilityProfile; got != "vl-v1.49-plus" { t.Fatalf("expected capability profile vl-v1.49-plus, got %q", got) } } -func TestBackendVersionDetection_V148KeepsSubstringFilterDisabled(t *testing.T) { - p := newTestProxy(t, "http://unused") - - h := http.Header{} - h.Set("Server", "VictoriaLogs/v1.48.0") - p.observeBackendVersionFromHeaders(h) - - if got := p.backendVersionSemver; got != "v1.48.0" { - t.Fatalf("expected semver v1.48.0 from headers, got %q", got) - } - if p.supportsMetadataSubstringFilter() { - t.Fatalf("expected metadata substring filter disabled for VictoriaLogs < v1.49.0") - } - if got := p.backendCapabilityProfile; got != "vl-v1.30-plus" { - t.Fatalf("expected capability profile vl-v1.30-plus, got %q", got) - } -} - func TestBackendVersionDetection_LegacyVersionDisablesStreamMetadataFastPath(t *testing.T) { p := newTestProxy(t, "http://unused") @@ -1540,6 +1482,53 @@ func TestBackendVersionDetection_LegacyVersionDisablesStreamMetadataFastPath(t * } } +// An unknown backend version (no header, no /metrics) behaves as the v1.49.0 +// floor: the metadata substring filter is forwarded to VictoriaLogs. +func TestMetadataQueryParams_UnknownVersionForwardsSubstringFilter(t *testing.T) { + for _, version := range []string{"", "v1.49.0", "v1.52.0"} { + t.Run("backend "+version, func(t *testing.T) { + p := newTestProxy(t, "http://unused") + if version != "" { + p.storeBackendVersion(version, version) + } + params, err := p.metadataQueryParams(context.Background(), `{app="api"}`, "", "", "", " err ") + if err != nil { + t.Fatalf("metadataQueryParams: %v", err) + } + if params.Get("q") != "err" || params.Get("filter") != "substring" { + t.Fatalf("expected q=err and filter=substring, got %v", params) + } + params, err = p.metadataQueryParams(context.Background(), `{app="api"}`, "", "", "", "") + if err != nil { + t.Fatalf("metadataQueryParams: %v", err) + } + if _, ok := params["q"]; ok || params.Get("filter") != "" { + t.Fatalf("expected no q/filter without a search, got %v", params) + } + }) + } +} + +// ip() label filters translate to the native ipv4_range() whatever backend +// version is known, an unknown one included. +func TestTranslateIPFilter_UnknownVersionEmitsIPv4Range(t *testing.T) { + for _, version := range []string{"", "v1.49.0", "v1.52.0"} { + t.Run("backend "+version, func(t *testing.T) { + p := newTestProxy(t, "http://unused") + if version != "" { + p.storeBackendVersion(version, version) + } + got, err := p.translateQueryWithContext(context.Background(), `{app="api"} | addr = ip("10.0.0.0/8")`) + if err != nil { + t.Fatalf("translate: %v", err) + } + if !strings.Contains(got, "ipv4_range(10.0.0.0, 10.255.255.255)") { + t.Fatalf("expected ipv4_range(), got %q", got) + } + }) + } +} + func TestBackendVersionCompatibilityGate_BlocksTooOldVersion(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/health" { @@ -1722,9 +1711,6 @@ func TestBackendVersionCompatibilityGate_EndpointProbeInfersCapabilities(t *test if !p.supportsStreamMetadataEndpoints() { t.Fatalf("expected stream metadata support inferred via endpoint probe") } - if !p.supportsMetadataSubstringFilter() { - t.Fatalf("expected metadata substring support inferred via endpoint probe") - } if p.supportsDensePatternWindowing() { t.Fatalf("expected dense windowing to remain conservative without explicit semver") } diff --git a/internal/proxy/query_translation.go b/internal/proxy/query_translation.go index d4fa18787..3cf3e2b6b 100644 --- a/internal/proxy/query_translation.go +++ b/internal/proxy/query_translation.go @@ -1253,7 +1253,7 @@ func (p *Proxy) fetchBareParserMetricSeriesViaHits( spec bareParserMetricCompatSpec, evalStart, evalEnd, stepNs int64, ) (series map[string]manualSeriesSamples, ok bool, err error) { - bucket, ok := p.slidingStatsBucket(time.Unix(0, evalStart), time.Duration(stepNs), spec.rangeWindow) + bucket, ok := slidingStatsBucket(time.Duration(stepNs), spec.rangeWindow) if !ok { return nil, false, nil } @@ -1266,7 +1266,7 @@ func (p *Proxy) fetchBareParserMetricSeriesViaHits( fetchStart := time.Unix(0, evalStart).Add(-spec.rangeWindow) params := url.Values{} params.Set("query", logsqlQuery) - p.setSlidingStatsRangeParams(params, fetchStart, time.Unix(0, evalEnd), bucket) + setSlidingStatsRangeParams(params, fetchStart, time.Unix(0, evalEnd), bucket) // Group by declared stream label fields so we get per-stream series. p.configMu.RLock() @@ -1340,7 +1340,7 @@ func (p *Proxy) fetchBareParserStatsBuckets( statsAggFunc, pre string, evalStart, evalEnd, stepNs int64, ) (seriesMap map[string]manualSeriesSamples, ok bool, err error) { - bucket, ok := p.slidingStatsBucket(time.Unix(0, evalStart), time.Duration(stepNs), spec.rangeWindow) + bucket, ok := slidingStatsBucket(time.Duration(stepNs), spec.rangeWindow) if !ok { return nil, false, nil } @@ -1360,7 +1360,7 @@ func (p *Proxy) fetchBareParserStatsBuckets( } params := url.Values{} params.Set("query", logsqlQuery+pre+" | stats by ("+group+") "+statsAggFunc) - p.setSlidingStatsRangeParams(params, fetchStart, time.Unix(0, evalEnd), bucket) + setSlidingStatsRangeParams(params, fetchStart, time.Unix(0, evalEnd), bucket) resp, err := p.vlPost(ctx, "/select/logsql/stats_query_range", params) if err != nil { diff --git a/internal/proxy/range_metric_compat.go b/internal/proxy/range_metric_compat.go index 02e21e245..1b606b062 100644 --- a/internal/proxy/range_metric_compat.go +++ b/internal/proxy/range_metric_compat.go @@ -388,7 +388,7 @@ func (p *Proxy) handleStatsCompatRange(w http.ResponseWriter, r *http.Request, o if strings.Contains(queryWithoutUnwrapGate(logsqlQuery), "| math ") { step, stepOk := parsePositiveStepDuration(r.FormValue("step")) origSpec, hasOrigSpec := parseOriginalRangeMetricSpec(originalLogql) - if stepOk && hasOrigSpec && origSpec.Window > 0 && p.statsRangeIsTumbling(r, origSpec.Window, step) { + if stepOk && hasOrigSpec && origSpec.Window > 0 && statsRangeIsTumbling(origSpec.Window, step) { spec, specOk := parseStatsCompatSpec(logsqlQuery) // Parser stages without an explicit drop-error opt-in require the manual path // to preserve Loki's error-exclusion semantics. Use origSpec.BaseQuery (the inner @@ -418,7 +418,7 @@ func (p *Proxy) handleStatsCompatRange(w http.ResponseWriter, r *http.Request, o return false } step, _ := parsePositiveStepDuration(r.FormValue("step")) - rangeEqualsStep := p.statsRangeIsTumbling(r, origSpec.Window, step) + rangeEqualsStep := statsRangeIsTumbling(origSpec.Window, step) // For tumbling windows, an explicit "| drop __error__" in the original LogQL opts in to // VL's count-all semantics (parse failures counted). Use origSpec.BaseQuery — the inner // pipeline without outer aggregation or range brackets — so hasDropErrorOnlyPostParserStage @@ -452,26 +452,13 @@ func (p *Proxy) handleStatsCompatRange(w http.ResponseWriter, r *http.Request, o // statsRangeIsTumbling reports whether native step buckets answer a range // metric: only when the range equals the step, where one VictoriaLogs bucket is -// exactly one Loki window, and the backend can place bucket edges on the -// request's windows (tumblingBucketsAligned). With range < step a bucket also -// holds the lines between windows, which Loki never counts, and with range > -// step the windows overlap; both use the anchored window evaluator, for Grafana -// Logs Drilldown requests too. -func (p *Proxy) statsRangeIsTumbling(r *http.Request, window, step time.Duration) bool { - return step > 0 && window > 0 && window == step && p.tumblingBucketsAligned(r, window) -} - -// tumblingBucketsAligned reports whether stats_query_range buckets of window can -// cover the request's evaluation windows: whenever the backend honours the -// offset arg (VictoriaLogs v1.45+, and an unknown version, which ignores the arg -// at worst), otherwise only when the start is epoch-aligned to window, like -// slidingStatsBucket. -func (p *Proxy) tumblingBucketsAligned(r *http.Request, window time.Duration) bool { - if p.supportsStatsRangeOffset() { - return true - } - startNs, ok := parseLokiTimeToUnixNano(r.FormValue("start")) - return ok && window > 0 && startNs%int64(window) == 0 +// exactly one Loki window (the stats_query_range offset arg places the bucket +// edges on the request's windows). With range < step a bucket also holds the +// lines between windows, which Loki never counts, and with range > step the +// windows overlap; both use the anchored window evaluator, for Grafana Logs +// Drilldown requests too. +func statsRangeIsTumbling(window, step time.Duration) bool { + return step > 0 && window > 0 && window == step } func (p *Proxy) handleStatsCompatInstant(w http.ResponseWriter, r *http.Request, originalLogql, logsqlQuery string) bool { @@ -682,7 +669,7 @@ func (p *Proxy) proxyManualRangeMetricRange(w http.ResponseWriter, r *http.Reque // fetchStart is the left edge of the first bucket (or raw fetch), and // sampleShift moves bucket labels onto the window they hold. fetchStart, sampleShift := startTS.Add(-origSpec.Window), time.Duration(0) - bucket, bucketsOK := p.slidingStatsBucket(startTS, step, origSpec.Window) + bucket, bucketsOK := slidingStatsBucket(step, origSpec.Window) if origSpec.Window < step { // Windows shorter than the step are disjoint. Keep only lines inside one, // so a bucket per step, (t-step, t], holds exactly the window (t-range, t] @@ -692,7 +679,7 @@ func (p *Proxy) proxyManualRangeMetricRange(w http.ResponseWriter, r *http.Reque spec.BaseQuery += windowPhaseFilter(startTS, step, origSpec.Window) rawSpec.BaseQuery += windowPhaseFilter(startTS, step, origSpec.Window) fetchStart, sampleShift = startTS.Add(-step), step-origSpec.Window - bucket, bucketsOK = p.slidingStatsBucket(startTS, step, step) + bucket, bucketsOK = slidingStatsBucket(step, step) } if p.slidingWindowStatsApplies(r.Context(), spec, field, startTS, endTS, step, origSpec.Window, bucket, bucketsOK) { p.writeSlidingWindowStatsRange(w, r.Context(), spec, manualFunc, field, startTS, endTS, step, origSpec.Window) @@ -1124,10 +1111,8 @@ func (p *Proxy) resolveManualMetricField(spec statsCompatSpec, origSpec original // lines the raw evaluator would transfer, and collectRangeMetricHits already // bounds the response bytes (an oversized response falls back to the raw // evaluator). ok is false below a 1ms bucket, where float response timestamps -// cannot be snapped reliably, and when the backend cannot anchor bucket edges to -// the request (stats_query_range offset, VictoriaLogs v1.45+) and the anchor is -// not epoch-aligned; callers then use the raw-sample evaluator. -func (p *Proxy) slidingStatsBucket(start time.Time, step, window time.Duration) (time.Duration, bool) { +// cannot be snapped reliably; callers then use the raw-sample evaluator. +func slidingStatsBucket(step, window time.Duration) (time.Duration, bool) { bucket, rest := step, window for rest > 0 { bucket, rest = rest, bucket%rest @@ -1135,18 +1120,15 @@ func (p *Proxy) slidingStatsBucket(start time.Time, step, window time.Duration) if bucket < time.Millisecond { return 0, false } - if !p.supportsStatsRangeOffset() && start.Add(-window).UnixNano()%int64(bucket) != 0 { - return 0, false - } return bucket, true } // setSlidingStatsRangeParams requests buckets of hitStep whose edges sit at -// start+k*hitStep. With offset support each bucket is (edge, edge+hitStep], -// Loki's left-open, right-closed range boundary: VictoriaLogs buckets cover -// [T, T+step) with edges at k*step-offset, so edges are shifted by 1ns. The end -// is exclusive in VictoriaLogs and inclusive in Loki, hence end+1ns. -func (p *Proxy) setSlidingStatsRangeParams(params url.Values, start, end time.Time, hitStep time.Duration) { +// start+k*hitStep. Each bucket is (edge, edge+hitStep], Loki's left-open, +// right-closed range boundary: VictoriaLogs buckets cover [T, T+step) with edges +// at k*step-offset, so edges are shifted by 1ns. The end is exclusive in +// VictoriaLogs and inclusive in Loki, hence end+1ns. +func setSlidingStatsRangeParams(params url.Values, start, end time.Time, hitStep time.Duration) { params.Set("start", start.UTC().Format(time.RFC3339Nano)) params.Set("end", end.Add(time.Nanosecond).UTC().Format(time.RFC3339Nano)) if hitStep%time.Second == 0 { @@ -1154,10 +1136,8 @@ func (p *Proxy) setSlidingStatsRangeParams(params url.Values, start, end time.Ti } else { params.Set("step", strconv.FormatInt(int64(hitStep), 10)+"ns") } - if p.supportsStatsRangeOffset() { - shift := ((start.UnixNano()+1)%int64(hitStep) + int64(hitStep)) % int64(hitStep) - params.Set("offset", strconv.FormatInt(-shift, 10)+"ns") - } + shift := ((start.UnixNano()+1)%int64(hitStep) + int64(hitStep)) % int64(hitStep) + params.Set("offset", strconv.FormatInt(-shift, 10)+"ns") } // snapSlidingBucketTimestamp maps a stats_query_range timestamp (float seconds) @@ -1274,7 +1254,7 @@ func (p *Proxy) collectRangeMetricHits( params := url.Values{} params.Set("query", statsQuery) - p.setSlidingStatsRangeParams(params, start, end, hitStep) + setSlidingStatsRangeParams(params, start, end, hitStep) // The detected_level chain is narrowed (a probe request) before a slot is // taken, not while holding one. diff --git a/internal/proxy/sliding_window_parity_test.go b/internal/proxy/sliding_window_parity_test.go index 5c28f1a06..e1a9e66e5 100644 --- a/internal/proxy/sliding_window_parity_test.go +++ b/internal/proxy/sliding_window_parity_test.go @@ -649,35 +649,33 @@ func TestSlidingRangeMetric_FineBucketsStayOnStats(t *testing.T) { } } -// VictoriaLogs before v1.45 ignores the stats_query_range offset arg and aligns -// buckets to the epoch, and a failed version probe leaves the release unknown. -// A release known to be older keeps buckets only for epoch-aligned grids (a line -// exactly on a window edge then counts in the neighbouring window, a documented -// limit, so these fixtures keep lines off the edges); other grids use the raw -// evaluator instead of silently misaligned buckets. An unknown version stays on -// buckets: the arg is ignored at worst, while raw rows scale with the stored -// data. This includes the range == step topk path. -func TestSlidingRangeMetric_BackendWithoutOffsetSupport(t *testing.T) { +// Bucket edges come from the stats_query_range offset arg on every supported +// backend, and a failed version probe leaves the release unknown, which behaves +// as the v1.49.0 floor: epoch-aligned and unaligned grids alike use anchored +// buckets and never the raw evaluator. This includes the range == step topk +// path. +func TestSlidingRangeMetric_AnchoredBucketsOnFloorAndUnknownVersion(t *testing.T) { s0 := time.Unix(1700000400, 0).UTC() var lines []slidingFixtureLine for i := 0; i < 180; i++ { - lines = append(lines, slidingFixtureLine{ts: s0.Add(time.Duration(i)*10*time.Second + 3*time.Second).UnixNano(), app: "old-vl", msg: "tick"}) + lines = append(lines, slidingFixtureLine{ts: s0.Add(time.Duration(i)*10*time.Second + 3*time.Second).UnixNano(), app: "floor-vl", msg: "tick"}) } - sliding := `sum(count_over_time({app="old-vl"}[2m]))` - ranked := `topk(1, sum by (app) (count_over_time({app="old-vl"}[1m])))` - for _, version := range []string{"v1.44.0", ""} { + sliding := `sum(count_over_time({app="floor-vl"}[2m]))` + ranked := `topk(1, sum by (app) (count_over_time({app="floor-vl"}[1m])))` + for _, version := range []string{"v1.49.0", ""} { for _, tc := range []struct { name, query string byApp bool window time.Duration start time.Time - unaligned bool - ranked bool // topk: one stats call with in-query _time buckets + wantOffset string }{ - {name: "sliding epoch-aligned grid", query: sliding, window: 2 * time.Minute, start: s0.Add(5 * time.Minute)}, - {name: "sliding unaligned grid", query: sliding, window: 2 * time.Minute, start: s0.Add(5*time.Minute + 30*time.Second), unaligned: true}, - {name: "topk range equals step epoch-aligned grid", query: ranked, byApp: true, window: time.Minute, start: s0.Add(5 * time.Minute), ranked: true}, - {name: "topk range equals step unaligned grid", query: ranked, byApp: true, window: time.Minute, start: s0.Add(5*time.Minute + 30*time.Second), unaligned: true, ranked: true}, + {name: "sliding epoch-aligned grid", query: sliding, window: 2 * time.Minute, start: s0.Add(5 * time.Minute), wantOffset: "-1ns"}, + {name: "sliding unaligned grid", query: sliding, window: 2 * time.Minute, start: s0.Add(5*time.Minute + 30*time.Second), wantOffset: "-30000000001ns"}, + // The topk path buckets inside the query (stats by (_time:...)), so + // its anchor is the positive complement of the request offset. + {name: "topk range equals step epoch-aligned grid", query: ranked, byApp: true, window: time.Minute, start: s0.Add(5 * time.Minute), wantOffset: "59999999999"}, + {name: "topk range equals step unaligned grid", query: ranked, byApp: true, window: time.Minute, start: s0.Add(5*time.Minute + 30*time.Second), wantOffset: "29999999999"}, } { t.Run(fmt.Sprintf("version=%q/%s", version, tc.name), func(t *testing.T) { srv, fake := newSlidingFakeVL(t, lines) @@ -689,34 +687,12 @@ func TestSlidingRangeMetric_BackendWithoutOffsetSupport(t *testing.T) { got := runSlidingQueryRange(t, p, tc.query, tc.start, end, time.Minute, nil) assertSlidingSeriesEqual(t, tc.query, lokiSlidingReference(lines, "count_over_time", tc.byApp, 0, tc.start, end, time.Minute, tc.window), got) calls, raw := fake.snapshot() - if tc.unaligned && version != "" { - if len(calls) != 0 || raw == 0 { - t.Fatalf("known old release, unaligned grid: expected the raw evaluator, got %+v stats calls and %d raw scans", calls, raw) - } - return - } - // The topk path buckets inside the query (stats by (_time:...)), so - // its anchor is the positive complement of the request offset. - wantStep, wantOffset := "60s", "" - if tc.ranked && version == "" { - // In-query _time buckets only where the anchor is honoured; - // a known-old release gets plain epoch buckets. + wantStep := "60s" + if strings.HasPrefix(tc.name, "topk") { wantStep = "1m0s" } - if version == "" { // unknown version: anchored buckets, arg ignored at worst - switch { - case tc.ranked && tc.unaligned: - wantOffset = "29999999999" - case tc.ranked: - wantOffset = "59999999999" - case tc.unaligned: - wantOffset = "-30000000001ns" - default: - wantOffset = "-1ns" - } - } - if raw != 0 || len(calls) != 1 || calls[0].step != wantStep || calls[0].offset != wantOffset { - t.Fatalf("expected one stats call with %s buckets and offset %q, got %+v and %d raw scans", wantStep, wantOffset, calls, raw) + if raw != 0 || len(calls) != 1 || calls[0].step != wantStep || calls[0].offset != tc.wantOffset { + t.Fatalf("expected one stats call with %s buckets and offset %q, got %+v and %d raw scans", wantStep, tc.wantOffset, calls, raw) } }) } diff --git a/internal/proxy/sliding_window_stats.go b/internal/proxy/sliding_window_stats.go index 94646f148..abaad6b08 100644 --- a/internal/proxy/sliding_window_stats.go +++ b/internal/proxy/sliding_window_stats.go @@ -88,9 +88,6 @@ func (p *Proxy) slidingWindowStatsApplies(ctx context.Context, spec statsCompatS return false } } - if !p.supportsStatsRangeOffset() { - return false - } // Ranking every series only matters when grouping can produce more series // than -max-stats-query-series; an aggregate-all topk keeps its fast path. if _, ranked := rangeTopKFromContext(ctx); ranked && len(spec.GroupBy) > 0 { diff --git a/internal/proxy/state.go b/internal/proxy/state.go index 49673244b..39fc67210 100644 --- a/internal/proxy/state.go +++ b/internal/proxy/state.go @@ -56,7 +56,6 @@ type State struct { backendCapabilityProfile *string backendSupportsStreamMetadata *bool backendSupportsDensePatternWindowing *bool - backendSupportsMetadataSubstring *bool backendVersionLogged *bool // labelValuesIndexMu guards the per-tenant label-values indexed cache. diff --git a/internal/proxy/tumbling_axis_parity_test.go b/internal/proxy/tumbling_axis_parity_test.go index 2e803b077..4594545d2 100644 --- a/internal/proxy/tumbling_axis_parity_test.go +++ b/internal/proxy/tumbling_axis_parity_test.go @@ -717,36 +717,34 @@ func TestShortRangeMetric_TooManySeriesFollowsLokiLimit(t *testing.T) { } } -// A backend known to be without the stats_query_range offset arg has only -// epoch-aligned buckets. They serve the tumbling relabel when the start is -// aligned to the range; any other start would count lines of the wrong windows, -// so the window evaluator answers. Lines sit off window edges, as epoch buckets -// are [T, T+range) while Loki's windows are (T-range, T]. -func TestTumblingRangeMetric_BackendWithoutOffsetSupport(t *testing.T) { +// The stats_query_range offset arg places the bucket edges on the request's +// windows on every supported backend, so one anchored stats call answers the +// tumbling relabel whatever the start, and so does an unknown version (it +// behaves as the v1.49.0 floor). Lines sit off window edges only to keep the +// fixture independent of the 1ns edge shift. +func TestTumblingRangeMetric_AnchoredBucketsOnFloorAndUnknownVersion(t *testing.T) { base := time.Unix(1700000400, 0).UTC() lines := tumblingFixture(base.Add(2500 * time.Millisecond)) step := 5 * time.Minute query := `sum by (pod) (count_over_time({app="tumble"}[5m]))` - for _, offset := range []time.Duration{0, 123 * time.Millisecond, 7 * time.Second, 150 * time.Second} { - t.Run(fmt.Sprintf("start+%s", offset), func(t *testing.T) { - start, end := base.Add(5*time.Minute+offset), base.Add(15*time.Minute+offset) - srv, fake := newTumblingFakeVL(t, lines) - p := newGapTestProxy(t, srv.URL) - p.storeBackendVersion("v1.44.0", "v1.44.0") - got := runTumblingQuery(t, p, "/loki/api/v1/query_range", tumblingRangeParams(query, start, end, step)) - assertTumblingEqual(t, query, lokiTumblingReference(lines, "count_over_time", []string{"pod"}, start, end, step, 5*time.Minute), got) - fake.mu.Lock() - defer fake.mu.Unlock() - if offset == 0 { - if len(fake.statsCalls) != 1 || fake.statsCalls[0].offset != "" || fake.rawCalls != 0 { - t.Fatalf("aligned start: expected one offset-free stats call, got %+v and %d raw scans", fake.statsCalls, fake.rawCalls) + for _, version := range []string{"v1.49.0", ""} { + for _, offset := range []time.Duration{0, 123 * time.Millisecond, 7 * time.Second, 150 * time.Second} { + t.Run(fmt.Sprintf("version=%q/start+%s", version, offset), func(t *testing.T) { + start, end := base.Add(5*time.Minute+offset), base.Add(15*time.Minute+offset) + srv, fake := newTumblingFakeVL(t, lines) + p := newGapTestProxy(t, srv.URL) + if version != "" { + p.storeBackendVersion(version, version) } - return - } - if len(fake.statsCalls) != 0 || fake.rawCalls != 1 { - t.Fatalf("unaligned start: expected the window evaluator, got %+v and %d raw scans", fake.statsCalls, fake.rawCalls) - } - }) + got := runTumblingQuery(t, p, "/loki/api/v1/query_range", tumblingRangeParams(query, start, end, step)) + assertTumblingEqual(t, query, lokiTumblingReference(lines, "count_over_time", []string{"pod"}, start, end, step, 5*time.Minute), got) + fake.mu.Lock() + defer fake.mu.Unlock() + if len(fake.statsCalls) != 1 || fake.statsCalls[0].offset == "" || fake.rawCalls != 0 { + t.Fatalf("expected one offset-anchored stats call and no raw scans, got %+v and %d raw scans", fake.statsCalls, fake.rawCalls) + } + }) + } } } diff --git a/internal/proxy/unwrap_ungrouped_sum_differential_test.go b/internal/proxy/unwrap_ungrouped_sum_differential_test.go index 88b31c7bb..f90682650 100644 --- a/internal/proxy/unwrap_ungrouped_sum_differential_test.go +++ b/internal/proxy/unwrap_ungrouped_sum_differential_test.go @@ -502,12 +502,26 @@ func (s uwScenario) query() string { } func uwWindowText(d time.Duration) string { + if d%time.Second != 0 { + return strconv.Itoa(int(d/time.Millisecond)) + "ms" + } if d%time.Minute == 0 { return strconv.Itoa(int(d/time.Minute)) + "m" } return strconv.Itoa(int(d/time.Second)) + "s" } +// forceRawWindow makes the window one millisecond longer than the step, so the +// gcd(step, window) bucket grid is 1 ms wide and holds more than +// heavyQueryBucketThreshold buckets: the raw-sample evaluator answers, whatever +// the backend version. Instant scenarios have no grid and are left alone. +func (s uwScenario) forceRawWindow() uwScenario { + if s.step > 0 { + s.window = s.step + time.Millisecond + } + return s +} + func generateUnwrapScenario(rnd *rand.Rand, fn, parser string, instant bool) uwScenario { steps := []time.Duration{30 * time.Second, time.Minute, 90 * time.Second} windows := []time.Duration{30 * time.Second, time.Minute, 90 * time.Second, 5 * time.Minute, 150 * time.Second} @@ -635,35 +649,48 @@ func compareUnwrapSum(t *testing.T, label string, s uwScenario, got, want map[in // multiple of the step, instant), the proxy's answer to an ungrouped // `sum(rate(... | unwrap amount [r]))` is the one series Loki's vector aggregation // makes of the per-series values, at every step, on every VictoriaLogs line the -// proxy supports: v1.52 buckets anchored with the offset argument, and v1.40, -// which ignores it. The answer comes from stats: raw rows are read only on -// v1.40 when the start is not on its epoch grid. +// proxy supports (v1.49.0 and v1.52.0) and on an unknown version, which behaves +// as the floor. The answer comes from stats on buckets anchored with the offset +// argument; no raw rows are read. With raw=true a 1 ms bucket grid (see +// forceRawWindow) sends a query_range through the raw-sample evaluator, which +// must read the same (t-range, t] window (isLogRangeWindowFunc covers +// unwrap_rate) and call /select/logsql/query. // // conformance: semantics/unwrap-rate-ungrouped-sum-per-stream, metric-series-identity, window-bounds-and-step-alignment func TestUngroupedSumUnwrapDifferential(t *testing.T) { - for _, version := range []string{"", "v1.52.0", "v1.40.0"} { + for _, version := range []string{"", "v1.49.0", "v1.52.0"} { for _, parser := range []string{"", "logfmt", "json"} { for _, instant := range []bool{false, true} { - name := fmt.Sprintf("version=%s/parser=%s/instant=%v", version, parser, instant) - t.Run(name, func(t *testing.T) { - rnd := rand.New(rand.NewSource(int64(len(name))*7919 + 17)) - for i := 0; i < 25; i++ { - s := generateUnwrapScenario(rnd, "rate", parser, instant) - emu := &vlEmulator{t: t, rows: s.rows, field: "amount", version: version} - got, series := runUnwrapScenario(t, emu, s) - want := lokiUnwrapSum(s.rows, "rate", int64(s.window), s.times()) - compareUnwrapSum(t, fmt.Sprintf("%s #%d", name, i), s, got, want, series) - if rawRows := emu.endpointCalls("/select/logsql/query"); len(rawRows) > 0 { - // Only v1.40 can read rows, for a start off its epoch bucket grid. - if version != "v1.40.0" { + for _, raw := range []bool{false, true} { + if raw && instant { + continue + } + name := fmt.Sprintf("version=%s/parser=%s/instant=%v/raw=%v", version, parser, instant, raw) + t.Run(name, func(t *testing.T) { + rnd := rand.New(rand.NewSource(int64(len(name))*7919 + 17)) + for i := 0; i < 25; i++ { + s := generateUnwrapScenario(rnd, "rate", parser, instant) + if raw { + s = s.forceRawWindow() + } + emu := &vlEmulator{t: t, rows: s.rows, field: "amount", version: version} + got, series := runUnwrapScenario(t, emu, s) + want := lokiUnwrapSum(s.rows, "rate", int64(s.window), s.times()) + compareUnwrapSum(t, fmt.Sprintf("%s #%d", name, i), s, got, want, series) + rawRows := emu.endpointCalls("/select/logsql/query") + switch { + case raw && len(rawRows) == 0: + t.Fatalf("%s #%d: expected the raw-sample evaluator, got stats calls only", name, i) + case raw: + case len(rawRows) > 0: t.Fatalf("%s #%d: the metric read raw rows: %s", name, i, rawRows[0]) + case emu.widest > 1: + // One series from VictoriaLogs, not one per stream for the proxy to add up. + t.Fatalf("%s #%d: VictoriaLogs answered %d series for an ungrouped sum", name, i, emu.widest) } - } else if emu.widest > 1 { - // One series from VictoriaLogs, not one per stream for the proxy to add up. - t.Fatalf("%s #%d: VictoriaLogs answered %d series for an ungrouped sum", name, i, emu.widest) } - } - }) + }) + } } } } @@ -757,17 +784,18 @@ func TestUngroupedSumUnwrapPerSeriesFoldIgnoresSeriesLimit(t *testing.T) { } } -// The raw evaluator a rate over an unwrapped label falls back to (VictoriaLogs -// before v1.45 with a start off its epoch grid) reads Loki's window (t-range, t] -// for a grouped sum too: a line on the lower edge belongs to the previous -// window, the line at t to this one. +// A grouped sum of a rate over an unwrapped label reads Loki's window +// (t-range, t]: a line on the lower edge belongs to the previous window, the +// line at t to this one. The raw-sample evaluator answers (a 1 ms bucket grid +// is over heavyQueryBucketThreshold, whatever the backend version) and must +// have read /select/logsql/query. // // conformance: semantics/unwrap-range-last-point, window-bounds-and-step-alignment func TestGroupedSumUnwrapRateRawWindow(t *testing.T) { rnd := rand.New(rand.NewSource(424242)) for i := 0; i < 30; i++ { - s := generateUnwrapScenario(rnd, "rate", "", false) - emu := &vlEmulator{t: t, rows: s.rows, field: "amount", version: "v1.40.0"} + s := generateUnwrapScenario(rnd, "rate", "", false).forceRawWindow() + emu := &vlEmulator{t: t, rows: s.rows, field: "amount", version: "v1.49.0"} backend := httptest.NewServer(emu.handler()) p, err := New(Config{BackendURL: backend.URL, Cache: cache.New(60*time.Second, 1000), LogLevel: "error", BackendMinVersion: "v1.40.0", BackendVersionCheckTimeout: time.Second}) if err != nil { @@ -783,6 +811,9 @@ func TestGroupedSumUnwrapRateRawWindow(t *testing.T) { if res.Code != http.StatusOK { t.Fatalf("%s: %d %s", query, res.Code, res.Body) } + if len(emu.endpointCalls("/select/logsql/query")) == 0 { + t.Fatalf("%s: expected the raw-sample evaluator, got stats calls only", query) + } var resp struct { Data struct { Result []struct { From 28712fd6d242109706ba0b367a0483a722e79287 Mon Sep 17 00:00:00 2001 From: szibis Date: Sun, 18 Oct 2026 12:25:35 +0200 Subject: [PATCH 4/7] docs(conformance): follow the v1.49.0 floor in the registry The generated matrix link and wiring are refreshed for the new matrix, the capability rows of the removed gates (rate_sum, ipv4_range, metadata substring) are deleted, and the two cases that cite the rewritten ordered JSON pushdown test use its new name. --- .../semantics/json-extracted-metric-pushdown.yaml | 2 +- .../cases/versions/stats-bucket-label-v1.45.yaml | 2 +- conformance/registry/generated/matrix-link.json | 3 +-- conformance/registry/generated/wiring.json | 2 +- .../registry/vl/capabilities/field-i-pv4-range.yaml | 13 ------------- .../vl/capabilities/metadata-substring.yaml | 13 ------------- .../registry/vl/capabilities/stats-rate-sum.yaml | 13 ------------- conformance/reports/compatibility-matrix.md | 2 +- 8 files changed, 5 insertions(+), 45 deletions(-) delete mode 100644 conformance/registry/vl/capabilities/field-i-pv4-range.yaml delete mode 100644 conformance/registry/vl/capabilities/metadata-substring.yaml delete mode 100644 conformance/registry/vl/capabilities/stats-rate-sum.yaml diff --git a/conformance/registry/cases/semantics/json-extracted-metric-pushdown.yaml b/conformance/registry/cases/semantics/json-extracted-metric-pushdown.yaml index e8acc5023..e194c5a5e 100644 --- a/conformance/registry/cases/semantics/json-extracted-metric-pushdown.yaml +++ b/conformance/registry/cases/semantics/json-extracted-metric-pushdown.yaml @@ -57,7 +57,7 @@ gap: area: 'internal/proxy ordered_json_metric.go stats pushdown (name_extracted labels) and translator.ExtractedLabelPipes' loki: 'pkg/logql/log/parser.go:25 duplicateSuffix; pkg/logql/log/labels.go ParserHint/Extracted' proxy: 'orderedJSONPushdownLabelOK refused name_extracted; raw-row evaluator answered (502 at the row limit)' - planned_test: 'TestCompat_ExtractedLabelMetricPushdown (test/e2e-compat, group core); proxy TestOrderedJSONExtractedMetricPushdownMatchesLoki, TestOrderedJSONExtractedMetricProbeKeepsRawEvaluator, TestLogfmtExtractedMetricPushdownMatchesLoki, TestOrderedJSONExtractedMetricDifferential, TestOrderedJSONExtractedMetricPushdownNeedsStatsRangeOffset; translator TestExtractedLabelPipes' + planned_test: 'TestCompat_ExtractedLabelMetricPushdown (test/e2e-compat, group core); proxy TestOrderedJSONExtractedMetricPushdownMatchesLoki, TestOrderedJSONExtractedMetricProbeKeepsRawEvaluator, TestLogfmtExtractedMetricPushdownMatchesLoki, TestOrderedJSONExtractedMetricDifferential, TestOrderedJSONExtractedMetricPushdownOnSupportedBackends; translator TestExtractedLabelPipes' note: | Gated off below VictoriaLogs v1.45 (versions/stats-bucket-label-v1.45), where the raw-row evaluator answers as before. Open items: an ungrouped range metric stays on raw rows diff --git a/conformance/registry/cases/versions/stats-bucket-label-v1.45.yaml b/conformance/registry/cases/versions/stats-bucket-label-v1.45.yaml index 7d68da243..e6d9ea939 100644 --- a/conformance/registry/cases/versions/stats-bucket-label-v1.45.yaml +++ b/conformance/registry/cases/versions/stats-bucket-label-v1.45.yaml @@ -29,7 +29,7 @@ why: | The `name_extracted` stats pushdown is gated on `supportsStatsRangeOffset` (v1.45+); older backends keep the raw-row evaluator these shapes had, which is exact within `-manual-range-metric-row-limit` - (TestOrderedJSONExtractedMetricPushdownNeedsStatsRangeOffset). Not gated: the + (TestOrderedJSONExtractedMetricPushdownOnSupportedBackends). Not gated: the other pushdowns. request: method: GET diff --git a/conformance/registry/generated/matrix-link.json b/conformance/registry/generated/matrix-link.json index 62545b399..0aef1fb14 100644 --- a/conformance/registry/generated/matrix-link.json +++ b/conformance/registry/generated/matrix-link.json @@ -119,9 +119,8 @@ }, "victorialogs": { "matrix": [ - "v1.47.0", - "v1.48.0", "v1.49.0", + "v1.50.0", "v1.51.1", "v1.52.0", "v1.53.0" diff --git a/conformance/registry/generated/wiring.json b/conformance/registry/generated/wiring.json index 3bfd35e0f..c3e320d53 100644 --- a/conformance/registry/generated/wiring.json +++ b/conformance/registry/generated/wiring.json @@ -653,7 +653,7 @@ "test/e2e-compat/unwrap_ungrouped_sum_test.go" ] }, - "registry_items": 320, + "registry_items": 317, "routes_without_registry_item": [], "test_to_items": { "cmd/proxy/main_test.go": [ diff --git a/conformance/registry/vl/capabilities/field-i-pv4-range.yaml b/conformance/registry/vl/capabilities/field-i-pv4-range.yaml deleted file mode 100644 index ca3f54c53..000000000 --- a/conformance/registry/vl/capabilities/field-i-pv4-range.yaml +++ /dev/null @@ -1,13 +0,0 @@ -id: field-i-pv4-range -kind: version_gated_capability -since: v1.45 -generated: - used_in: 1 - -description: | - TODO: what the capability does in VictoriaLogs. -proxy_usage: | - TODO: native use, or the transformation used instead and why. -fallback: | - TODO: what happens on a backend older than `since`. -proves: [] # case ids that exercise it diff --git a/conformance/registry/vl/capabilities/metadata-substring.yaml b/conformance/registry/vl/capabilities/metadata-substring.yaml deleted file mode 100644 index 6852ed5f5..000000000 --- a/conformance/registry/vl/capabilities/metadata-substring.yaml +++ /dev/null @@ -1,13 +0,0 @@ -id: metadata-substring -kind: version_gated_capability -since: v1.49 -generated: - used_in: 0 - -description: | - TODO: what the capability does in VictoriaLogs. -proxy_usage: | - TODO: native use, or the transformation used instead and why. -fallback: | - TODO: what happens on a backend older than `since`. -proves: [] # case ids that exercise it diff --git a/conformance/registry/vl/capabilities/stats-rate-sum.yaml b/conformance/registry/vl/capabilities/stats-rate-sum.yaml deleted file mode 100644 index 0faecce8a..000000000 --- a/conformance/registry/vl/capabilities/stats-rate-sum.yaml +++ /dev/null @@ -1,13 +0,0 @@ -id: stats-rate-sum -kind: version_gated_capability -since: v1.44 -generated: - used_in: 0 - -description: | - TODO: what the capability does in VictoriaLogs. -proxy_usage: | - TODO: native use, or the transformation used instead and why. -fallback: | - TODO: what happens on a backend older than `since`. -proves: [] # case ids that exercise it diff --git a/conformance/reports/compatibility-matrix.md b/conformance/reports/compatibility-matrix.md index 1b9fd7577..c24581f5e 100644 --- a/conformance/reports/compatibility-matrix.md +++ b/conformance/reports/compatibility-matrix.md @@ -11,7 +11,7 @@ Generated by `conformance/scripts/matrix_link.py`. Versions come from `test/e2e- | grafana_loki_datasource_contract | `—` | 13.2.3, 13.2.1, 13.0.1, 12.4.12, 12.4.10, 12.4.2, 12.4.1 | | logs_drilldown_contract | `2.6.0` | 2.5.2, 2.5.1, 2.5.0, 2.4.0, 2.3.0, 2.2.1, 2.2.0, 2.1.5, 2.1.3, 2.1.2, 2.1.1, 2.1.0, 2.0.4, 2.0.3, 2.0.2, 2.0.1, 2.0.0, 1.0.41, 1.0.40, 1.0.39, 1.0.38, 1.0.37, 1.0.36, 1.0.35, 1.0.34 | | loki | `3.7.7` | 3.6.0, 3.6.1, 3.6.2, 3.6.3, 3.6.4, 3.6.5, 3.6.6, 3.6.7, 3.6.8, 3.6.9, 3.6.10, 3.6.11, 3.6.12, 3.6.13, 3.6.14, 3.6.15, 3.6.16, 3.7.0, 3.7.1, 3.7.2, 3.7.3, 3.7.4, 3.7.5, 3.7.6 | -| victorialogs | `v1.52.0` | v1.47.0, v1.48.0, v1.49.0, v1.51.1, v1.53.0 | +| victorialogs | `v1.52.0` | v1.49.0, v1.50.0, v1.51.1, v1.53.0 | ## Tracks and their evidence From d965b101592ba8e840ae9ef891b2e32f035aaaeb Mon Sep 17 00:00:00 2001 From: szibis Date: Sun, 18 Oct 2026 12:26:34 +0200 Subject: [PATCH 5/7] docs: document the v1.49.0 VictoriaLogs support floor The compatibility, matrix, testing, configuration and architecture pages state the new rule (the latest v1.4x release plus every v1.5x release), the version table starts at v1.49.0 and adds v1.50.0, and the sentences that described the stats offset, metadata substring and ipv4_range fallbacks are removed. --- README.md | 2 +- docs/architecture.md | 2 +- docs/compatibility-drilldown.md | 9 ++---- docs/compatibility-matrix.md | 6 ++-- docs/compatibility-victorialogs.md | 39 ++++++++++++-------------- docs/configuration.md | 8 +++--- docs/logql-parser.md | 2 +- docs/logsql-architecture.md | 13 ++++----- docs/real-window-compatibility-gaps.md | 6 ++-- docs/testing.md | 6 ++-- docs/translation-reference.md | 2 +- 11 files changed, 42 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index c0c593140..43bd094da 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ LogQL→LogsQL translation is **4.7–15.5 µs per query** (arm64). For a 100– | Selector `{app="nginx"}` | 4.7 µs | 18 | | `rate({...}[5m])` | 6.1 µs | 45 | | `sum(rate({...}[5m])) by (host)` | 8.9 µs | 48 | -| `ip("10.0.0.0/8")` filter (v1.45+, capability-aware) | 8.4 µs | 36 | +| `ip("10.0.0.0/8")` filter (native `ipv4_range()`) | 8.4 µs | 36 | | Binary metric `sum(rate) / sum(rate)` | 12.5 µs | 76 | Measured: `go test ./internal/translator/ -bench BenchmarkTranslate -benchmem -count=5` (Apple M5 Pro, Go 1.26, darwin/arm64). diff --git a/docs/architecture.md b/docs/architecture.md index ef8c8fcae..e81ca876d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -413,7 +413,7 @@ flowchart LR end subgraph Build["Build — internal/logsql"] - BUILDER["Capabilities-aware Builder\n(VL v1.40 → v1.5x)"] + BUILDER["Capabilities-aware Builder\n(VL v1.49 → v1.5x)"] LOGSQL["LogsQL query string"] end diff --git a/docs/compatibility-drilldown.md b/docs/compatibility-drilldown.md index bf4a35905..657911675 100644 --- a/docs/compatibility-drilldown.md +++ b/docs/compatibility-drilldown.md @@ -122,16 +122,11 @@ range different from the step, plus `topk`/`bottomk` over them) follow Loki for every client, including Drilldown-tagged requests: a step whose window `(t-range, t]` holds no log line is absent, not `0`. Each window is summed from `stats_query_range` buckets of `gcd(step, range)` whose edges are anchored to -the request start with the `offset` argument (VictoriaLogs v1.45+) and shifted +the request start with the `offset` argument and shifted by one nanosecond, so a line on a window edge counts where Loki counts it. The bucket count has no budget because VictoriaLogs returns only non-empty buckets. The raw-sample evaluator answers with the same `(t-range, t]` boundaries when a -bucket would be below 1 ms, when the stats response exceeds its byte limit, or -when an unaligned grid meets a backend known to be older than v1.45 (an -undetected version keeps the bucket path: such a backend ignores the `offset` -argument at worst). On such older backends an -epoch-aligned grid still uses buckets without the one-nanosecond shift, so a -line exactly on a window edge counts in the neighbouring window. +bucket would be below 1 ms or when the stats response exceeds its byte limit. Routing does not depend on the client: Explore, Drilldown, dashboard panels and direct API clients reach the same exact stats path. Only the answer above diff --git a/docs/compatibility-matrix.md b/docs/compatibility-matrix.md index 172263fa1..9a552fc8c 100644 --- a/docs/compatibility-matrix.md +++ b/docs/compatibility-matrix.md @@ -77,11 +77,11 @@ Those cases belong in the proxy contract suite, not the strict Loki semantics pa The matrix is intentionally not open-ended. For every upstream we support a moving window that advances as new releases land: - Loki: current minor family plus one minor family behind, including patch releases in those two families -- VictoriaLogs: the latest line plus the previous one (`v1.5x.x` and `v1.4x.x`), tested on at most the latest 3 releases of each line +- VictoriaLogs: the latest `v1.4x` release (`v1.49.0`) plus every `v1.5x` release, each supported minor tested at its latest patch with a published image - Logs Drilldown: current release family plus one family behind, including patch releases in those two families - Grafana runtime (for built-in Loki datasource): current runtime family plus one family behind -When a new upstream family becomes current, the oldest family drops out of the matrix. For VictoriaLogs the same rule applies per minor line: when `v1.6x` appears, `v1.6x` and `v1.5x` are supported and `v1.4x` drops out. `TestVictoriaLogsSupportPolicy` keeps the manifest, the `-backend-min-version` default and the capability floor in step. +When a new upstream family becomes current, the oldest family drops out of the matrix. VictoriaLogs is versioned per minor instead: a new minor joins the matrix as soon as it is released, and `v1.49.0` is the floor. `TestVictoriaLogsSupportPolicy` keeps the manifest, the `-backend-min-version` default, the capability floor and the README supported-versions table in step. See [VictoriaLogs compatibility](compatibility-victorialogs.md#support-policy). ## Matrix Summary @@ -90,7 +90,7 @@ When a new upstream family becomes current, the oldest family drops out of the m | Loki | `3.6.x` and `3.7.x` | Real runtime matrix in GitHub Actions | | Grafana Loki datasource runtime | `12.x` and `13.x` | Runtime contracts through Grafana datasource API in GitHub Actions | | Logs Drilldown | `1.0.x` and `2.x` (current pinned: `2.6.0`) | Pinned runtime e2e plus source-contract matrix | -| VictoriaLogs | `v1.47.x` to `v1.49.x` and `v1.51.x` to `v1.53.x` (latest 3 releases of `v1.4x` and `v1.5x`) | Real runtime matrix in GitHub Actions | +| VictoriaLogs | `v1.49.0`, `v1.50.0`, `v1.51.1`, `v1.52.0`, `v1.53.0` (the latest `v1.4x` release and every `v1.5x` minor) | Real runtime matrix in GitHub Actions | ## Grafana Version Sensing Model diff --git a/docs/compatibility-victorialogs.md b/docs/compatibility-victorialogs.md index 9cd59cde9..6f4d66b5a 100644 --- a/docs/compatibility-victorialogs.md +++ b/docs/compatibility-victorialogs.md @@ -18,16 +18,16 @@ This track measures whether the proxy keeps VictoriaLogs behavior usable and sta - Workflow: `compat-vl.yaml` - Score test: `TestVLTrackScore` -- Runtime matrix: real VictoriaLogs images, the latest 3 releases of each supported line (`v1.47.0`, `v1.48.0`, `v1.49.0` and `v1.51.1`, `v1.52.0`, `v1.53.0`) +- Runtime matrix: real VictoriaLogs images, every supported minor at its latest patch that has a published image (`v1.49.0`, `v1.50.0`, `v1.51.1`, `v1.52.0`, `v1.53.0`). Each version runs the score test, the version-sensitive checks and the range-metric table compared with Loki (`TestCompat_Unwrap*`, `TestRangeMetric*`, `TestGrafanaClickout_*`) ## Support Policy -VictoriaLogs support is by minor line, not by individual release: +Supported and tested versions are also listed in the [README](https://github.com/ReliablyObserve/Loki-VL-proxy#supported-versions). The rules: -- **Fully supported:** the latest line and the previous one. Today that is `v1.5x.x` and `v1.4x.x` (`v1.40.0` and newer). `v1.3x.x` and older are not supported. -- **Tested:** at most the latest 3 releases of each supported line, using the latest patch of each minor. Versions in a supported line that fall outside the tested three (`v1.40`-`v1.46` and `v1.50.x`) are supported through the capability gates below, which fall back for every feature introduced after `v1.40.0`, but they are not run in CI. -- **Floor:** `-backend-min-version` defaults to `v1.40.0`, the first minor of the oldest supported line. Startup fails when the detected backend is older, with an error naming the flag. With an explicit older `-backend-min-version` (for example a Helm value still carrying `v1.30.0`) or `-backend-allow-unsupported-version=true` the proxy starts, logs a warning on every start (`backend version is below the supported VictoriaLogs lines`, whatever the flag says), and the version is unsupported and untested. A backend whose version cannot be read only warns unless `-backend-version-strict=true`. See [Configuration](configuration.md). -- **Moving the window:** when a new line appears (for example `v1.6x`), it becomes current, the previous current line becomes previous, and the oldest line drops out. In one change: update `matrix_versions`, `support_window` and the capability profiles in `test/e2e-compat/compatibility-matrix.json`, the `-backend-min-version` default, `logsql.MinSupportedMinor`, and the pinned image. `TestVictoriaLogsSupportPolicy` fails until these agree and enforces "at most 3 per line, two adjacent lines". +- **Fully supported:** `v1.49.0`, the latest `v1.4x` release, and every `v1.5x` release. `v1.48.x` and older are not supported. +- **Tested:** every supported minor at its latest patch that has a published Docker image (`v1.49.0`, `v1.50.0`, `v1.51.1`, `v1.52.0`, `v1.53.0`; `v1.52.1` has no image, so `v1.52.0`). The pull-request CI image is pinned to `v1.52.0`; moving the pin is a separate change with its own proof. +- **Floor:** `-backend-min-version` defaults to `v1.49.0`. Startup fails when the detected backend is older, with an error naming the flag. With an explicit older `-backend-min-version` (for example a Helm value still carrying `v1.30.0`) or `-backend-allow-unsupported-version=true` the proxy starts, logs a warning on every start (`backend version is below the supported VictoriaLogs lines`, whatever the flag says), and the version is unsupported and untested: the proxy keeps no fallbacks for features older than `v1.49.0`, for example the `stats_query_range` `offset` argument (`v1.45`). A backend whose version cannot be read only warns unless `-backend-version-strict=true`, and then behaves as `v1.49.0`. See [Configuration](configuration.md). +- **Moving the floor or adding a version:** a new minor joins `matrix_versions`; when support for the oldest supported minor ends, the floor moves. In one change: update `matrix_versions`, `support_window` and the capability profiles in `test/e2e-compat/compatibility-matrix.json`, the supported-versions table in the README, the `-backend-min-version` default (flag, Go fallback, Helm value), `logsql.MinSupportedMinor`, and the pinned image. `TestVictoriaLogsSupportPolicy` fails until these agree: the floor is the lowest matrix version and the only `v1.4x` entry, every `v1.5x` minor from `v1.50` up is present once, and the pin is a tested version. ## Version Matrix @@ -36,9 +36,8 @@ VictoriaLogs support is by minor line, not by individual release: | `v1.53.0` | Scheduled and manual matrix (tested) | Newest tested backend. `/internal/force_merge`, `/internal/force_flush`, `/internal/log_new_streams` and `/internal/partition/*` require `POST` (the proxy does not call them). Not the PR CI pin; moving the pin is a separate change | | `v1.52.0` | PR and main CI pinned runtime | Current pinned backend. Distroless image (no shell): compose health probes must exec the binary. `json_array_concat` pipe. Bare filter pipes starting with a non-word token or `not` are accepted again. Parse errors echo the query before the reason | | `v1.51.1` | Scheduled and manual matrix (tested) | Cluster upgrade bridge: `vlstorage` v1.51.1 accepts `vlselect` v1.38.0 through v1.51.0; same LogsQL surface as v1.51.0 | -| `v1.49.0` | Scheduled and manual matrix (tested) | Structured metadata shaping, volume endpoints | -| `v1.48.0` | Scheduled and manual matrix (tested) | Structured metadata shaping, volume endpoints | -| `v1.47.0` | Scheduled and manual matrix (tested) | Structured metadata shaping, volume endpoints | +| `v1.50.0` | Scheduled and manual matrix (tested) | Stream fields in the column index (`field_values` fast path), dense pattern windowing | +| `v1.49.0` | Scheduled and manual matrix (tested); supported floor | Structured metadata shaping, volume endpoints | ## Edge Cases Covered @@ -55,40 +54,38 @@ The proxy passively detects backend version from upstream response headers and s |---|---|---|---|---| | `v1.50.x+` | `vl-v1.50-plus` | enabled | enabled | enabled | | `v1.49.x` | `vl-v1.49-plus` | enabled | enabled | disabled (conservative profile) | -| `v1.30.x` to `v1.48.x` | `vl-v1.30-plus` | enabled | disabled | disabled (conservative profile) | -| `< v1.30.0` | `legacy-pre-v1.30` | disabled (fallback to generic `field_*`) | disabled | disabled | -| unknown (before first upstream response) | `unknown` | enabled (optimistic default) | disabled (safe default) | disabled (safe default) | +| `v1.30.x` to `v1.48.x` (unsupported) | `vl-v1.30-plus` | enabled | enabled (always forwarded) | disabled (conservative profile) | +| `< v1.30.0` | `legacy-pre-v1.30` | disabled (fallback to generic `field_*`) | enabled (always forwarded) | disabled | +| unknown (before first upstream response) | `unknown` | enabled (optimistic default) | enabled (always forwarded) | disabled (safe default) | Current code gates: - pattern extraction window density for `/loki/api/v1/patterns` - stream-metadata-first label inventory/value lookups (`/select/logsql/stream_field_names`, `/select/logsql/stream_field_values`) -- metadata search fan-in via `q` + `filter=substring` on `field_*` and `stream_field_*` endpoints As new LogSQL backend features land, this table and the capability derivation in proxy code should be updated together, with explicit tests per profile. -## Feature Capability Matrix (v1.40.0 To v1.53.0) +## Feature Capability Matrix (v1.49.0 To v1.53.0) -The table below tracks changelog-relevant LogSQL and metadata behavior between `v1.40.0` and `v1.53.0`, and how the proxy should treat each band. +The table below tracks changelog-relevant LogSQL and metadata behavior between `v1.49.0` and `v1.53.0`, and how the proxy should treat each band. | Version band | Backend capability signals | Limitations / risks to account for | Proxy handling policy | |---|---|---|---| -| `v1.40.x` to `v1.48.x` | improved cluster query behavior and partial-response handling in VictoriaLogs changelog line | still treat dense pattern extraction as opt-in to avoid long-range overload in mixed backends | same runtime profile as `vl-v1.30-plus`; prefer conservative pattern sampling | -| `v1.49.x` | adds `filter=substring` support for `field_names` / `field_values` and stream metadata browse endpoints | older versions do not support this parameter reliably | gate substring server-side filtering by backend capability; keep fallback filtering in proxy for older versions | +| `v1.49.x` | `filter=substring` support for `field_names` / `field_values` and stream metadata browse endpoints; `stats_query_range` `offset` argument (since `v1.45`) | none on the supported floor | `q` + `filter=substring` is always forwarded and stats buckets are always anchored with `offset`; the proxy-side search window also serves the browse index and offset/limit paging | | `v1.50.x` | parser/query fixes and latest metadata/query behavior | none specific beyond normal backend saturation limits | enable `vl-v1.50-plus` profile, including dense patterns windowing and newest metadata behavior | | `v1.51.x` | `coalesce` pipe; `limit`/`offset` after `stats` on `stats_query`; `unpack_json` accepts JSON with leading spaces; `stats_query`/`stats_query_range` answer 502 when a storage node is unavailable | a filter pipe without the `filter` prefix is rejected unless it starts with `field_name:` | the translator emits `| filter` for every filter that follows a non-filter pipe | | `v1.52.x` | distroless image; `json_array_concat` pipe; filters starting with a non-word token or `not` are accepted without the prefix again; `-search.maxQueueDuration` is honoured for queued requests; current pinned target | no shell in the image; parse errors echo the query before the reason (`cannot parse query arg []: `) | same `vl-v1.50-plus` profile; compose health probes exec `/victoria-logs-prod -version`; the upstream error classifier reads both parse-error layouts, so invalid queries stay Loki `400 bad_data` | | `v1.53.x` | `/internal/force_merge`, `/internal/force_flush`, `/internal/log_new_streams` and `/internal/partition/*` require `POST`; `/delete/run_task` is `POST` only; Unix socket listener | none for the proxy: it uses only the select endpoints | same `vl-v1.50-plus` profile; newest tested version, not yet the PR CI pin | -| `< v1.40.0` | not supported (`v1.3x` and older) | outside the supported lines; modern drilldown/explore contracts are not tested there | block startup by default (`backend-min-version`), allow override with `backend-allow-unsupported-version=true` | +| `< v1.49.0` | not supported | outside the supported versions; modern drilldown/explore contracts are not tested there, and the proxy keeps no fallbacks for them (for example `v1.44` and older ignore the `stats_query_range` `offset` argument) | block startup by default (`backend-min-version`), allow override with `backend-allow-unsupported-version=true` | ### Capability Profile Guidance - `vl-v1.50-plus`: use for latest backend capabilities and densest safe pattern windowing. -- `vl-v1.49-plus`: adds the metadata substring filter. -- `vl-v1.30-plus`: base profile for every version with the stream metadata endpoints; the supported part of it is `v1.40.0` to `v1.48.x`. +- `vl-v1.49-plus`: the floor profile. +- `vl-v1.30-plus`: base profile for every version with the stream metadata endpoints; no supported version maps to it (the floor is `v1.49.0`). - `legacy-pre-v1.30`: fallback-only profile for versions without them. -Profile names describe where a capability starts, not the support floor, so the floor (`v1.40.0`) moving does not rename them. A detected version below the floor logs a startup warning (`backend version is below the supported VictoriaLogs lines`) whatever `-backend-min-version` says; such a version is unsupported and untested. +Profile names describe where a capability starts, not the support floor, so the floor moving does not rename them. A detected version below the floor logs a startup warning (`backend version is below the supported VictoriaLogs lines`) whatever `-backend-min-version` says; such a version is unsupported and untested. When adding a new profile or changing a gate, update three places in the same PR: diff --git a/docs/configuration.md b/docs/configuration.md index 4a9a4e882..f09b635d3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -861,12 +861,12 @@ See [Performance — Go Runtime Tuning](performance.md#go-runtime-tuning) for gu | Flag | Env | Default | Description | |---|---|---|---| | `-max-lines` | — | `1000` | Default max lines per query | -| `-manual-range-metric-row-limit` | — | `1000000` | Maximum rows fetched per proxy-side range-metric evaluation (`rate`, `count_over_time`, etc.): raw log rows, or stats rows (one per non-empty step bucket and series) on the window stats path. Exceeding it rejects the query with HTTP `502` (`manual range metric row limit exceeded ... increase -manual-range-metric-row-limit`) instead of returning truncated results. On the window stats path the limit bounds each bucket grid separately (at most one row per step and series), not their sum. For pipe-free selectors over at least `-backend-heavy-query-min-range`, a one-row count runs first and rejects the query before any log line is fetched. `count_over_time`, `rate`, `bytes_over_time` and `bytes_rate` normally avoid raw rows by summing `stats_query_range` buckets of `gcd(step, range)` (one bucket per step when the range is shorter than the step). When that grid would need more than 11,000 buckets, and for every `topk`/`bottomk` input, they instead read one window-sized seed bucket and two step-sized bucket grids from a streamed VictoriaLogs stats pipe and derive each window exactly; `topk`/`bottomk` then rank every series at each step. Raw rows remain the fallback only when the bucket is below 1 ms or the evaluation grid is not epoch-aligned and VictoriaLogs is known to be older than v1.45 (an undetected version keeps the bucket path: the `offset` argument is ignored at worst). The encoded range-metric response is capped at 64 MiB (HTTP `503` above it), and Loki's 11,000-points-per-series limit bounds the evaluation steps. Proxy memory on the window stats path is about 100 bytes per stats row, so the default bounds one evaluation to roughly 100 MiB | +| `-manual-range-metric-row-limit` | — | `1000000` | Maximum rows fetched per proxy-side range-metric evaluation (`rate`, `count_over_time`, etc.): raw log rows, or stats rows (one per non-empty step bucket and series) on the window stats path. Exceeding it rejects the query with HTTP `502` (`manual range metric row limit exceeded ... increase -manual-range-metric-row-limit`) instead of returning truncated results. On the window stats path the limit bounds each bucket grid separately (at most one row per step and series), not their sum. For pipe-free selectors over at least `-backend-heavy-query-min-range`, a one-row count runs first and rejects the query before any log line is fetched. `count_over_time`, `rate`, `bytes_over_time` and `bytes_rate` normally avoid raw rows by summing `stats_query_range` buckets of `gcd(step, range)` (one bucket per step when the range is shorter than the step). When that grid would need more than 11,000 buckets, and for every `topk`/`bottomk` input, they instead read one window-sized seed bucket and two step-sized bucket grids from a streamed VictoriaLogs stats pipe and derive each window exactly; `topk`/`bottomk` then rank every series at each step. Raw rows remain the fallback only when the bucket is below 1 ms. The encoded range-metric response is capped at 64 MiB (HTTP `503` above it), and Loki's 11,000-points-per-series limit bounds the evaluation steps. Proxy memory on the window stats path is about 100 bytes per stats row, so the default bounds one evaluation to roughly 100 MiB | | `-ordered-json-metric-max-bytes` | — | `1073741824` | Safety cap, not the fix for slow queries: the most bytes the proxy-side ordered JSON metric evaluator reads from the VictoriaLogs raw rows response, and the largest response it builds. `0` uses the 1 GiB default; there is no upper bound. Exceeding it rejects the query with HTTP `502` (`ordered JSON metric response exceeds N bytes; narrow the query or increase -ordered-json-metric-max-bytes`) instead of returning partial results. The default admits about one million rows of 1 KiB, the `-manual-range-metric-row-limit` default; on the e2e generator (about 290k lines and 300 MiB of rows per hour) that is roughly 3.5 hours. Rows are streamed, not buffered, so raising it costs VictoriaLogs scan work and network transfer; proxy memory grows with the retained rows, which `-manual-range-metric-row-limit` bounds. Lower it to protect VictoriaLogs from long raw scans. Helm: `extraArgs.ordered-json-metric-max-bytes`. The evaluator answers only `\| json` range metrics that need Loki's per-line semantics (surviving parser errors, `_extracted` labels, `keep`, a second parser, filters that accept the empty value without `drop __error__`). Summed `count_over_time`, `rate`, `bytes_over_time` and `bytes_rate` over a single `\| json` or `\| logfmt` parser, grouped by parsed labels (including `detected_level` and labels with underscores such as `service_version`) or ungrouped, with string label filters (`=`, `!=`, `=~`, `!~`) after the parser, are computed from VictoriaLogs `stats_query_range` buckets over `unpack_json`/`unpack_logfmt` and `filter` pipes and do not read raw rows; this includes Grafana's logs volume queries for plain, `\| json` and `\| logfmt` selectors with query-builder filters, and Logs Drilldown field breakdowns. Parser errors must be dropped unless a filter rejects the empty value (`pipeline="logs/loki"`, `field!=""`), which no unparsed line passes. A label VictoriaLogs stores under another spelling (`service_version` as `service.version`) is read from the stored field where the line carries it, as Loki reads structured metadata before a parsed key. Before answering, cheap `\| limit 1` probes check the range for a line the two parsers would read differently: a key parsed before a syntax error, a repeated key, an array value, a key spelled differently from the label Loki sanitizes it to (`service-version`, `service.version`, a nested `{"service":{"version":...}}`), a logfmt tab, or, when errors are not dropped, a stored value of a filter label on a line whose body yields none. One such line keeps the previous route: this evaluator for `\| json`, the native stats route for plain and `\| logfmt`. `loki_vl_proxy_parser_metric_evaluations_total{evaluator,reason}` counts which evaluator answered and why. Grafana Logs Drilldown requests without a JSON parser keep their dedicated routes. | | `-label-filter-refill-max-pages` | — | `8` | Loki-compatible profile: further VictoriaLogs pages of at most `limit` rows a log query reads when a Loki label filter on a log line key no earlier stage exposes dropped rows VictoriaLogs matched (`{app="x"} \| user="u1"` without a parser), so the page still holds `limit` lines. A response reads at most (1 + N) x `limit` rows. `0` reads no further page: such a page can return fewer lines than the limit. | | `-backend-timeout` | — | `120s` | Timeout for non-streaming VL backend requests. The remaining request budget (this timeout or an earlier request deadline) is passed to VictoriaLogs as its `timeout` query argument, so VictoriaLogs stops executing a query the proxy has given up on; VictoriaLogs caps it at its own `-search.maxQueryDuration`. A client disconnect cancels the in-flight VictoriaLogs request | -| `-backend-min-version` | — | `v1.40.0` | Minimum VictoriaLogs version considered fully supported at startup compatibility gate (first minor of the oldest supported line; the proxy refuses to start below it unless `-backend-allow-unsupported-version=true`) | -| `-backend-allow-unsupported-version` | — | `false` | Allow startup when detected backend version is lower than `-backend-min-version` (unsafe override) | +| `-backend-min-version` | — | `v1.49.0` | Minimum VictoriaLogs version considered fully supported at startup compatibility gate (`v1.49.0`, the latest `v1.4x` release; every `v1.5x` release is supported; the proxy refuses to start below it unless `-backend-allow-unsupported-version=true`) | +| `-backend-allow-unsupported-version` | — | `false` | Allow startup when detected backend version is lower than `-backend-min-version` (unsafe override; the version is unsupported and untested, and the proxy keeps no fallbacks for it) | | `-backend-version-check-timeout` | — | `5s` | Timeout for startup backend version compatibility check (`/health`) | | `-stream-response` | — | `false` | Stream via chunked transfer | | `-response-compression` | — | `auto` | Response compression codec: `auto`, `gzip`, `none` | @@ -902,7 +902,7 @@ Backend version gate notes: - If detected version is below `-backend-min-version`, startup is blocked by default. - Set `-backend-allow-unsupported-version=true` to bypass the gate at your own risk. - If version cannot be detected from headers, proxy logs a warning and continues startup. -- Version-gated behaviour keeps VictoriaLogs-native evaluation while the version is unknown: range metrics still use `stats_query_range` buckets anchored with the `offset` argument, which a release predating it (before v1.45) ignores, leaving the epoch-aligned buckets such a backend has anyway. Only a version *known* to be older than v1.45 restricts buckets to epoch-aligned grids. While the version remains unknown, for example when the startup probe ran before VictoriaLogs was ready or `/metrics` is not routed through a proxy in front of it, the proxy retries the `/metrics` version probe in the background at most once every 5 minutes, bounded by `-backend-version-check-timeout`. There is no version override flag; make `/metrics` or a version response header reachable to enable version-gated paths. +- An unknown version behaves as the `v1.49.0` floor: range metrics use `stats_query_range` buckets anchored with the `offset` argument, the metadata substring filter (`q` + `filter=substring`) is forwarded and `ip()` filters use `ipv4_range()`. Only the `v1.50`+ behaviour (dense pattern windowing, column-index `field_values`, `coalesce`) waits for the version. While the version remains unknown, for example when the startup probe ran before VictoriaLogs was ready or `/metrics` is not routed through a proxy in front of it, the proxy retries the `/metrics` version probe in the background at most once every 5 minutes, bounded by `-backend-version-check-timeout`. There is no version override flag; make `/metrics` or a version response header reachable to enable the version-gated paths. ## Built-In Protection Defaults diff --git a/docs/logql-parser.md b/docs/logql-parser.md index 9a6322170..a693e4b39 100644 --- a/docs/logql-parser.md +++ b/docs/logql-parser.md @@ -56,7 +56,7 @@ flowchart TD SS["translateStreamSelector\n→ logsql.FilterExpr"] PIPE_STAGES["translateLogQuery\n→ []logsql.Pipe"] LABEL_FN["LabelFn applied\n(OTel dotted → underscore)"] - CAPS_GATE["Capabilities gating\n(VL v1.40 min, v1.5x features)"] + CAPS_GATE["Capabilities gating\n(VL v1.49 min, v1.5x features)"] FT["errFallthrough\n→ String Translator"] end subgraph STR["String Translator — TranslateLogQLWithCapabilities"] diff --git a/docs/logsql-architecture.md b/docs/logsql-architecture.md index a5a0ec18f..d84610aa3 100644 --- a/docs/logsql-architecture.md +++ b/docs/logsql-architecture.md @@ -2,7 +2,7 @@ ## Overview -`internal/logsql` provides a typed AST, recursive-descent parser, capability-aware builder, and version gating for VictoriaLogs LogsQL queries. It mirrors the structure of `internal/logql` (the existing LogQL parser) and targets VictoriaLogs v1.40 and later. +`internal/logsql` provides a typed AST, recursive-descent parser, capability-aware builder, and version gating for VictoriaLogs LogsQL queries. It mirrors the structure of `internal/logql` (the existing LogQL parser) and targets VictoriaLogs v1.49.0 and later (the supported floor; see [VictoriaLogs compatibility](compatibility-victorialogs.md)). The package is used in two modes: 1. **Direct construction** — callers assemble `logsql.Expr` nodes with the builder API and call `String()` to emit the final query. @@ -170,17 +170,14 @@ sequenceDiagram ## VictoriaLogs Version Capability Matrix -The package targets **v1.40 minimum**. All features in the v1.40 baseline are always available. +The package targets **v1.49.0 minimum**. Everything introduced up to v1.49.0 is always available and has no flag: the baseline pipes and stats functions (`PipeHits`, `PipeRunning`, `PipeBlock`, `PipeUniq`, `PipeTop`, `StatsHistogram`), `rate_sum()` (v1.44), `ipv4_range()` (v1.45), the `stats_query_range` `offset` argument (v1.45) and the metadata substring filter (v1.49). | Version range | Feature additions | |---------------|-------------------| -| v1.40–v1.43 | Baseline: `PipeHits`, `PipeRunning`, `PipeBlock`, `PipeUniq`, `PipeTop`, `StatsHistogram` | -| v1.44 | `rate_sum()` stats function (`Capabilities.StatsRateSum`) | -| v1.45–v1.48 | `ipv4_range()` field filter (`Capabilities.FieldIPv4Range`) | -| v1.49 | Metadata substring filter (`Capabilities.MetadataSubstring`) | +| v1.49 | Floor: the zero `Capabilities` value | | v1.50+ | Dense pattern windowing (`Capabilities.DensePatternWindowing`) | -`CapabilitiesFor(semver)` is called once at proxy startup from `storeBackendVersion()`. Unsupported versions (pre-v1.40, malformed) return a zero `Capabilities` (all false), which is the safest possible degraded state. +`CapabilitiesFor(semver)` is called from `storeBackendVersion()` and `logsqlCapabilities()`. An unknown or malformed version, and a version below the floor (unsupported, only started with `-backend-allow-unsupported-version`), return the zero `Capabilities`, the floor: no fallbacks are kept for older releases. --- @@ -270,4 +267,4 @@ Once the AST-to-AST path is wired to handlers, the full pipeline becomes: 2. **Parser** (`scanner.go` + `parser.go`) — recursive-descent parser that round-trips what the translator emits 3. **Capability-aware builder** (`capabilities.go` + `builder.go`) — selects the best LogsQL construct for the detected VL version -Callers import the package and construct queries using the builder API. The minimum supported VictoriaLogs version is v1.40. +Callers import the package and construct queries using the builder API. The minimum supported VictoriaLogs version is v1.49.0. diff --git a/docs/real-window-compatibility-gaps.md b/docs/real-window-compatibility-gaps.md index 515876818..bd63c92b2 100644 --- a/docs/real-window-compatibility-gaps.md +++ b/docs/real-window-compatibility-gaps.md @@ -28,8 +28,8 @@ The exhaustive checks compare status, result type and non-emptiness. Strict quan - Binary matching checks cardinality independently at each timestamp. Empty grouping modifiers retain their meaning, disjoint streams do not conflict, and set operations retain their many-to-many exemption. Original operands execute through the scoped query handlers, preserving trailing windows, error propagation, extraction aliases and comparison semantics. - Additive ungrouped sums reduce all streams instead of leaking intermediate backend groups. Regexp captures remain visible as parsed fields in ordinary and categorized log responses. - Tumbling range aggregations (range equal to step) served from VictoriaLogs `stats_query_range` buckets are relabelled to Loki's evaluation timestamps. VictoriaLogs labels a bucket by its start and covers `[T, T+step)`; Loki's sample at `T` covers `(T-range, T]`. The proxy fetches from `start-range` and moves each bucket forward by one range, instead of trimming the bucket Loki's first sample needs. It covers `rate` and `bytes_rate` in any aggregation, and ungrouped `sum(count_over_time(...))` and `sum(bytes_over_time(...))`. -- Sliding range aggregations (`count_over_time`, `rate`, `bytes_over_time`, `bytes_rate` with a range different from the step) served from `stats_query_range` buckets sum buckets of `gcd(step, range)` anchored to the request start and shifted by one nanosecond through the `offset` argument, so each bucket covers `(T, T+bucket]` and every window `(t-range, t]` is an exact union of buckets, including lines on window edges and unaligned starts. A step whose window holds no line is absent, as in Loki, for every client including Drilldown. Buckets below 1 ms, stats responses over the byte limit, and unaligned grids on a backend older than VictoriaLogs v1.45 or of undetected version use the raw-sample evaluator, which applies the same `(t-range, t]` boundaries to these functions for range and instant queries. -- Bare parser range aggregations without an outer aggregation (`count_over_time({...} | logfmt [90s])`, also `regexp`, `pattern` and `json` with parameters) use the same anchored `gcd(step, range)` buckets: `stats_query_range` grouped by stream for `count_over_time`, `rate`, `bytes_over_time` and `bytes_rate` with a range greater than or equal to the step (byte sums add a `count()` presence column, so a window holding only empty lines is a zero sample), VictoriaLogs `/hits` with the same `offset` argument for sliding `count_over_time` and `rate` when stream label fields are declared, and bucket values for `sum_over_time`, `max_over_time` and `min_over_time` over `unwrap`. Tumbling queries on this path are no longer merged into one unlabelled series. Unaligned grids on a backend without `offset` support use the raw evaluator. Bare `| json` without parameters keeps the ordered JSON evaluator. +- Sliding range aggregations (`count_over_time`, `rate`, `bytes_over_time`, `bytes_rate` with a range different from the step) served from `stats_query_range` buckets sum buckets of `gcd(step, range)` anchored to the request start and shifted by one nanosecond through the `offset` argument, so each bucket covers `(T, T+bucket]` and every window `(t-range, t]` is an exact union of buckets, including lines on window edges and unaligned starts. A step whose window holds no line is absent, as in Loki, for every client including Drilldown. Buckets below 1 ms and stats responses over the byte limit use the raw-sample evaluator, which applies the same `(t-range, t]` boundaries to these functions for range and instant queries. +- Bare parser range aggregations without an outer aggregation (`count_over_time({...} | logfmt [90s])`, also `regexp`, `pattern` and `json` with parameters) use the same anchored `gcd(step, range)` buckets: `stats_query_range` grouped by stream for `count_over_time`, `rate`, `bytes_over_time` and `bytes_rate` with a range greater than or equal to the step (byte sums add a `count()` presence column, so a window holding only empty lines is a zero sample), VictoriaLogs `/hits` with the same `offset` argument for sliding `count_over_time` and `rate` when stream label fields are declared, and bucket values for `sum_over_time`, `max_over_time` and `min_over_time` over `unwrap`. Tumbling queries on this path are no longer merged into one unlabelled series. Bare `| json` without parameters keeps the ordered JSON evaluator. - Binary expressions joining two vector operands evaluate both on the step-aligned grid, as Loki's query frontend does when `align_queries_with_step` is enabled. The compatibility stack enables it; a default Loki evaluates at `start+k*step`, so results with an unaligned start differ by less than one step. Operands with different ranges take different execution paths (sliding evaluation versus tumbling buckets), and an unaligned start previously left no common timestamps to join. - `unwrap duration(...)` and `unwrap bytes(...)` over bare parsers convert unit strings such as `15ms` and `1024B` instead of dropping every sample. - Range operands grouped `by (level)` keep Loki's `level` key, so `on(level)` matching works for range queries as it already did for instant queries. @@ -76,7 +76,7 @@ The release UI pass also found a local Compose configuration mismatch: a 1 GiB V ## Remaining limits -Valid label `ip()` syntax and exact matching of all IPv6/non-octet CIDR/range forms need further compatibility work; argument validation does not fix approximate translation. Regexp capture collisions with existing labels and learned aliases when only an alternate stored spelling exists remain separate limits. The grouped quantile canary uses a shared aligned evaluation axis; arbitrary frontend range alignment is not established by it. Outside binary joins, native tumbling results remain on the step-aligned grid rather than Loki's unaligned `start+k*step` grid, and a bucket boundary is inclusive at its start where Loki's window is inclusive at its end. The same edge limit applies to sliding range aggregations on VictoriaLogs releases older than v1.45, whose `stats_query_range` ignores `offset`. Grouped tumbling `count_over_time` and `bytes_over_time`, served by the Drilldown hits and hybrid paths, are not relabelled, so their labels mark bucket starts while ungrouped sums use Loki's evaluation timestamps. Bare parser range aggregations served from buckets label each series with its stream labels (or the declared fields on the `/hits` route), while Loki also adds every parsed label, such as `level` from `| logfmt`; their values match Loki when parsing adds no labels. A uniform offset is applied before binary alignment, which differs from Loki only when the offset is not a multiple of the step. Wide raw-sample workloads remain subject to explicit resource limits. +Valid label `ip()` syntax and exact matching of all IPv6/non-octet CIDR/range forms need further compatibility work; argument validation does not fix approximate translation. Regexp capture collisions with existing labels and learned aliases when only an alternate stored spelling exists remain separate limits. The grouped quantile canary uses a shared aligned evaluation axis; arbitrary frontend range alignment is not established by it. Outside binary joins, native tumbling results remain on the step-aligned grid rather than Loki's unaligned `start+k*step` grid, and a bucket boundary is inclusive at its start where Loki's window is inclusive at its end. Grouped tumbling `count_over_time` and `bytes_over_time`, served by the Drilldown hits and hybrid paths, are not relabelled, so their labels mark bucket starts while ungrouped sums use Loki's evaluation timestamps. Bare parser range aggregations served from buckets label each series with its stream labels (or the declared fields on the `/hits` route), while Loki also adds every parsed label, such as `level` from `| logfmt`; their values match Loki when parsing adds no labels. A uniform offset is applied before binary alignment, which differs from Loki only when the offset is not a multiple of the step. Wide raw-sample workloads remain subject to explicit resource limits. The existing label sanitizer normalizes a stored `__name__` label to `_name`. Direct-ingestion probes that require Loki's reserved metric-name identity are diff --git a/docs/testing.md b/docs/testing.md index 02ce0e8fc..6de63975a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -182,7 +182,7 @@ Test, security and quality jobs defined under `.github/workflows/` (release, doc | `compat-drilldown.yaml` | `drilldown-grafana-pr-matrix` | runtime profiles with `run_on_pr` (Grafana 13.2.3 `current_smoke`, 12.4.12 `previous_smoke`) | | `compat-drilldown.yaml` | `drilldown-contract-matrix`, `drilldown-grafana-runtime-matrix` | weekly/manual: Drilldown app contract checks per version and every Grafana runtime profile | | `compat-vl.yaml` | `vl-pinned` | Loki 3.7.7, VictoriaLogs v1.52.0, Grafana 13.2.3: `TestVLTrackScore` | -| `compat-vl.yaml` | `vl-matrix` | weekly/manual: `TestVLTrackScore` for every `stack.victorialogs.matrix_versions` entry | +| `compat-vl.yaml` | `vl-matrix` | weekly/manual, for every `stack.victorialogs.matrix_versions` entry: `TestVLTrackScore`, the version-sensitive checks (`TestCompat_DetectedLevelQueryRange`, `TestCompat_RejectedQueryStatusParity`) and the range-metric table compared with Loki (`TestCompat_Unwrap*`, `TestRangeMetric*`, `TestGrafanaClickout_*`) | | `security-pr.yaml` | `Security / static` | Gitleaks v8.28.0, gosec v2.29.0, Trivy 0.71.0 filesystem scan, actionlint 1.7.7, hadolint v2.12.0, OpenSSF Scorecard guardrails | | `security-pr.yaml` | `Security / runtime` | compose stack, `scripts/ci/run_security_regressions.sh`, ZAP baseline | | `security-heavy.yaml` | `Heavy / image and sbom`, `Heavy / semgrep`, `Heavy / fuzz`, `Heavy / dast` | scheduled/manual only: Trivy image scan and SBOM, Semgrep 1.161.0, 2-minute fuzzing, ZAP active scan and curated nuclei | @@ -536,7 +536,7 @@ The repo now keeps four separate compatibility tracks/contracts: | Loki | `TestLokiTrackScore` | Loki `3.6.x` and `3.7.x` | | Logs Drilldown | `TestDrilldownTrackScore` | Logs Drilldown `1.0.x` and `2.x` families | | Grafana Loki datasource | `TestGrafanaDatasourceCatalogAndHealth` | Grafana runtime `13.x` (current) and `12.x` (previous) families | -| VictoriaLogs | `TestVLTrackScore` | VictoriaLogs `v1.4x.x` and `v1.5x.x`, latest 3 releases of each | +| VictoriaLogs | `TestVLTrackScore` | VictoriaLogs `v1.49.0` (the latest `v1.4x` release) and every `v1.5x` minor at its latest patch | The default local stack (`test/e2e-compat/docker-compose.yml`) is pinned to: @@ -568,7 +568,7 @@ Support window policy: - Loki: current minor family plus one minor behind - Grafana runtime: pinned current family gets the fuller Drilldown runtime contract, and pull requests also run smaller current-family and previous-family smoke profiles; the full runtime matrix stays on scheduled/manual coverage - Logs Drilldown: current family plus one family behind -- VictoriaLogs: the latest line plus the previous one (`v1.5x.x` and `v1.4x.x`), tested on at most the latest 3 releases of each; `v1.3x.x` and older are unsupported (see [VictoriaLogs compatibility](compatibility-victorialogs.md#support-policy)) +- VictoriaLogs: `v1.49.0` (the latest `v1.4x` release) plus every `v1.5x` release, each supported minor tested at its latest patch with a published image; `v1.48.x` and older are unsupported (see [VictoriaLogs compatibility](compatibility-victorialogs.md#support-policy)) Grafana runtime profiles from the manifest: diff --git a/docs/translation-reference.md b/docs/translation-reference.md index 1cac08e73..2458f025f 100644 --- a/docs/translation-reference.md +++ b/docs/translation-reference.md @@ -82,7 +82,7 @@ These stages are executed at the proxy level (VL has no native equivalents): | LogQL | Implementation | |---|---| | `\| decolorize` | ANSI escape sequence stripping via regex | -| `\| ip("10.0.0.0/8")` | IP address, CIDR or range filtering. Arguments are validated at parse time; line filters accept only `\|= ip(...)` and `!= ip(...)`. Line filters translate to regular-expression approximations, so non-octet IPv4 prefixes, IPv4 ranges and IPv6 forms are not matched exactly. Label filters (`addr = ip("cidr")`) use VictoriaLogs `ipv4_range()` for IPv4 CIDRs when the backend supports it, otherwise a regular expression | +| `\| ip("10.0.0.0/8")` | IP address, CIDR or range filtering. Arguments are validated at parse time; line filters accept only `\|= ip(...)` and `!= ip(...)`. Line filters translate to regular-expression approximations, so non-octet IPv4 prefixes, IPv4 ranges and IPv6 forms are not matched exactly. Label filters (`addr = ip("cidr")`) use VictoriaLogs `ipv4_range()` for IPv4 CIDRs and a regular expression for the other forms | | `\| line_format` (templates) | Go `text/template` (ToUpper, ToLower, default, etc.) with bounded work: 64 KiB output per line and 16 MiB per response, plus template depth, input and execution limits. Exceeding a limit returns HTTP 400 | ## Metric Queries From 5e1ce74dfe7324aed3c0854d02ad2000ec13f6bb Mon Sep 17 00:00:00 2001 From: szibis Date: Sun, 18 Oct 2026 12:26:35 +0200 Subject: [PATCH 6/7] docs(changelog): VictoriaLogs v1.49.0 support floor --- CHANGELOG.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bda02b904..d3c94d0c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Breaking Changes + +- **The supported VictoriaLogs floor is v1.49.0: VictoriaLogs v1.48.0 and older + are no longer supported.** Supported is v1.49.0 (the latest v1.4x release) and + every v1.5x release. `-backend-min-version` now defaults to `v1.49.0` (was + `v1.40.0`), in the flag, the Go fallback used when it is empty and the Helm + value `extraArgs.backend-min-version`; `logsql.MinSupportedMinor` is 49. A + deployment on VictoriaLogs v1.40 to v1.48 stops at startup with an error that + names `-backend-allow-unsupported-version`: upgrade VictoriaLogs to v1.49.0 or + newer. A Helm release that pins an older `backend-min-version` still starts + against an older backend, with a warning on every start; a version below the + floor is unsupported and untested, and the proxy no longer carries the + fallbacks that served it (see Changed). `-backend-allow-unsupported-version=true` + starts the proxy against such a backend at the operator's risk, and + `-backend-version-strict=true` still turns a missing or too old version into a + startup failure. + +### Changed + +- **Supported and tested VictoriaLogs versions are named in the README and the + compatibility docs.** Tested in CI: v1.49.0, v1.50.0, v1.51.1, v1.52.0 and + v1.53.0, every supported minor at its latest patch that has a published image + (v1.52.1 has none, so v1.52.0); pull requests keep the v1.52.0 pin. The + support window in `compatibility-matrix.json` is now "the latest v1.4x release + plus every v1.5x release" (was "the latest two lines, three releases each"): + v1.47.0 and v1.48.0 leave the matrix and v1.50.0 joins it. + `TestVictoriaLogsSupportPolicy` enforces the rule: the floor, the lowest + matrix version, the `-backend-min-version` default (flag, Go fallback, Helm + value) and `logsql.MinSupportedMinor` agree, the matrix holds exactly one v1.4x + entry (the floor) and every v1.5x minor from v1.50 up once, the pin is a + tested version, and the README supported-versions table lists the same + versions. +- **The weekly VictoriaLogs matrix runs the range-metric table on every tested + version.** Besides the compatibility score and the two version-sensitive + checks, each version now runs `TestCompat_Unwrap*`, `TestRangeMetric*` and + `TestGrafanaClickout_*` against Loki (the checks that failed on VictoriaLogs + v1.40.0 because it ignores the `stats_query_range` `offset` argument), with an + explicit test and job timeout. +- **The fallbacks for VictoriaLogs before v1.49.0 are removed, and an unknown + backend version behaves as v1.49.0.** The `stats_query_range` `offset` + argument (v1.45) is always sent: the epoch-aligned-only tumbling and sliding + grids, the raw-row route for unaligned starts and the gate of the + `name_extracted` pushdown are gone (raw evaluation remains only where it does + not depend on the version: buckets below 1 ms, byte and row limits). The + metadata substring filter (v1.49) is always forwarded as `q` and + `filter=substring`; the proxy-side search window stays because it also serves + the label-values browse index and offset/limit paging. `ip()` filters always + use `ipv4_range()` (v1.45) for IPv4 and keep the regular expression only for + IPv6 and unparsable input. `logsql.Capabilities` no longer has `StatsRateSum`, + `FieldIPv4Range` or `MetadataSubstring`. The v1.50+ behaviour (dense pattern + windowing, column-index `field_values`, the v1.51 `coalesce` pipe and the + v1.52 parse-error echo) keeps its fallback down to v1.49.0. The background + `/metrics` version probe still retries every five minutes while the version + is unknown. + ## [2.18.1] - 2026-10-18 ### Changed From b7113d7f4a365ee1f67a4c2d69a41f5bc367df65 Mon Sep 17 00:00:00 2001 From: szibis Date: Sun, 18 Oct 2026 12:34:26 +0200 Subject: [PATCH 7/7] docs(conformance): correct the stats bucket case and matrix bands for the v1.49.0 floor The pre-v1.45 stats bucket case no longer describes version gates that were removed: it records the unsupported-version behaviour as a kept difference of the support floor. The ordered JSON pushdown case no longer says it is gated off below v1.45, the VictoriaLogs edge-case bands in the manifest list only the supported v1.49 line, and the testing guide's example image is a supported version. --- .../json-extracted-metric-pushdown.yaml | 4 +-- .../versions/stats-bucket-label-v1.45.yaml | 34 +++++++++---------- conformance/reports/parity-gaps.md | 10 +++--- docs/testing.md | 2 +- test/e2e-compat/compatibility-matrix.json | 4 +-- 5 files changed, 26 insertions(+), 28 deletions(-) diff --git a/conformance/registry/cases/semantics/json-extracted-metric-pushdown.yaml b/conformance/registry/cases/semantics/json-extracted-metric-pushdown.yaml index e194c5a5e..4b04e2c65 100644 --- a/conformance/registry/cases/semantics/json-extracted-metric-pushdown.yaml +++ b/conformance/registry/cases/semantics/json-extracted-metric-pushdown.yaml @@ -59,8 +59,8 @@ gap: proxy: 'orderedJSONPushdownLabelOK refused name_extracted; raw-row evaluator answered (502 at the row limit)' planned_test: 'TestCompat_ExtractedLabelMetricPushdown (test/e2e-compat, group core); proxy TestOrderedJSONExtractedMetricPushdownMatchesLoki, TestOrderedJSONExtractedMetricProbeKeepsRawEvaluator, TestLogfmtExtractedMetricPushdownMatchesLoki, TestOrderedJSONExtractedMetricDifferential, TestOrderedJSONExtractedMetricPushdownOnSupportedBackends; translator TestExtractedLabelPipes' note: | - Gated off below VictoriaLogs v1.45 (versions/stats-bucket-label-v1.45), where the raw-row evaluator - answers as before. Open items: an ungrouped range metric stays on raw rows + Runs on every supported VictoriaLogs version: the stats_query_range offset arg it needs for bucket + edges is present from v1.45 (versions/stats-bucket-label-v1.45). Open items: an ungrouped range metric stays on raw rows (semantics/extracted-ungrouped-metric-raw-rows); service_name_extracted and detected_level_extracted stay on the raw evaluator (semantics/extracted-derived-labels-raw-evaluator); two differences against Loki (semantics/extracted-metric-known-differences). Related, unchanged: semantics/unwrap-range-last-point diff --git a/conformance/registry/cases/versions/stats-bucket-label-v1.45.yaml b/conformance/registry/cases/versions/stats-bucket-label-v1.45.yaml index e6d9ea939..e8dcd987b 100644 --- a/conformance/registry/cases/versions/stats-bucket-label-v1.45.yaml +++ b/conformance/registry/cases/versions/stats-bucket-label-v1.45.yaml @@ -1,5 +1,5 @@ id: versions/stats-bucket-label-v1.45 -title: Stats-bucket metrics put a line on a bucket edge one bucket late on VictoriaLogs before v1.45 (open) +title: Stats-bucket metrics put a line on a bucket edge one bucket late on VictoriaLogs before v1.45 (unsupported versions) proves: - window-bounds-and-step-alignment - loki_api_v1_query_range @@ -18,19 +18,19 @@ why: | missing and the neighbours carry the wrong counts: `proxy "3"` where Loki has `"2"`). Bisected on isolated stacks, one VictoriaLogs per release. - `supportsStatsRangeOffset` already makes the sliding window decline a start - that is not on the epoch grid below v1.45 (`slidingStatsBucket`), but an - aligned start still passes and an edge line is misplaced; the existing - pushdown of every `| json` metric, and the `name_extracted` pushdown before - it was gated, are affected. Real data rarely sits on a bucket edge (nanosecond - timestamps), so this shows on synthetic fixtures and second-aligned - generators. + Real data rarely sits on a bucket edge (nanosecond timestamps), so this shows + on synthetic fixtures and second-aligned generators. - The `name_extracted` stats pushdown is gated on `supportsStatsRangeOffset` - (v1.45+); older backends keep the raw-row evaluator these shapes had, which - is exact within `-manual-range-metric-row-limit` - (TestOrderedJSONExtractedMetricPushdownOnSupportedBackends). Not gated: the - other pushdowns. + Decision: supported VictoriaLogs starts at v1.49.0, so the `offset` arg is + present on every supported release and the proxy keeps no pre-v1.45 + fallback: the version gate, the epoch-aligned-only bucket grids and the + raw-row routes that served those backends are removed, and an unknown + version behaves as v1.49.0 + (TestSlidingRangeMetric_AnchoredBucketsOnFloorAndUnknownVersion, + TestOrderedJSONExtractedMetricPushdownOnSupportedBackends). A backend older + than v1.45 can still be started with `-backend-allow-unsupported-version` + and answers with the misplaced edge samples above; it is unsupported and + untested. request: method: GET path: /loki/api/v1/query_range @@ -49,12 +49,12 @@ compare: expect: | Loki's samples. v1.42.0 and v1.44.0 answer one bucket late for an edge line. gap: - status: open + status: owner-kept impact: edge effort: M area: 'stats-bucket relabel (internal/proxy range_metric_compat.go setSlidingStatsRangeParams / slidingStatsBucket; sliding_window_stats.go)' loki: 'pkg/logql range vector (T-range, T]' - proxy: 'stats_query_range without offset aligns buckets to the epoch; the 1 ns edge shift is not applied' - planned_test: 'TestRangeMetricCompatibilityJSONFilterPushdown on VictoriaLogs v1.40-v1.44 (version matrix); fix by relabelling edge lines or declining the pushdown below v1.45' - note: 'Evidence: bisect v1.42.0 FAIL, v1.44.0 FAIL, v1.45.0 PASS, v1.46.0 PASS, v1.52.0 PASS. The v1.45.0 changelog entry for the offset arg is the introducing release.' + proxy: 'stats_query_range without offset aligns buckets to the epoch; the 1 ns edge shift is not applied (VictoriaLogs before v1.45, below the supported floor v1.49.0)' + planned_test: 'TestRangeMetricCompatibilityJSONFilterPushdown on every supported VictoriaLogs version (compat-vl vl-matrix job); TestSlidingRangeMetric_AnchoredBucketsOnFloorAndUnknownVersion' + note: 'Evidence: bisect v1.42.0 FAIL, v1.44.0 FAIL, v1.45.0 PASS, v1.46.0 PASS, v1.52.0 PASS. The v1.45.0 changelog entry for the offset arg is the introducing release; the supported floor is v1.49.0, so no fallback is kept for the earlier releases.' clusters: [] diff --git a/conformance/reports/parity-gaps.md b/conformance/reports/parity-gaps.md index be3cba9bf..df15e60a7 100644 --- a/conformance/reports/parity-gaps.md +++ b/conformance/reports/parity-gaps.md @@ -91,11 +91,11 @@ Last run: 2026-10-05T20:16:15Z on `fcaccfcc`, window [1791220800, 1791224400]. | 70 | `semantics/unwrap-conversion-error-undetected-shapes` — A few query shapes answer 200 where Loki fails with SampleExtractionErr, because no VictoriaLogs query of theirs carries the conversion c... | open | edge | — | — | M | internal/proxy/unwrap_conversion_error.go unwrapErrorProbes (opaque expressions), unwrapCountingQuery (pipes after stats), unwrapLokiLabe... | pkg/logql/evaluator.go:726-733 | TestCompat_UnwrapConversionErrorParity open/* asserts Loki's 400 and skips naming this case (absent_over_time, label_replace); TestUnwrap... | | 71 | `semantics/unwrap-gate-parsefloat-divergences` — The unwrap sample gate drops a few values Loki's conversions accept or reject (open) | open | edge | — | — | S | internal/translator/unwrap_gate.go (UnwrapNumberPattern, UnwrapDurationPattern, UnwrapBytesPattern) | pkg/logql/log/metrics_extraction.go:316-338 convertFloat, convertDuration, convertBytes | TestUnwrapNumberPatternMatchesParseFloat (internal/translator) pins the known differences | | 72 | `semantics/unwrap-sum-without-extracted-label-collision` — a parsed key that is also a stream label is not renamed to key_extracted after a without fold (open) | open | edge | — | — | M | internal/proxy withParsedGroupLabels (a parsed group key that equals a stream label of the series); internal/translator parsedSeriesLabels | pkg/logql/log/labels.go and parser.go (duplicateSuffix "_extracted") | TestSumWithoutExtractedLabelCollision (internal/proxy, emulator); an e2e test vs Loki in group semantics when fixed | -| 73 | `versions/stats-bucket-label-v1.45` — Stats-bucket metrics put a line on a bucket edge one bucket late on VictoriaLogs before v1.45 (open) | open | edge | — | — | M | stats-bucket relabel (internal/proxy range_metric_compat.go setSlidingStatsRangeParams / slidingStatsBucket; sliding_window_stats.go) | pkg/logql range vector (T-range, T] | TestRangeMetricCompatibilityJSONFilterPushdown on VictoriaLogs v1.40-v1.44 (version matrix); fix by relabelling edge lines or declining t... | -| 74 | `metric-identity/structured-metadata-splits-series` — Loki splits a stream's metric series per structured-metadata set; the proxy keeps the stream (owner-accepted deviation) | owner-kept | explore-visible | 173 | 37 | L | internal/translator series identity (stats by stream); VictoriaLogs cannot group by a row's remaining fields | pkg/logql/log/metrics_extraction.go:102-120 builder.Add(StructuredMetadataLabel) -> GroupedLabels | differential run cluster count (bench/parity) | -| 75 | `parsed-identity/dynamic-key-parsers` — `\| json` and `\| logfmt` metric series keep the stream identity by default (owner-accepted deviation, opt-in flag) | owner-kept | explore-visible | 95 | 29 | L | internal/translator parser series identity; -exact-parser-series-identity | pkg/logql/log/parser.go label extraction into the sample labels | differential run cluster count (bench/parity) | -| 76 | `profiles/label-filter-on-line-field` — A label filter on a line key no earlier stage exposes matches like Loki | owner-kept | explore-visible | — | — | M | internal/proxy label-filter refill (-label-filter-refill-max-pages); metric queries on keys no stage exposes | pkg/logql/log/label_filter.go (filter reads only labels the pipeline set) | TestCompat_LabelFilterOnLineField (exists, profile tests) | -| 77 | `profiles/detected-fields-no-extracted-suffix` — detected_fields lists service / service.name without Loki's _extracted suffix (owner decision) | owner-kept | drilldown-visible | — | — | S | internal/proxy/label_handlers.go handleDetectedFields | pkg/logql/log/parser.go:25 duplicateSuffix | bench/parity diff rule (documented facet, service fields only) | +| 73 | `metric-identity/structured-metadata-splits-series` — Loki splits a stream's metric series per structured-metadata set; the proxy keeps the stream (owner-accepted deviation) | owner-kept | explore-visible | 173 | 37 | L | internal/translator series identity (stats by stream); VictoriaLogs cannot group by a row's remaining fields | pkg/logql/log/metrics_extraction.go:102-120 builder.Add(StructuredMetadataLabel) -> GroupedLabels | differential run cluster count (bench/parity) | +| 74 | `parsed-identity/dynamic-key-parsers` — `\| json` and `\| logfmt` metric series keep the stream identity by default (owner-accepted deviation, opt-in flag) | owner-kept | explore-visible | 95 | 29 | L | internal/translator parser series identity; -exact-parser-series-identity | pkg/logql/log/parser.go label extraction into the sample labels | differential run cluster count (bench/parity) | +| 75 | `profiles/label-filter-on-line-field` — A label filter on a line key no earlier stage exposes matches like Loki | owner-kept | explore-visible | — | — | M | internal/proxy label-filter refill (-label-filter-refill-max-pages); metric queries on keys no stage exposes | pkg/logql/log/label_filter.go (filter reads only labels the pipeline set) | TestCompat_LabelFilterOnLineField (exists, profile tests) | +| 76 | `profiles/detected-fields-no-extracted-suffix` — detected_fields lists service / service.name without Loki's _extracted suffix (owner decision) | owner-kept | drilldown-visible | — | — | S | internal/proxy/label_handlers.go handleDetectedFields | pkg/logql/log/parser.go:25 duplicateSuffix | bench/parity diff rule (documented facet, service fields only) | +| 77 | `versions/stats-bucket-label-v1.45` — Stats-bucket metrics put a line on a bucket edge one bucket late on VictoriaLogs before v1.45 (unsupported versions) | owner-kept | edge | — | — | M | stats-bucket relabel (internal/proxy range_metric_compat.go setSlidingStatsRangeParams / slidingStatsBucket; sliding_window_stats.go) | pkg/logql range vector (T-range, T] | TestRangeMetricCompatibilityJSONFilterPushdown on every supported VictoriaLogs version (compat-vl vl-matrix job); TestSlidingRangeMetric_... | | 78 | `profiles/json-line-stored-without-line` — A JSON line pushed without _msg is stored without its line (documented) | documented | explore-visible | — | — | L | client push configuration (disable_message_parsing=1); docs/KNOWN_ISSUES.md | pkg/distributor push keeps the line verbatim | cases/profiles/stage-field-exposure with disable_message_parsing pushes | | 79 | `drilldown/detected-labels-sampled-cardinality` — detected_labels cardinality is sampled from the window's last minutes; Loki counts every stream its ingesters hold (documented) | documented | drilldown-visible | 20 | 2 | M | internal/proxy/patterns.go handleDetectedLabels | pkg/ingester/instance.go LabelsWithValues | bench/parity diff rule (documented facet) | | 80 | `profiles/volume-detected-level-extension` — index/volume answers targetLabels=detected_level; Loki returns no volume for it (documented proxy extension) | documented | drilldown-visible | 17 | 2 | S | internal/proxy/volume.go | pkg/storage volume index (stream labels only) | differential run row volume by detected_level | diff --git a/docs/testing.md b/docs/testing.md index 6de63975a..aeff672da 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -587,7 +587,7 @@ The stack is version-parameterized through compose environment variables: ```bash LOKI_IMAGE=grafana/loki:3.6.10 \ -VICTORIALOGS_IMAGE=victoriametrics/victoria-logs:v1.48.0 \ +VICTORIALOGS_IMAGE=victoriametrics/victoria-logs:v1.49.0 \ GRAFANA_IMAGE=grafana/grafana:12.4.2 \ docker compose -f test/e2e-compat/docker-compose.yml up -d --build ``` diff --git a/test/e2e-compat/compatibility-matrix.json b/test/e2e-compat/compatibility-matrix.json index 60da9f17b..0415a70ef 100644 --- a/test/e2e-compat/compatibility-matrix.json +++ b/test/e2e-compat/compatibility-matrix.json @@ -374,15 +374,13 @@ "edge_cases": [ { "versions": [ - "v1.47.x", - "v1.48.x", "v1.49.x" ], "focus": [ "stats_query_range shape", "structured metadata shaping", "drilldown-derived labels and values", - "stats_query_range offset (v1.45+) and metadata substring filter (v1.49)" + "stats_query_range offset and metadata substring filter (always on: the v1.49.0 floor)" ] }, {