From d64f23640d865f92370bf0064e634583fe56f82c Mon Sep 17 00:00:00 2001 From: szibis Date: Mon, 12 Oct 2026 16:14:14 +0200 Subject: [PATCH 1/7] fix: a scalar instant answer carries its sample time in integer milliseconds, like Loki Loki's query frontend writes a scalar through marshalScalar, which hands the millisecond timestamp to model.TimeFromUnix (seconds), so 1 answers [1791811168123,"1"] while vector(1) answers seconds. The proxy stamped scalars in seconds. Every scalar the proxy answers without a backend now carries the evaluation time as integer milliseconds; vector samples are unchanged. The e2e compares the sample time and value of each constant against Loki. --- CHANGELOG.md | 79 +++++------------- .../semantics/scalar-sample-time-units.yaml | 69 ++++++++++++++++ conformance/registry/generated/wiring.json | 80 +++++-------------- conformance/reports/parity-gaps.md | 1 + internal/proxy/binary_matching_test.go | 3 +- internal/proxy/binary_scalar.go | 8 +- internal/proxy/instant_result_type_test.go | 65 ++++++++++++--- internal/proxy/metric_agg.go | 22 +++-- test/e2e-compat/instant_result_type_test.go | 58 +++++++++++++- 9 files changed, 241 insertions(+), 144 deletions(-) create mode 100644 conformance/registry/cases/semantics/scalar-sample-time-units.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index e2003897d..acc496489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,68 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [2.14.1] - 2026-10-12 - -### CI - -- **Release metadata pull requests are opened by a maintainer.** The release - workflows (`auto-release.yaml`, `release.yaml`) still compute the metadata and - push the signed `release/metadata-vX.Y.Z` branch, but no longer open or - auto-merge its pull request. A maintainer opens it from that branch, so the - pull request and the commit that lands on main carry the maintainer's - authorship. - -## [2.14.0] - 2026-10-12 - -### Fixed - -- **`unwrap duration_seconds(label)` is converted like `duration(label)`, as Loki - does, instead of answering an empty matrix.** `sum by (service_name) - (sum_over_time({service_name="cache-redis"} | logfmt | unwrap - duration_seconds(ttl) [5m]))` over `ttl=250ms` lines returned no series on the - proxy and 7.5 per window on Loki 3.7.7. Loki converts `duration_seconds()` with - `convertDuration` (`time.ParseDuration`, in seconds), the same function as - `duration()` (`pkg/logql/syntax/extractor.go:63-71`, `OpConvDurationSeconds`; - `pkg/logql/log/metrics_extraction.go` `convertDuration`). The translator - recognised only the `duration(` and `bytes(` wrappers, so it treated - `duration_seconds(ttl)` as the name of a label that no line has: nothing - matched the plain-number gate, and the conversion-error check, which counts the - rows the gate rejects, saw no duration to count. Now `duration_seconds()` takes - the duration route everywhere `duration()` does: the label is extracted, - gated by the `time.ParseDuration` syntax (`250ms`, `1.5s`, `1h`, `-1s`, `5.s`, - `.5s`) and converted to seconds by VictoriaLogs for the sum, rate, average, - maximum, minimum and quantile of a range, tumbling, sliding or instant window, - the raw evaluators convert it with `time.ParseDuration`, and a value Go rejects - (`abc`, `1d`, `42`) fails the query with Loki's 400 `SampleExtractionErr` text - unless a label filter after the unwrap drops the error. The proxy now answers - `duration_seconds(x)` with exactly the queries and answer of `duration(x)`. - The window of a sum, maximum or minimum over time on a range query is a - separate open difference that applies to either conversion. (#729) - -## [2.13.0] - 2026-10-12 - ### Fixed -- **An empty pattern line filter selects the lines Loki selects: `|> ""` keeps no - line of an ordinary stream and `!> ""` keeps them all.** `{namespace="prod"} - |> ""` returned the page on the proxy and no line on Loki 3.7.7, and - `{namespace="prod"} !> ""` returned nothing where Loki returns the page. An - empty pattern is valid in Loki: it parses without error and builds a pattern - matcher with no expression, which accepts only an empty line - (`pkg/logql/log/pattern/pattern.go` `ParseLineFilter`, `Matcher.Test`), so - `|> ""` keeps the empty lines and `!> ""` drops them. It is not the match-all - filter that `|= ""` is: `|> "" or "x"` keeps the empty lines and the lines that - match `x`. The proxy translated the empty pattern to the regexp `.*`, which - VictoriaLogs read as every line; it now sends `^$`, the empty line, in log - queries, in range and instant metric queries (the filter is part of the stats - query, nothing is filtered in the proxy), after a parser stage and inside an - `or` chain. A `!=`, `!~` or `!>` written right after a pattern filter, with no - pipe between them (`|> "a <_>" != "x"`), is a filter of its own again: the - proxy read it as part of the pattern, dropped both filters and returned every - line. Non-empty patterns are otherwise unchanged. Covered by the edge table - `TestTranslateEmptyPatternLineFilter`, `TestEmptyPatternLineFilterReachesVictoriaLogs` - for the log, range and instant paths, and `TestCompat_PatternLineFilter` - against Loki (#743). +- **A scalar instant answer carries its sample time in integer milliseconds, as + Loki does, not in seconds (#TBD).** `GET /loki/api/v1/query?query=1` (and + `1 + 1`, `2 * (3 + 4)`, `1 > bool 2`) was answered `[1791811168.123,"1"]`; + Loki 3.7.7 answers `[1791811168123,"1"]`, while `vector(1)` is in seconds on + both. The engine keeps the scalar time in milliseconds (`pkg/logql/engine.go` + `evalLiteral`), the encoder of `pkg/util/marshal` would write seconds, but the + HTTP answer is written by the query frontend: `pkg/querier/queryrange/prometheus.go` + `marshalScalar` builds the time with `model.TimeFromUnix(TimestampMs)`, which + reads seconds, so the millisecond count is printed as is, whereas + `marshalVector` uses `model.Time(TimestampMs)`. The earlier fix (#735) followed + `pkg/util/marshal` and stamped seconds. Every scalar shape the proxy answers + without a backend (a literal, a binary operation of literals, a comparison + with `bool`) now carries the evaluation time truncated to the millisecond; + vector samples are unchanged. `1 / 0` is left as it is: Loki 3.7.7 answers a + 500 `failed to enqueue request` after about 40 s (its frontend cannot ship + the folded NaN to the querier) where the proxy answers a 200 scalar `NaN`; that + difference awaits an owner decision. `TestCompat_InstantResultTypeAlwaysSet` now compares the + sample time and value of every constant against Loki + (`semantics/scalar-sample-time-units`). ## [2.12.0] - 2026-10-12 diff --git a/conformance/registry/cases/semantics/scalar-sample-time-units.yaml b/conformance/registry/cases/semantics/scalar-sample-time-units.yaml new file mode 100644 index 000000000..8f07a1e68 --- /dev/null +++ b/conformance/registry/cases/semantics/scalar-sample-time-units.yaml @@ -0,0 +1,69 @@ +id: semantics/scalar-sample-time-units +title: A scalar instant answer is stamped in integer milliseconds, like Loki (fixed) +proves: + - loki_api_v1_query +consumer: grafana-datasource +why: | + `/loki/api/v1/query?query=1&time=1791811168123456789` is answered by Loki + 3.7.7 (measured on an isolated instance, the compose config) with + `{"resultType":"scalar","result":[1791811168123,"1"]}`: the sample time is the + count of milliseconds, not seconds. `1 + 1`, `2 * (3 + 4)` and + `1 > bool 2` are stamped the same way. `vector(1)` and `1 + vector(1)` are + stamped `1791811168.123`, in seconds. + + The cause is the query frontend's own encoder, not the engine's. The engine + keeps the time as milliseconds (pkg/logql/engine.go evalLiteral: + `T: q.params.Start().UnixNano() / int64(time.Millisecond)`), and the encoder + of pkg/util/marshal (encodeScalar, `model.Time(v.T).String()`) would write + seconds, but the HTTP answer does not go through it: ResultToResponse + (pkg/querier/queryrange/marshal.go) turns the promql.Scalar into a + LokiPromResponse, and LokiPromResponse.marshalScalar + (pkg/querier/queryrange/prometheus.go) builds the time with + `model.TimeFromUnix(r.Samples[0].TimestampMs)`. TimeFromUnix takes seconds, + so the millisecond count is multiplied by 1000 and model.Time.String divides + it by 1000 again: the millisecond count is printed as if it were seconds. + marshalVector uses `model.Time(TimestampMs)` and is correct, which is why a + vector is in seconds. + + The proxy stamped a scalar with seconds (`[1791811168.123,"1"]`), written + from the reading of pkg/util/marshal that the frontend does not follow (#735). + A client that reads the scalar time as seconds reads a date in the year + 58000; one that reads it as Loki's wire format (the Grafana Loki datasource + decodes through loghttp.Scalar, which parses a number) now gets the same + number. The value and the resultType were already Loki's. + + Not covered here: `1 / 0`, `0 / 0` and `-1 / 0`. Loki folds a division of + literals through MergeBinOp, which makes a division by zero NaN + (pkg/logql/syntax/ast.go mustNewBinOpExpr -> reduceBinOp -> MergeBinOp, + OpTypeDiv). Loki 3.7.7 with the compose config answers HTTP 500 + `failed to enqueue request` after about 40 s for these queries (its frontend + retries the grpc send, which fails with `grpc: error while marshaling: + unsupported value: NaN`), while the proxy answers 200 with a scalar `NaN` + (stamped in milliseconds like the other scalars). Whether to follow that + failure is an owner decision, not made here. +request: + method: GET + path: /loki/api/v1/query + params: + query: '1' + time: '1791811168123456789' + headers: + X-Scope-OrgID: "0" + X-Loki-Response-Encoding-Flags: categorize-labels +compare: + mode: observed + fields: [data.result] + expect: | + [1791811168123,"1"] for a scalar; [1791811168.123,"1"] for vector(1). +gap: + status: fixed + fixed_by: "#TBD" + impact: grafana-datasource + visual: none + visual_reason: 'instant scalar literals only; no Explore range panel renders them, the fix is proven by the sample-time comparison against Loki in the e2e' + effort: S + area: 'internal/proxy evaluateConstantInstantQuery (metric_agg.go), binaryConstantResponse (binary_scalar.go)' + loki: 'pkg/logql/engine.go:700-702 evalLiteral; pkg/querier/queryrange/marshal.go:97-112 ResultToResponse; pkg/querier/queryrange/codec.go:1221-1227,1761-1772 DecodeResponse/toProtoScalar; pkg/querier/queryrange/prometheus.go:163-176 marshalScalar (model.TimeFromUnix) vs :90-102 marshalVector' + proxy: 'a scalar answer carries the evaluation time as integer milliseconds; vector() keeps seconds' + planned_test: 'TestCompat_InstantResultTypeAlwaysSet (test/e2e-compat, subtest constant sample time); TestInstantConstantsCarrySecondsTimestamps and TestBinaryScalarSubtreesAndIEEEValues (internal/proxy)' + note: 'The e2e ran against an isolated Loki 3.7.7 and VictoriaLogs v1.52.0 with a proxy built from the branch.' diff --git a/conformance/registry/generated/wiring.json b/conformance/registry/generated/wiring.json index 93c3cc852..c72b1be9a 100644 --- a/conformance/registry/generated/wiring.json +++ b/conformance/registry/generated/wiring.json @@ -181,24 +181,20 @@ "test/e2e-compat/metadata_scan_admission_test.go" ], "loki_api_v1_query": [ + "internal/proxy/binary_matching_test.go", "internal/proxy/instant_result_type_test.go", "internal/proxy/parenthesized_range_test.go", "internal/proxy/series_limit_routes_test.go", "internal/proxy/tenant_query_limits_test.go", "internal/proxy/tumbling_axis_parity_test.go", "internal/proxy/unwrap_conversion_error_test.go", - "internal/proxy/unwrap_convert_test.go", - "internal/proxy/unwrap_samples_test.go", - "internal/translator/unwrap_gate_test.go", "test/e2e-compat/extracted_suffix_collision_test.go", "test/e2e-compat/instant_result_type_test.go", "test/e2e-compat/parenthesized_range_or_filters_test.go", "test/e2e-compat/parser_capture_identity_test.go", - "test/e2e-compat/pattern_line_filter_test.go", "test/e2e-compat/plain_name_after_parser_test.go", "test/e2e-compat/range_metric_offset_series_limit_test.go", "test/e2e-compat/unwrap_conversion_error_test.go", - "test/e2e-compat/unwrap_duration_seconds_test.go", "test/e2e-compat/unwrap_ungrouped_sum_test.go" ], "loki_api_v1_query_range": [ @@ -215,22 +211,17 @@ "internal/proxy/tenant_query_limits_test.go", "internal/proxy/tumbling_axis_parity_test.go", "internal/proxy/unwrap_conversion_error_test.go", - "internal/proxy/unwrap_convert_test.go", - "internal/proxy/unwrap_samples_test.go", "internal/translator/extracted_suffix_test.go", "internal/translator/regexp_escaping_test.go", - "internal/translator/unwrap_gate_test.go", "test/e2e-compat/drilldown_field_breakdown_test.go", "test/e2e-compat/extracted_suffix_collision_test.go", "test/e2e-compat/heavy_query_protection_test.go", "test/e2e-compat/line_fields_parity_test.go", "test/e2e-compat/parenthesized_range_or_filters_test.go", - "test/e2e-compat/pattern_line_filter_test.go", "test/e2e-compat/plain_name_after_parser_test.go", "test/e2e-compat/range_metric_offset_series_limit_test.go", "test/e2e-compat/stage_field_exposure_test.go", "test/e2e-compat/unwrap_conversion_error_test.go", - "test/e2e-compat/unwrap_duration_seconds_test.go", "test/e2e-compat/unwrap_sample_validity_test.go", "test/e2e-compat/unwrap_ungrouped_sum_test.go" ], @@ -464,11 +455,6 @@ "semantics/partial-parse-before-error": [ "internal/proxy/ordered_json_filter_pushdown_test.go" ], - "semantics/pattern-line-filter": [ - "internal/proxy/pattern_line_filter_test.go", - "internal/translator/pattern_line_filter_test.go", - "test/e2e-compat/pattern_line_filter_test.go" - ], "semantics/pipeline-error-text-parity": [ "internal/proxy/ordered_json_filter_pushdown_test.go", "internal/proxy/ordered_json_metric_test.go" @@ -477,6 +463,11 @@ "internal/proxy/plain_stream_labels_test.go", "test/e2e-compat/plain_name_after_parser_test.go" ], + "semantics/scalar-sample-time-units": [ + "internal/proxy/binary_matching_test.go", + "internal/proxy/instant_result_type_test.go", + "test/e2e-compat/instant_result_type_test.go" + ], "semantics/sliding-window-recurrence-exact": [ "internal/proxy/sliding_window_stats_test.go", "test/e2e-compat/heavy_query_protection_test.go" @@ -497,12 +488,6 @@ "internal/proxy/unwrap_conversion_error_differential_test.go", "test/e2e-compat/unwrap_conversion_error_test.go" ], - "semantics/unwrap-duration-seconds": [ - "internal/proxy/unwrap_convert_test.go", - "internal/proxy/unwrap_samples_test.go", - "internal/translator/unwrap_gate_test.go", - "test/e2e-compat/unwrap_duration_seconds_test.go" - ], "semantics/unwrap-gate-parsefloat-divergences": [ "internal/proxy/unwrap_bytes_gate_property_test.go", "internal/proxy/unwrap_conversion_error_test.go", @@ -550,8 +535,7 @@ "internal/proxy/ordered_json_filter_pushdown_test.go", "internal/proxy/unwrap_conversion_error_test.go", "internal/proxy/volume_test.go", - "test/e2e-compat/unwrap_conversion_error_test.go", - "test/e2e-compat/unwrap_duration_seconds_test.go" + "test/e2e-compat/unwrap_conversion_error_test.go" ], "tenant-query-limits": [ "internal/proxy/drilldown_breakdown_exact_test.go", @@ -569,7 +553,7 @@ "test/e2e-compat/unwrap_ungrouped_sum_test.go" ] }, - "registry_items": 300, + "registry_items": 301, "routes_without_registry_item": [], "test_to_items": { "cmd/proxy/main_test.go": [ @@ -620,6 +604,10 @@ "operator-configurable-limits", "semantics/backend-aborted-response" ], + "internal/proxy/binary_matching_test.go": [ + "loki_api_v1_query", + "semantics/scalar-sample-time-units" + ], "internal/proxy/compat_cache_freshness_test.go": [ "semantics/metadata-answers-include-last-24h-like-loki" ], @@ -662,7 +650,8 @@ ], "internal/proxy/instant_result_type_test.go": [ "loki_api_v1_query", - "semantics/instant-result-type-missing" + "semantics/instant-result-type-missing", + "semantics/scalar-sample-time-units" ], "internal/proxy/instant_unwrap_sum_test.go": [ "profiles/sanitized-json-key-grouping" @@ -793,9 +782,6 @@ "internal/proxy/parser_capture_identity_test.go": [ "parsed-label-series-identity" ], - "internal/proxy/pattern_line_filter_test.go": [ - "semantics/pattern-line-filter" - ], "internal/proxy/plain_stream_labels_test.go": [ "loki_api_v1_query_range", "semantics/plain-name-after-parser-reads-stream-label" @@ -881,16 +867,6 @@ "semantics/unwrap-gate-parsefloat-divergences", "status-400" ], - "internal/proxy/unwrap_convert_test.go": [ - "loki_api_v1_query", - "loki_api_v1_query_range", - "semantics/unwrap-duration-seconds" - ], - "internal/proxy/unwrap_samples_test.go": [ - "loki_api_v1_query", - "loki_api_v1_query_range", - "semantics/unwrap-duration-seconds" - ], "internal/proxy/unwrap_ungrouped_sum_differential_test.go": [ "metric-series-identity", "semantics/unwrap-range-last-point", @@ -923,9 +899,6 @@ "profiles/sanitized-json-key-filter", "profiles/sanitized-json-key-grouping" ], - "internal/translator/pattern_line_filter_test.go": [ - "semantics/pattern-line-filter" - ], "internal/translator/regexp_escaping_test.go": [ "loki_api_v1_query_range" ], @@ -936,11 +909,6 @@ "semantics/unwrap-gate-parsefloat-divergences", "semantics/unwrap-sample-validity" ], - "internal/translator/unwrap_gate_test.go": [ - "loki_api_v1_query", - "loki_api_v1_query_range", - "semantics/unwrap-duration-seconds" - ], "internal/translator/unwrap_ungrouped_sum_test.go": [ "metric-series-identity", "semantics/unwrap-rate-ungrouped-sum-per-stream" @@ -999,7 +967,8 @@ ], "test/e2e-compat/instant_result_type_test.go": [ "loki_api_v1_query", - "semantics/instant-result-type-missing" + "semantics/instant-result-type-missing", + "semantics/scalar-sample-time-units" ], "test/e2e-compat/json_filter_pushdown_test.go": [ "parser-error-and-label-collision", @@ -1049,11 +1018,6 @@ "loki_api_v1_query", "parsed-label-series-identity" ], - "test/e2e-compat/pattern_line_filter_test.go": [ - "loki_api_v1_query", - "loki_api_v1_query_range", - "semantics/pattern-line-filter" - ], "test/e2e-compat/plain_name_after_parser_test.go": [ "loki_api_v1_query", "loki_api_v1_query_range", @@ -1108,12 +1072,6 @@ "semantics/unwrap-conversion-error-stored-value-overwritten", "status-400" ], - "test/e2e-compat/unwrap_duration_seconds_test.go": [ - "loki_api_v1_query", - "loki_api_v1_query_range", - "semantics/unwrap-duration-seconds", - "status-400" - ], "test/e2e-compat/unwrap_sample_validity_test.go": [ "loki_api_v1_query_range", "parser-error-and-label-collision", @@ -1132,7 +1090,7 @@ "profiles/structured-metadata-keys-per-profile" ] }, - "tests_with_markers": 105, + "tests_with_markers": 99, "unknown_ids": {}, "unwired_items": [ "api_prom_label", @@ -1216,6 +1174,7 @@ "semantics/non-sum-vector-aggregation-unreduced", "semantics/parse-error-labels-on-log-lines", "semantics/parse-error-text-parity", + "semantics/pattern-line-filter", "semantics/proxy-accepts-invalid-logql", "semantics/proxy-rejects-valid-logql", "semantics/repeated-label-filter-replaced-across-label-format", @@ -1226,6 +1185,7 @@ "semantics/unpack-non-string-values", "semantics/unwrap-conversion-error-parser-divergence", "semantics/unwrap-conversion-error-undetected-shapes", + "semantics/unwrap-duration-seconds", "semantics/unwrap-first-last-under-aggregation", "semantics/unwrap-over-time-range-windows", "semantics/unwrap-per-series-identity-raw-rows", @@ -1238,5 +1198,5 @@ "versions/capabilities-floor-vs-matrix", "versions/rejected-stats-query-wording-vl-1.47" ], - "wired_items": 109 + "wired_items": 108 } diff --git a/conformance/reports/parity-gaps.md b/conformance/reports/parity-gaps.md index 33091679c..97cb38461 100644 --- a/conformance/reports/parity-gaps.md +++ b/conformance/reports/parity-gaps.md @@ -115,4 +115,5 @@ Last run: 2026-10-05T20:16:15Z on `fcaccfcc`, window [1791220800, 1791224400]. | `severity/otlp-severity-before-vl-1.50` — OTLP rows stored by VictoriaLogs before v1.50 (one `severity` field, no severity_number) get detected_level "Unspecified" / "Info2" (fixed) | #666 | 0 | 0 | | `versions/rejected-stats-query-wording-vl-1.47` — The proxy did not recognise VictoriaLogs v1.47 parse errors, so a rejected query was not Loki's 400 (fixed) | #659 | 0 | 0 | | `semantics/instant-result-type-missing` — An instant query answers its failure status and a resultType, like Loki (fixed) | #735 | 1 | 2 | +| `semantics/scalar-sample-time-units` — A scalar instant answer is stamped in integer milliseconds, like Loki (fixed) | #TBD | 0 | 0 | | `versions/capabilities-floor-vs-matrix` — logsql.Capabilities treated VictoriaLogs below v1.40 as unsupported while the matrix claimed v1.30 (fixed) | #659 | 0 | 0 | diff --git a/internal/proxy/binary_matching_test.go b/internal/proxy/binary_matching_test.go index 67ab0ff45..0f14f4577 100644 --- a/internal/proxy/binary_matching_test.go +++ b/internal/proxy/binary_matching_test.go @@ -225,6 +225,7 @@ func TestBinaryInstantUsesSingleDefaultTimestamp(t *testing.T) { } } +// conformance: semantics/scalar-sample-time-units, loki_api_v1_query func TestBinaryScalarSubtreesAndIEEEValues(t *testing.T) { p := newTestProxy(t, "http://127.0.0.1:1") for _, tc := range []struct{ query, want string }{ @@ -253,7 +254,7 @@ func TestBinaryScalarSubtreesAndIEEEValues(t *testing.T) { } if resultType == "vector" { var point []any - if response.Data.ResultType != "scalar" || json.Unmarshal(response.Data.Result, &point) != nil || !reflect.DeepEqual(point, []any{float64(1700000000), tc.want}) { + if response.Data.ResultType != "scalar" || json.Unmarshal(response.Data.Result, &point) != nil || !reflect.DeepEqual(point, []any{float64(1700000000000), tc.want}) { t.Fatalf("scalar response %s", w.Body) } } else { diff --git a/internal/proxy/binary_scalar.go b/internal/proxy/binary_scalar.go index 2fe4d07b1..1dd209500 100644 --- a/internal/proxy/binary_scalar.go +++ b/internal/proxy/binary_scalar.go @@ -7,6 +7,7 @@ import ( "net/http" "strconv" "strings" + "time" logqlpkg "github.com/ReliablyObserve/Loki-VL-proxy/internal/logql" ) @@ -85,9 +86,10 @@ func binaryConstantResponse(r *http.Request, value float64, resultType string) ( if !ok { return nil, fmt.Errorf("invalid scalar evaluation time") } - // Loki encodes a scalar with model.Time(ms).String(): seconds with - // millisecond precision, the same unit as vector and matrix samples. - return json.Marshal(map[string]any{"status": "success", "data": map[string]any{"resultType": "scalar", "result": []any{float64(ts/1e6) / 1e3, encoded}}}) + // Loki's frontend marshalScalar passes the millisecond timestamp to + // model.TimeFromUnix (seconds), so a scalar is stamped in integer + // milliseconds, unlike the vector and matrix samples. + return json.Marshal(map[string]any{"status": "success", "data": map[string]any{"resultType": "scalar", "result": []any{ts / int64(time.Millisecond), encoded}}}) } start, startOK := parseLokiTimeToUnixNano(r.FormValue("start")) end, endOK := parseLokiTimeToUnixNano(r.FormValue("end")) diff --git a/internal/proxy/instant_result_type_test.go b/internal/proxy/instant_result_type_test.go index 228982fb0..161d3f4c1 100644 --- a/internal/proxy/instant_result_type_test.go +++ b/internal/proxy/instant_result_type_test.go @@ -28,8 +28,11 @@ import ( // vector, whatever the expression (queryrange/codec.go NewEmptyResponse, // LokiInstantRequest). // -// The sample time of a vector or scalar is seconds (model.Time, millisecond -// precision), not nanoseconds (pkg/util/marshal/query.go NewVector, NewScalar). +// The sample time of a vector is seconds with millisecond precision, not +// nanoseconds (queryrange/prometheus.go marshalVector, model.Time(ms)); the +// time of a scalar is integer milliseconds, because marshalScalar hands the +// millisecond timestamp to model.TimeFromUnix, which reads seconds +// (queryrange/prometheus.go marshalScalar). type instantBackendMode string @@ -277,18 +280,18 @@ func requireInstantAnswer(t *testing.T, label string, a instantAnswer, want stri } // requireInstantResultShape checks the result against loghttp's types: a vector -// sample is {metric, value:[seconds, "v"]}, a scalar is [seconds, "v"]. +// sample is {metric, value:[seconds, "v"]}, a scalar is [milliseconds, "v"]. func requireInstantResultShape(t *testing.T, label string, a instantAnswer, want string) { t.Helper() - checkPoint := func(raw json.RawMessage) { + checkPoint := func(raw json.RawMessage, lo, hi float64, unit string) { var point []any if err := json.Unmarshal(raw, &point); err != nil || len(point) != 2 { t.Errorf("%s: sample point %s is not [time, value]\n%s", label, raw, a.body) return } ts, ok := point[0].(float64) - if !ok || ts < 1e9 || ts > 1e11 { - t.Errorf("%s: sample time %v is not seconds since the epoch\n%s", label, point[0], a.body) + if !ok || ts < lo || ts > hi { + t.Errorf("%s: sample time %v is not %s since the epoch\n%s", label, point[0], unit, a.body) } if _, ok := point[1].(string); !ok { t.Errorf("%s: sample value %v is not a string\n%s", label, point[1], a.body) @@ -300,7 +303,7 @@ func requireInstantResultShape(t *testing.T, label string, a instantAnswer, want Result json.RawMessage `json:"result"` } _ = json.Unmarshal(a.raw["data"], &data) - checkPoint(data.Result) + checkPoint(data.Result, 1e12, 1e14, "milliseconds") case "vector": for _, item := range a.resp.Data.Result { var sample struct { @@ -312,7 +315,7 @@ func requireInstantResultShape(t *testing.T, label string, a instantAnswer, want t.Errorf("%s: %s is not a vector sample {metric, value}\n%s", label, item, a.body) continue } - checkPoint(sample.Value) + checkPoint(sample.Value, 1e9, 1e11, "seconds") } } } @@ -370,9 +373,11 @@ func TestInstantQueryPostProcessedBackendFailureKeepsStatus(t *testing.T) { } } -// Sample times of vector() are seconds, like every other sample, and the -// scalar of a literal follows pkg/util/marshal NewScalar. -// conformance: semantics/instant-result-type-missing, loki_api_v1_query +// Sample times of vector() are seconds, like every other vector sample. The +// time of a scalar is integer milliseconds, as Loki's query frontend writes it +// (queryrange/prometheus.go marshalScalar passes the millisecond timestamp to +// model.TimeFromUnix), which a live Loki 3.7.7 answers as [1791811168123,"1"]. +// conformance: semantics/instant-result-type-missing, semantics/scalar-sample-time-units, loki_api_v1_query func TestInstantConstantsCarrySecondsTimestamps(t *testing.T) { p := newTestProxy(t, instantBackend(t, instantBackendData).URL) for _, tc := range []struct{ query, time string }{ @@ -390,6 +395,42 @@ func TestInstantConstantsCarrySecondsTimestamps(t *testing.T) { } requireInstantAnswer(t, tc.query+" at "+tc.time, a, want, false) } + // the exact sample time, as the JSON text a client reads + for _, tc := range []struct{ query, time, want string }{ + {`1`, "1704067200", `[1704067200000,"1"]`}, + {`1`, "1704067200.5", `[1704067200500,"1"]`}, + {`1`, "1704067200123456789", `[1704067200123,"1"]`}, + {`1`, "2024-01-01T00:00:00.789Z", `[1704067200789,"1"]`}, + {`-1`, "1704067200", `[1704067200000,"-1"]`}, + {`1 + 1`, "1704067200123456789", `[1704067200123,"2"]`}, + {`2 * (3 + 4)`, "1704067200", `[1704067200000,"14"]`}, + {`1 > bool 2`, "1704067200", `[1704067200000,"0"]`}, + } { + a := askInstant(t, p, tc.query, "0", url.Values{"time": {tc.time}}) + var data struct { + ResultType string `json:"resultType"` + Result json.RawMessage `json:"result"` + } + _ = json.Unmarshal(a.raw["data"], &data) + if data.ResultType != "scalar" || string(data.Result) != tc.want { + t.Errorf("%s at %s: scalar %s %s, want scalar %s", tc.query, tc.time, data.ResultType, data.Result, tc.want) + } + } + for _, tc := range []struct{ query, time, want string }{ + {`vector(1)`, "1704067200123456789", `[1704067200.123,"1"]`}, + {`vector(1) + 1`, "1704067200", `[1704067200,"2"]`}, + } { + a := askInstant(t, p, tc.query, "0", url.Values{"time": {tc.time}}) + var data struct { + Result []struct { + Value json.RawMessage `json:"value"` + } `json:"result"` + } + _ = json.Unmarshal(a.raw["data"], &data) + if len(data.Result) != 1 || string(data.Result[0].Value) != tc.want { + t.Errorf("%s at %s: vector %s, want value %s", tc.query, tc.time, a.body, tc.want) + } + } // without a time, the evaluation time is now req := httptest.NewRequest("GET", "/loki/api/v1/query?query="+url.QueryEscape(`1`), nil) req.Header.Set("X-Scope-OrgID", "0") @@ -404,7 +445,7 @@ func TestInstantConstantsCarrySecondsTimestamps(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got.Data.ResultType != "scalar" || len(got.Data.Result) != 2 { t.Fatalf("scalar literal without time: HTTP %d %s", rec.Code, rec.Body.String()) } - if ts, _ := got.Data.Result[0].(float64); time.Since(time.Unix(int64(ts), 0)) > time.Minute { + if ts, _ := got.Data.Result[0].(float64); time.Since(time.UnixMilli(int64(ts))) > time.Minute { t.Fatalf("scalar literal without time is stamped %v, want now", got.Data.Result[0]) } } diff --git a/internal/proxy/metric_agg.go b/internal/proxy/metric_agg.go index b0de00b52..45fc3b2ab 100644 --- a/internal/proxy/metric_agg.go +++ b/internal/proxy/metric_agg.go @@ -46,7 +46,8 @@ func unwrapEmptySumWithout(query string) (logqlpkg.Expr, int) { // evaluateConstantInstantQuery answers an instant query of constants, which has // no data behind it. vector(n), and a binary operation of two of them, is a // vector of one sample with no labels (Loki pkg/logql/engine.go evalVector); a -// literal, or a binary operation of literals, is a scalar (evalLiteral). +// literal, or a binary operation of literals, is a scalar (evalLiteral), whose +// sample time is integer milliseconds (see constantInstantMillis). func evaluateConstantInstantQuery(expr, timeParam string) ([]byte, bool) { parsed, err := logqlpkg.Parse(strings.TrimSpace(expr)) if err != nil { @@ -63,17 +64,26 @@ func evaluateConstantInstantQuery(expr, timeParam string) ([]byte, bool) { "status": "success", "data": map[string]interface{}{ "resultType": "scalar", - "result": []interface{}{constantInstantSeconds(timeParam), strconv.FormatFloat(value, 'f', -1, 64)}, + "result": []interface{}{constantInstantMillis(timeParam), strconv.FormatFloat(value, 'f', -1, 64)}, }, }) return result, true } -// constantInstantSeconds is the evaluation time as a sample time: seconds with -// millisecond precision (Loki pkg/util/marshal NewVector and NewScalar), not -// the nanoseconds the request carries. +// constantInstantSeconds is the evaluation time as a vector sample time: seconds +// with millisecond precision (Loki pkg/querier/queryrange/prometheus.go +// marshalVector, model.Time(ms)), not the nanoseconds the request carries. func constantInstantSeconds(timeParam string) float64 { - return float64(parseInstantVectorTime(timeParam)/int64(time.Millisecond)) / 1e3 + return float64(constantInstantMillis(timeParam)) / 1e3 +} + +// constantInstantMillis is the evaluation time as a scalar sample time. Loki +// answers it as integer milliseconds, not seconds: marshalScalar +// (pkg/querier/queryrange/prometheus.go) hands the millisecond timestamp of +// promql.Scalar.T (pkg/logql/engine.go evalLiteral) to model.TimeFromUnix, +// which reads seconds, so model.Time.String prints the millisecond count. +func constantInstantMillis(timeParam string) int64 { + return parseInstantVectorTime(timeParam) / int64(time.Millisecond) } func buildConstantVectorResponse(timeParam string, value float64) []byte { diff --git a/test/e2e-compat/instant_result_type_test.go b/test/e2e-compat/instant_result_type_test.go index 17c23eadf..3efe10a40 100644 --- a/test/e2e-compat/instant_result_type_test.go +++ b/test/e2e-compat/instant_result_type_test.go @@ -23,8 +23,9 @@ import ( // // Each shape is asked of Loki and of the proxy. Both must answer, and answer // the same status and the same resultType; the series of the answers are -// compared by the other registry items, not here. -// conformance: semantics/instant-result-type-missing, loki_api_v1_query +// compared by the other registry items, not here. The sample time and value of +// the constants are compared too (semantics/scalar-sample-time-units). +// conformance: semantics/instant-result-type-missing, semantics/scalar-sample-time-units, loki_api_v1_query func TestCompat_InstantResultTypeAlwaysSet(t *testing.T) { ensureDataIngested(t) waitForLokiMetricData(t) @@ -67,6 +68,8 @@ func TestCompat_InstantResultTypeAlwaysSet(t *testing.T) { {"scalar literal", `1`}, {"negative literal", `-1`}, {"scalar binary", `1 + 1`}, + {"scalar nested binary", `2 * (3 + 4)`}, + {"scalar comparison", `1 > bool 2`}, {"log selector", sel}, } @@ -153,6 +156,57 @@ func TestCompat_InstantResultTypeAlwaysSet(t *testing.T) { } }) + // A scalar is stamped in integer milliseconds, a vector in seconds: Loki's + // query frontend hands the millisecond timestamp of a scalar to + // model.TimeFromUnix, which reads seconds (pkg/querier/queryrange + // /prometheus.go marshalScalar; marshalVector keeps model.Time(ms)). The + // same evaluation time must give the same sample time and value on both + // sides, to the millisecond, for every constant shape. `1 / 0` is left out: + // Loki fails to ship a folded NaN or Inf to its querier and answers 500. + t.Run("constant sample time", func(t *testing.T) { + at := time.Now().Truncate(time.Millisecond).Add(123456 * time.Nanosecond) + for _, tc := range []struct{ query, resultType string }{ + {`1`, "scalar"}, + {`-1`, "scalar"}, + {`1 + 1`, "scalar"}, + {`2 * (3 + 4)`, "scalar"}, + {`1 > bool 2`, "scalar"}, + {`vector(1)`, "vector"}, + {`vector(1) + 2`, "vector"}, + } { + sample := func(payload map[string]interface{}) (string, []interface{}) { + data, _ := payload["data"].(map[string]interface{}) + rt, _ := data["resultType"].(string) + if rt == "scalar" { + point, _ := data["result"].([]interface{}) + return rt, point + } + result, _ := data["result"].([]interface{}) + if len(result) != 1 { + return rt, nil + } + first, _ := result[0].(map[string]interface{}) + point, _ := first["value"].([]interface{}) + return rt, point + } + lokiStatus, lokiBody, lokiPayload := ask(t, lokiURL, tc.query, "0", at) + proxyStatus, proxyBody, proxyPayload := ask(t, proxyURL, tc.query, "0", at) + if lokiStatus != http.StatusOK || proxyStatus != http.StatusOK { + t.Errorf("%s: HTTP loki=%d proxy=%d\nloki: %s\nproxy: %s", tc.query, lokiStatus, proxyStatus, lokiBody, proxyBody) + continue + } + lokiType, lokiPoint := sample(lokiPayload) + proxyType, proxyPoint := sample(proxyPayload) + if lokiType != tc.resultType || proxyType != tc.resultType || len(lokiPoint) != 2 || len(proxyPoint) != 2 { + t.Errorf("%s: want one %s sample on both sides\nloki: %s\nproxy: %s", tc.query, tc.resultType, lokiBody, proxyBody) + continue + } + if lokiPoint[0] != proxyPoint[0] || lokiPoint[1] != proxyPoint[1] { + t.Errorf("%s: sample loki=%v proxy=%v (the time is milliseconds for a scalar, seconds for a vector)\nloki: %s\nproxy: %s", tc.query, lokiPoint, proxyPoint, lokiBody, proxyBody) + } + } + }) + // An aggregation over a binary expression (`sum by (job) (a / b)`) is not // evaluated by the proxy yet (semantics/aggregation-over-binary-dropped); it // may be rejected, but never with a 200 that has no resultType. From 77a1a42d71883f30fb4fc24bfafb9e8bd6e95b1b Mon Sep 17 00:00:00 2001 From: szibis Date: Mon, 12 Oct 2026 16:17:12 +0200 Subject: [PATCH 2/7] chore(conformance): record #752 as the fix of scalar-sample-time-units --- CHANGELOG.md | 2 +- .../registry/cases/semantics/scalar-sample-time-units.yaml | 2 +- conformance/reports/parity-gaps.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acc496489..7059085ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **A scalar instant answer carries its sample time in integer milliseconds, as - Loki does, not in seconds (#TBD).** `GET /loki/api/v1/query?query=1` (and + Loki does, not in seconds (#752).** `GET /loki/api/v1/query?query=1` (and `1 + 1`, `2 * (3 + 4)`, `1 > bool 2`) was answered `[1791811168.123,"1"]`; Loki 3.7.7 answers `[1791811168123,"1"]`, while `vector(1)` is in seconds on both. The engine keeps the scalar time in milliseconds (`pkg/logql/engine.go` diff --git a/conformance/registry/cases/semantics/scalar-sample-time-units.yaml b/conformance/registry/cases/semantics/scalar-sample-time-units.yaml index 8f07a1e68..bab6ea4a5 100644 --- a/conformance/registry/cases/semantics/scalar-sample-time-units.yaml +++ b/conformance/registry/cases/semantics/scalar-sample-time-units.yaml @@ -57,7 +57,7 @@ compare: [1791811168123,"1"] for a scalar; [1791811168.123,"1"] for vector(1). gap: status: fixed - fixed_by: "#TBD" + fixed_by: "#752" impact: grafana-datasource visual: none visual_reason: 'instant scalar literals only; no Explore range panel renders them, the fix is proven by the sample-time comparison against Loki in the e2e' diff --git a/conformance/reports/parity-gaps.md b/conformance/reports/parity-gaps.md index 97cb38461..eddf52009 100644 --- a/conformance/reports/parity-gaps.md +++ b/conformance/reports/parity-gaps.md @@ -115,5 +115,5 @@ Last run: 2026-10-05T20:16:15Z on `fcaccfcc`, window [1791220800, 1791224400]. | `severity/otlp-severity-before-vl-1.50` — OTLP rows stored by VictoriaLogs before v1.50 (one `severity` field, no severity_number) get detected_level "Unspecified" / "Info2" (fixed) | #666 | 0 | 0 | | `versions/rejected-stats-query-wording-vl-1.47` — The proxy did not recognise VictoriaLogs v1.47 parse errors, so a rejected query was not Loki's 400 (fixed) | #659 | 0 | 0 | | `semantics/instant-result-type-missing` — An instant query answers its failure status and a resultType, like Loki (fixed) | #735 | 1 | 2 | -| `semantics/scalar-sample-time-units` — A scalar instant answer is stamped in integer milliseconds, like Loki (fixed) | #TBD | 0 | 0 | +| `semantics/scalar-sample-time-units` — A scalar instant answer is stamped in integer milliseconds, like Loki (fixed) | #752 | 0 | 0 | | `versions/capabilities-floor-vs-matrix` — logsql.Capabilities treated VictoriaLogs below v1.40 as unsupported while the matrix claimed v1.30 (fixed) | #659 | 0 | 0 | From ce638d629418c196cbaa1726adf2d604edc4fc77 Mon Sep 17 00:00:00 2001 From: szibis Date: Mon, 12 Oct 2026 17:09:32 +0200 Subject: [PATCH 3/7] docs(changelog): keep this change's entry under Unreleased --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7059085ef..c329c146a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 sample time and value of every constant against Loki (`semantics/scalar-sample-time-units`). +## [2.13.0] - 2026-10-12 + +### Fixed + +- **An empty pattern line filter selects the lines Loki selects: `|> ""` keeps no + line of an ordinary stream and `!> ""` keeps them all.** `{namespace="prod"} + |> ""` returned the page on the proxy and no line on Loki 3.7.7, and + `{namespace="prod"} !> ""` returned nothing where Loki returns the page. An + empty pattern is valid in Loki: it parses without error and builds a pattern + matcher with no expression, which accepts only an empty line + (`pkg/logql/log/pattern/pattern.go` `ParseLineFilter`, `Matcher.Test`), so + `|> ""` keeps the empty lines and `!> ""` drops them. It is not the match-all + filter that `|= ""` is: `|> "" or "x"` keeps the empty lines and the lines that + match `x`. The proxy translated the empty pattern to the regexp `.*`, which + VictoriaLogs read as every line; it now sends `^$`, the empty line, in log + queries, in range and instant metric queries (the filter is part of the stats + query, nothing is filtered in the proxy), after a parser stage and inside an + `or` chain. A `!=`, `!~` or `!>` written right after a pattern filter, with no + pipe between them (`|> "a <_>" != "x"`), is a filter of its own again: the + proxy read it as part of the pattern, dropped both filters and returned every + line. Non-empty patterns are otherwise unchanged. Covered by the edge table + `TestTranslateEmptyPatternLineFilter`, `TestEmptyPatternLineFilterReachesVictoriaLogs` + for the log, range and instant paths, and `TestCompat_PatternLineFilter` + against Loki (#743). + ## [2.12.0] - 2026-10-12 ### Fixed From fa7257888a56a8fdb3f4372208c2db7d56b3b0d5 Mon Sep 17 00:00:00 2001 From: szibis Date: Mon, 12 Oct 2026 17:09:33 +0200 Subject: [PATCH 4/7] chore(conformance): regenerate after rebase --- conformance/registry/generated/wiring.json | 23 +++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/conformance/registry/generated/wiring.json b/conformance/registry/generated/wiring.json index c72b1be9a..f1a00de8b 100644 --- a/conformance/registry/generated/wiring.json +++ b/conformance/registry/generated/wiring.json @@ -192,6 +192,7 @@ "test/e2e-compat/instant_result_type_test.go", "test/e2e-compat/parenthesized_range_or_filters_test.go", "test/e2e-compat/parser_capture_identity_test.go", + "test/e2e-compat/pattern_line_filter_test.go", "test/e2e-compat/plain_name_after_parser_test.go", "test/e2e-compat/range_metric_offset_series_limit_test.go", "test/e2e-compat/unwrap_conversion_error_test.go", @@ -218,6 +219,7 @@ "test/e2e-compat/heavy_query_protection_test.go", "test/e2e-compat/line_fields_parity_test.go", "test/e2e-compat/parenthesized_range_or_filters_test.go", + "test/e2e-compat/pattern_line_filter_test.go", "test/e2e-compat/plain_name_after_parser_test.go", "test/e2e-compat/range_metric_offset_series_limit_test.go", "test/e2e-compat/stage_field_exposure_test.go", @@ -455,6 +457,11 @@ "semantics/partial-parse-before-error": [ "internal/proxy/ordered_json_filter_pushdown_test.go" ], + "semantics/pattern-line-filter": [ + "internal/proxy/pattern_line_filter_test.go", + "internal/translator/pattern_line_filter_test.go", + "test/e2e-compat/pattern_line_filter_test.go" + ], "semantics/pipeline-error-text-parity": [ "internal/proxy/ordered_json_filter_pushdown_test.go", "internal/proxy/ordered_json_metric_test.go" @@ -782,6 +789,9 @@ "internal/proxy/parser_capture_identity_test.go": [ "parsed-label-series-identity" ], + "internal/proxy/pattern_line_filter_test.go": [ + "semantics/pattern-line-filter" + ], "internal/proxy/plain_stream_labels_test.go": [ "loki_api_v1_query_range", "semantics/plain-name-after-parser-reads-stream-label" @@ -899,6 +909,9 @@ "profiles/sanitized-json-key-filter", "profiles/sanitized-json-key-grouping" ], + "internal/translator/pattern_line_filter_test.go": [ + "semantics/pattern-line-filter" + ], "internal/translator/regexp_escaping_test.go": [ "loki_api_v1_query_range" ], @@ -1018,6 +1031,11 @@ "loki_api_v1_query", "parsed-label-series-identity" ], + "test/e2e-compat/pattern_line_filter_test.go": [ + "loki_api_v1_query", + "loki_api_v1_query_range", + "semantics/pattern-line-filter" + ], "test/e2e-compat/plain_name_after_parser_test.go": [ "loki_api_v1_query", "loki_api_v1_query_range", @@ -1090,7 +1108,7 @@ "profiles/structured-metadata-keys-per-profile" ] }, - "tests_with_markers": 99, + "tests_with_markers": 102, "unknown_ids": {}, "unwired_items": [ "api_prom_label", @@ -1174,7 +1192,6 @@ "semantics/non-sum-vector-aggregation-unreduced", "semantics/parse-error-labels-on-log-lines", "semantics/parse-error-text-parity", - "semantics/pattern-line-filter", "semantics/proxy-accepts-invalid-logql", "semantics/proxy-rejects-valid-logql", "semantics/repeated-label-filter-replaced-across-label-format", @@ -1198,5 +1215,5 @@ "versions/capabilities-floor-vs-matrix", "versions/rejected-stats-query-wording-vl-1.47" ], - "wired_items": 108 + "wired_items": 109 } From f7aed5d23e4a3661421308fab7c0707b36df9c22 Mon Sep 17 00:00:00 2001 From: szibis Date: Mon, 12 Oct 2026 18:28:44 +0200 Subject: [PATCH 5/7] docs(changelog): keep this change's entry under Unreleased --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c329c146a..5f3913b54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 sample time and value of every constant against Loki (`semantics/scalar-sample-time-units`). +## [2.14.0] - 2026-10-12 + +### Fixed + +- **`unwrap duration_seconds(label)` is converted like `duration(label)`, as Loki + does, instead of answering an empty matrix.** `sum by (service_name) + (sum_over_time({service_name="cache-redis"} | logfmt | unwrap + duration_seconds(ttl) [5m]))` over `ttl=250ms` lines returned no series on the + proxy and 7.5 per window on Loki 3.7.7. Loki converts `duration_seconds()` with + `convertDuration` (`time.ParseDuration`, in seconds), the same function as + `duration()` (`pkg/logql/syntax/extractor.go:63-71`, `OpConvDurationSeconds`; + `pkg/logql/log/metrics_extraction.go` `convertDuration`). The translator + recognised only the `duration(` and `bytes(` wrappers, so it treated + `duration_seconds(ttl)` as the name of a label that no line has: nothing + matched the plain-number gate, and the conversion-error check, which counts the + rows the gate rejects, saw no duration to count. Now `duration_seconds()` takes + the duration route everywhere `duration()` does: the label is extracted, + gated by the `time.ParseDuration` syntax (`250ms`, `1.5s`, `1h`, `-1s`, `5.s`, + `.5s`) and converted to seconds by VictoriaLogs for the sum, rate, average, + maximum, minimum and quantile of a range, tumbling, sliding or instant window, + the raw evaluators convert it with `time.ParseDuration`, and a value Go rejects + (`abc`, `1d`, `42`) fails the query with Loki's 400 `SampleExtractionErr` text + unless a label filter after the unwrap drops the error. The proxy now answers + `duration_seconds(x)` with exactly the queries and answer of `duration(x)`. + The window of a sum, maximum or minimum over time on a range query is a + separate open difference that applies to either conversion. (#729) + ## [2.13.0] - 2026-10-12 ### Fixed From 3471aa0954c24c6ab3c1ece87b20c9b21f16772b Mon Sep 17 00:00:00 2001 From: szibis Date: Mon, 12 Oct 2026 18:28:45 +0200 Subject: [PATCH 6/7] chore(conformance): regenerate after rebase --- conformance/registry/generated/wiring.json | 43 ++++++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/conformance/registry/generated/wiring.json b/conformance/registry/generated/wiring.json index f1a00de8b..81be0940a 100644 --- a/conformance/registry/generated/wiring.json +++ b/conformance/registry/generated/wiring.json @@ -188,6 +188,9 @@ "internal/proxy/tenant_query_limits_test.go", "internal/proxy/tumbling_axis_parity_test.go", "internal/proxy/unwrap_conversion_error_test.go", + "internal/proxy/unwrap_convert_test.go", + "internal/proxy/unwrap_samples_test.go", + "internal/translator/unwrap_gate_test.go", "test/e2e-compat/extracted_suffix_collision_test.go", "test/e2e-compat/instant_result_type_test.go", "test/e2e-compat/parenthesized_range_or_filters_test.go", @@ -196,6 +199,7 @@ "test/e2e-compat/plain_name_after_parser_test.go", "test/e2e-compat/range_metric_offset_series_limit_test.go", "test/e2e-compat/unwrap_conversion_error_test.go", + "test/e2e-compat/unwrap_duration_seconds_test.go", "test/e2e-compat/unwrap_ungrouped_sum_test.go" ], "loki_api_v1_query_range": [ @@ -212,8 +216,11 @@ "internal/proxy/tenant_query_limits_test.go", "internal/proxy/tumbling_axis_parity_test.go", "internal/proxy/unwrap_conversion_error_test.go", + "internal/proxy/unwrap_convert_test.go", + "internal/proxy/unwrap_samples_test.go", "internal/translator/extracted_suffix_test.go", "internal/translator/regexp_escaping_test.go", + "internal/translator/unwrap_gate_test.go", "test/e2e-compat/drilldown_field_breakdown_test.go", "test/e2e-compat/extracted_suffix_collision_test.go", "test/e2e-compat/heavy_query_protection_test.go", @@ -224,6 +231,7 @@ "test/e2e-compat/range_metric_offset_series_limit_test.go", "test/e2e-compat/stage_field_exposure_test.go", "test/e2e-compat/unwrap_conversion_error_test.go", + "test/e2e-compat/unwrap_duration_seconds_test.go", "test/e2e-compat/unwrap_sample_validity_test.go", "test/e2e-compat/unwrap_ungrouped_sum_test.go" ], @@ -495,6 +503,12 @@ "internal/proxy/unwrap_conversion_error_differential_test.go", "test/e2e-compat/unwrap_conversion_error_test.go" ], + "semantics/unwrap-duration-seconds": [ + "internal/proxy/unwrap_convert_test.go", + "internal/proxy/unwrap_samples_test.go", + "internal/translator/unwrap_gate_test.go", + "test/e2e-compat/unwrap_duration_seconds_test.go" + ], "semantics/unwrap-gate-parsefloat-divergences": [ "internal/proxy/unwrap_bytes_gate_property_test.go", "internal/proxy/unwrap_conversion_error_test.go", @@ -542,7 +556,8 @@ "internal/proxy/ordered_json_filter_pushdown_test.go", "internal/proxy/unwrap_conversion_error_test.go", "internal/proxy/volume_test.go", - "test/e2e-compat/unwrap_conversion_error_test.go" + "test/e2e-compat/unwrap_conversion_error_test.go", + "test/e2e-compat/unwrap_duration_seconds_test.go" ], "tenant-query-limits": [ "internal/proxy/drilldown_breakdown_exact_test.go", @@ -877,6 +892,16 @@ "semantics/unwrap-gate-parsefloat-divergences", "status-400" ], + "internal/proxy/unwrap_convert_test.go": [ + "loki_api_v1_query", + "loki_api_v1_query_range", + "semantics/unwrap-duration-seconds" + ], + "internal/proxy/unwrap_samples_test.go": [ + "loki_api_v1_query", + "loki_api_v1_query_range", + "semantics/unwrap-duration-seconds" + ], "internal/proxy/unwrap_ungrouped_sum_differential_test.go": [ "metric-series-identity", "semantics/unwrap-range-last-point", @@ -922,6 +947,11 @@ "semantics/unwrap-gate-parsefloat-divergences", "semantics/unwrap-sample-validity" ], + "internal/translator/unwrap_gate_test.go": [ + "loki_api_v1_query", + "loki_api_v1_query_range", + "semantics/unwrap-duration-seconds" + ], "internal/translator/unwrap_ungrouped_sum_test.go": [ "metric-series-identity", "semantics/unwrap-rate-ungrouped-sum-per-stream" @@ -1090,6 +1120,12 @@ "semantics/unwrap-conversion-error-stored-value-overwritten", "status-400" ], + "test/e2e-compat/unwrap_duration_seconds_test.go": [ + "loki_api_v1_query", + "loki_api_v1_query_range", + "semantics/unwrap-duration-seconds", + "status-400" + ], "test/e2e-compat/unwrap_sample_validity_test.go": [ "loki_api_v1_query_range", "parser-error-and-label-collision", @@ -1108,7 +1144,7 @@ "profiles/structured-metadata-keys-per-profile" ] }, - "tests_with_markers": 102, + "tests_with_markers": 106, "unknown_ids": {}, "unwired_items": [ "api_prom_label", @@ -1202,7 +1238,6 @@ "semantics/unpack-non-string-values", "semantics/unwrap-conversion-error-parser-divergence", "semantics/unwrap-conversion-error-undetected-shapes", - "semantics/unwrap-duration-seconds", "semantics/unwrap-first-last-under-aggregation", "semantics/unwrap-over-time-range-windows", "semantics/unwrap-per-series-identity-raw-rows", @@ -1215,5 +1250,5 @@ "versions/capabilities-floor-vs-matrix", "versions/rejected-stats-query-wording-vl-1.47" ], - "wired_items": 109 + "wired_items": 110 } From f92077ebd9206dfecd510f73657e0b8c643a7420 Mon Sep 17 00:00:00 2001 From: szibis Date: Mon, 12 Oct 2026 19:06:55 +0200 Subject: [PATCH 7/7] docs(changelog): keep this change's entry under Unreleased --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f3913b54..1d484e831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 sample time and value of every constant against Loki (`semantics/scalar-sample-time-units`). +## [2.14.1] - 2026-10-12 + +### CI + +- **Release metadata pull requests are opened by a maintainer.** The release + workflows (`auto-release.yaml`, `release.yaml`) still compute the metadata and + push the signed `release/metadata-vX.Y.Z` branch, but no longer open or + auto-merge its pull request. A maintainer opens it from that branch, so the + pull request and the commit that lands on main carry the maintainer's + authorship. + ## [2.14.0] - 2026-10-12 ### Fixed