Skip to content

fix(promql): malformed JSON from /api/v1/query_range on empty aggregations#103286

Draft
JTCunning wants to merge 7 commits intoClickHouse:masterfrom
JTCunning:fix/prometheus-query-api-malformed-json-on-empty-aggregation
Draft

fix(promql): malformed JSON from /api/v1/query_range on empty aggregations#103286
JTCunning wants to merge 7 commits intoClickHouse:masterfrom
JTCunning:fix/prometheus-query-api-malformed-json-on-empty-aggregation

Conversation

@JTCunning
Copy link
Copy Markdown
Contributor

@JTCunning JTCunning commented Apr 21, 2026

When running the prometheus/compliance test harness, the robots found a regression.

The fix itself is pretty simple, so the majority of the line change is comments on behavior and confirmation via tests.
Let me know if I need to slim down either.

The below is the robot's description of the behavior/tests/fixes. It's verbose but I like it, so I kept it.


Summary

The Prometheus query API endpoints (/api/v1/query, /api/v1/query_range) returned an unbalanced JSON body when an aggregation over a non-existent metric reached the timeSeriesFromGrid step function. Reproducer:

END=$(date -u +%s); START=$((END-900)); STEP=15
curl -sS "http://127.0.0.1:9093/api/v1/query_range" \
  --data-urlencode "query=count(nonexistent_metric_name)" \
  --data-urlencode "start=$START" --data-urlencode "end=$END" \
  --data-urlencode "step=$STEP"

returned HTTP 400 with body:

{"status":"success","data":{"resultType":"matrix","result":[
  {"status":"error","errorType":"bad_data",
   "error":"Number of values (0) doesn't match number of steps (61):
    while executing 'FUNCTION timeSeriesFromGrid(...)'"}

The outer {, the data {, and the result [ are all left unclosed. The Prometheus Go client (v1.apiResponse.Data) errors with ReadArrayCB: expect ] in the end, but found <EOF>. All five trivial aggregations (count/sum/avg/min/max) over a non-existent metric hit this, as does any case where the inner aggregation produces zero rows but timeSeriesFromGrid is still asked to emit N steps.

Two cooperating defects, both fixed here:

1. Function-level (src/Functions/TimeSeries/timeSeriesRange.cpp)

timeSeriesFromGrid threw Number of values (0) doesn't match number of steps (N) for any row whose values array was empty. The Prometheus contract for query_range over an empty aggregation is an empty matrix series, not a hard error. The function now treats num_values == 0 as "no samples for this row" and emits an empty per-row time series. The truly-mismatched case (0 < num_values != num_steps) still throws BAD_ARGUMENTS, preserving the existing strict contract.

The row-0 base offset is computed with an explicit (i == 0) ? 0 : (*values_offsets)[i - 1] guard. IColumn::Offsets is a PaddedPODArray<UInt64> with reverse padding, so offsets[-1] == 0 is well-defined (the canonical idiom in ColumnArray::offsetAt/sizeAt); the explicit guard makes the row-0 case obvious at the call site without depending on that invariant.

2. Handler-level (src/Server/PrometheusRequestHandler.cpp, src/Storages/TimeSeries/PrometheusHTTPProtocolAPI.cpp)

The PromQL HTTP handler streamed the success envelope ({"status":"success","data":{"resultType":"matrix","result":[) directly into the response output stream, then on exception appended a second {"status":"error",...} object onto the still-open buffer — the unbalanced body above. The fix has two parts:

  • PrometheusHTTPProtocolAPI::writeQueryResponse performs the first PullingPipelineExecutor::pull(...) before emitting any byte of the success envelope. The dominant PromQL evaluation failure mode (e.g. timeSeriesFromGrid over an empty aggregation, malformed PromQL parse errors) raises on that first pull because the underlying pipeline is lazy, so it propagates with the response stream still untouched. Bulk row data continues to stream to the network (no in-memory accumulation, no peak-memory regression for large query_range responses).

  • QueryAPIImpl::handlingRequestWithContext branches its catch block on body_buf.count() == 0:

    • Pre-output (no bytes written yet): set HTTP 4xx and emit a single, well-formed structured-error JSON document. The error message is JSON-escaped via writeJSONString (it embeds SQL fragments that can contain quotes/backslashes which would otherwise produce another class of invalid JSON).
    • Post-output (envelope or rows already in flight): re-throw. The outer PrometheusRequestHandler::handleRequest catch invokes WriteBufferFromHTTPServerResponse::cancelWithException, which appends a __exception__ marker block in a new chunk and intentionally skips the final empty chunk to break HTTP. The client sees a connection-level abort instead of concatenated JSON — wire format stays well-defined and a second JSON document is never written.

Either fix on its own defends against the specific reproducer; both land because the function-level fix restores the regressed PASS for the specific aggregation queries, while the handler-level fix is defense-in-depth so any future PromQL function failure (early or late) cannot produce malformed JSON.

Tests

  • tests/queries/0_stateless/03254_timeseries_range.sql gains three cases right after the existing wrong-count BAD_ARGUMENTS block: empty Array(Nullable(Float64)), empty Array(Float64), and a multi-row case mixing empty and populated arrays via arrayJoin to prove an empty row does not poison sibling rows in the same block. The existing BAD_ARGUMENTS cases are unchanged.

  • tests/integration/test_prometheus_protocols/test_evaluation.py gains two complementary tests:

    • test_query_range_empty_aggregation_returns_empty_matrix — strict regression guard for the function-level fix. Issues count/sum/avg/min/max(nonexistent_metric_name) over a 15-minute / 15-second-step range and asserts HTTP 200 + status == "success" + empty matrix. Cannot silently re-pass if timeSeriesFromGrid regresses to throwing.
    • test_query_range_invalid_promql_returns_structured_error — companion guard for the handler's pre-output error path. Issues a syntactically broken PromQL expression and asserts HTTP 400 + status == "error" + errorType == "bad_data" + presence of the error field. Locks both the status code and envelope shape so a regression on either layer is caught.

Verified end-to-end against a live ClickHouse instance: all five reproducer queries now return HTTP 200 with {"status":"success","data":{"resultType":"matrix","result":[]}}; parse-time and other pre-stream error paths now return clean {"status":"error","errorType":"bad_data","error":"..."} envelopes with HTTP 400; late-stream failures abort the chunked transfer cleanly via cancelWithException rather than appending a second JSON document.

Made-with: Cursor

Changelog category (leave one):

  • Bug fix

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Fix malformed JSON from /api/v1/query_range on empty aggregations

Summary
-------

The Prometheus query API endpoints (/api/v1/query, /api/v1/query_range)
returned an unbalanced JSON body when an aggregation over a non-existent
metric reached the timeSeriesFromGrid step function. Reproducer:

    END=$(date -u +%s); START=$((END-900)); STEP=15
    curl -sS "http://127.0.0.1:9093/api/v1/query_range" \
      --data-urlencode "query=count(nonexistent_metric_name)" \
      --data-urlencode "start=$START" --data-urlencode "end=$END" \
      --data-urlencode "step=$STEP"

returned HTTP 400 with body:

    {"status":"success","data":{"resultType":"matrix","result":[
      {"status":"error","errorType":"bad_data",
       "error":"Number of values (0) doesn't match number of steps (61):
        while executing 'FUNCTION timeSeriesFromGrid(...)'"}

The outer '{', the data '{', and the result '[' are all left unclosed.
The Prometheus Go client (v1.apiResponse.Data) errors with
'ReadArrayCB: expect ] in the end, but found <EOF>'. All five trivial
aggregations (count/sum/avg/min/max) over a non-existent metric hit
this, as does any case where the inner aggregation produces zero rows
but timeSeriesFromGrid is still asked to emit N steps.

Two cooperating defects, both fixed here:

1. Function-level. timeSeriesFromGrid threw
   "Number of values (0) doesn't match number of steps (N)" for any row
   whose values array was empty. The Prometheus contract for
   query_range over an empty aggregation is an empty matrix series, not
   a hard error. The function now treats num_values == 0 as "no samples
   for this row" and emits an empty per-row time series. The truly-
   mismatched case (0 < num_values != num_steps) still throws
   BAD_ARGUMENTS, preserving the existing strict contract.

2. Handler-level. The PromQL HTTP handler streamed the success envelope
   ('{"status":"success","data":{"resultType":"matrix","result":[')
   directly into the response output stream, then on exception appended
   a second '{"status":"error",...}' object onto the still-open buffer,
   which is what produced the unbalanced body. The handler now buffers
   the body in a WriteBufferFromString and only writes it to the HTTP
   output stream on success; on exception the partial body is discarded
   and a single, well-formed '{"status":"error",...}' document is
   emitted with HTTP 400. As a defense-in-depth correctness fix the
   error message is now JSON-escaped (it embeds SQL fragments that can
   contain quotes/backslashes which would otherwise produce another
   class of invalid JSON).

Either fix on its own defends against this class of bug; both are
landed because the function-level fix restores the regressed PASS for
the specific aggregation queries, while the handler-level fix is a
defense-in-depth so any future PromQL function failure mid-stream
cannot produce malformed JSON.

Tests
-----

- tests/queries/0_stateless/03254_timeseries_range.sql gains three
  cases right after the existing wrong-count BAD_ARGUMENTS block:
  empty Array(Nullable(Float64)), empty Array(Float64), and a
  multi-row case mixing empty and populated arrays via arrayJoin to
  prove an empty row does not poison sibling rows in the same block.
  The existing BAD_ARGUMENTS cases are unchanged.
- tests/integration/test_prometheus_protocols/test_evaluation.py
  gains test_query_range_empty_aggregation_returns_valid_json which
  issues count/sum/avg/min/max(nonexistent_metric_name) over a
  15-minute / 15-second-step range against the local ClickHouse
  Prometheus endpoint and asserts the body parses as JSON in either
  the success-with-empty-matrix or structured-error shape.

Verified end-to-end against a live ClickHouse instance: all five
reproducer queries now return HTTP 200 with
{"status":"success","data":{"resultType":"matrix","result":[]}};
parse-time and other mid-stream error paths now return clean
{"status":"error","errorType":"bad_data","error":"..."} envelopes
with HTTP 400.

Made-with: Cursor
@clickhouse-gh
Copy link
Copy Markdown
Contributor

clickhouse-gh Bot commented Apr 21, 2026

Workflow [PR], commit [782e489]

Summary:

job_name test_name status info comment
AST fuzzer (amd_debug, targeted, old_compatibility) FAIL
Logical error: AggregateFunctionArray: parameters mismatch between Array wrapper 'A' and nested function 'B'. Wrapper has C parameter(s): [D], nested function has E parameter(s): [F] (STID: 4870-4f21) FAIL cidb
Integration tests (amd_asan_ubsan, db disk, old analyzer, 4/6) FAIL
test_backup_restore_new/test.py::test_backup_all[True] FAIL cidb
Integration tests (arm_binary, distributed plan, 4/4) FAIL
test_backup_restore_new/test.py::test_backup_all[True] FAIL cidb
Integration tests (amd_tsan, 4/6) FAIL
test_backup_restore_new/test.py::test_backup_all[True] FAIL cidb
Integration tests (amd_msan, 4/6) FAIL
test_backup_restore_new/test.py::test_backup_all[True] FAIL cidb
Finish Workflow FAIL
python3 ./ci/jobs/scripts/workflow_hooks/check_report_messages.py FAIL

AI Review

Summary

This PR fixes malformed /api/v1/query_range JSON on empty aggregations by making timeSeriesFromGrid treat empty value arrays as empty per-row series and by hardening Prometheus response streaming to avoid appending a second JSON object on exceptions. I did not find any remaining correctness, safety, or performance issues in the current PR head.

ClickHouse Rules
Item Status Notes
Deletion logging
Serialization versioning
Core-area scrutiny
No test removal
Experimental gate
No magic constants
Backward compatibility
SettingsChangesHistory.cpp
PR metadata quality
Safe rollout
Compilation time
No large/binary files
Final Verdict
  • Status: ✅ Approve

@JTCunning JTCunning added comp-promql PromQL / time-series subsystem: TimeSeries storage engine, PromQL parser, PromQL-to-SQL converter... pr-not-for-changelog This PR should not be mentioned in the changelog experimental feature Bug in the feature that should not be used in production and removed pr-not-for-changelog This PR should not be mentioned in the changelog labels Apr 21, 2026
@clickhouse-gh clickhouse-gh Bot added the pr-experimental Experimental Feature label Apr 21, 2026
Comment thread src/Server/PrometheusRequestHandler.cpp Outdated
Comment thread tests/integration/test_prometheus_protocols/test_evaluation.py Outdated
Address clickhouse-gh AI Review on PR ClickHouse#103286: the previous fix wrapped
the entire /api/v1/query and /api/v1/query_range response body in a
String + WriteBufferFromString before writing it to the socket, which
turned a network-streamed payload into in-memory accumulation and a
peak-memory regression for large query_range responses (many series x
many steps), with avoidable OOM risk under concurrency.

Restore streaming, but keep the malformed-JSON guarantee that motivated
the original fix:

- PrometheusHTTPProtocolAPI::writeQueryResponse now performs the first
  PullingPipelineExecutor::pull(...) BEFORE emitting any byte of the
  '{"status":"success","data":{...,"result":[' envelope. The dominant
  PromQL evaluation failure mode (e.g. timeSeriesFromGrid over an
  aggregation that produced zero samples) raises on this first pull
  because the underlying pipeline is lazy, so it propagates with the
  response stream still untouched.

- QueryAPIImpl::handlingRequestWithContext writes directly into the
  HTTP output buffer again. On a startup-side throw the headers have
  not been committed yet, so the catch block can still set HTTP 4xx
  and emit a single, well-formed JSON error document (with the error
  text properly JSON-escaped via writeJSONString).

Net effect: bulk row data streams to the network as before (no full-
response RAM accumulation), and the empty-aggregation regression
covered by tests/integration/test_prometheus_protocols/test_evaluation
still produces a valid JSON error body instead of a half-success /
half-error concatenation.

Made-with: Cursor
Comment thread src/Storages/TimeSeries/PrometheusHTTPProtocolAPI.cpp
… test

Address the remaining clickhouse-gh AI Review items on PR ClickHouse#103286:

❌ Blocker — src/Storages/TimeSeries/PrometheusHTTPProtocolAPI.cpp:114
The previous revision only deferred the success envelope past the *first*
PullingPipelineExecutor::pull(). A throw from a *subsequent* pull (later
block) would still land in QueryAPIImpl's catch block and append a second
'{"status":"error",...}' object onto a response that already started with
the success envelope, regressing to the exact concatenated-JSON shape this
PR exists to prevent.

QueryAPIImpl::handlingRequestWithContext now branches on
`body_buf.count() == 0`:
  - Pre-output (count == 0): same as before — set HTTP 4xx and emit a
    single, well-formed structured-error JSON document.
  - Post-output (count  > 0): re-throw. The outer
    PrometheusRequestHandler::handleRequest catch invokes
    WriteBufferFromHTTPServerResponse::cancelWithException, which appends
    a __exception__ marker block in a new chunk and intentionally skips
    the final empty chunk to break HTTP. The client sees a connection-
    level abort instead of concatenated JSON — wire format stays
    well-defined and no second JSON document is ever written.

⚠️ Major — tests/integration/test_prometheus_protocols/test_evaluation.py:2763
The empty-aggregation regression guard accepted both `status == "success"`
and `status == "error"`, so it would silently re-pass if the function-level
fix regressed back to throwing. Split into two strict tests:

  - test_query_range_empty_aggregation_returns_empty_matrix
      Asserts HTTP 200 + `status == "success"` + empty matrix for
      count/sum/avg/min/max(nonexistent_metric_name). This is the strict
      regression guard for the timeSeriesFromGrid empty-array fix.
  - test_query_range_invalid_promql_returns_structured_error
      Asserts the structured-error envelope (`status == "error"` with
      `errorType` and `error`) for a deterministically broken PromQL
      expression. This covers the handler's pre-output error-path
      contract independently from the function-level guarantee.

Made-with: Cursor
Comment thread tests/integration/test_prometheus_protocols/test_evaluation.py
Address clickhouse-gh AI Review on PR ClickHouse#103286 (⚠️ Major):
test_query_range_invalid_promql_returns_structured_error previously
validated only the JSON envelope, so it would still pass if the handler
regressed to returning HTTP 200 (or any non-4xx) with the same error
shape — masking a real status-code regression for parse failures.

Lock both layers at once:
  - assert response.status_code == 400
  - assert body["errorType"] == "bad_data"
  - keep the existing status/error-field shape assertions

Both invariants are guaranteed by QueryAPIImpl::handlingRequestWithContext
in src/Server/PrometheusRequestHandler.cpp on the pre-output error path
(setStatusAndReason(HTTP_BAD_REQUEST) + the literal "errorType":"bad_data"
prelude), so this strengthens the regression guard without changing
runtime behavior.

Made-with: Cursor
Comment thread tests/integration/test_prometheus_protocols/test_evaluation.py
@clickhouse-gh clickhouse-gh Bot added pr-bugfix Pull request with bugfix, not backported by default and removed pr-experimental Experimental Feature labels Apr 21, 2026
Comment thread src/Functions/TimeSeries/timeSeriesRange.cpp Outdated
Address clickhouse-gh AI Review on PR ClickHouse#103286
(❌ Blocker, src/Functions/TimeSeries/timeSeriesRange.cpp:280):
guard i == 0 explicitly when computing values_base_offset.

Note on correctness: IColumn::Offsets is a PaddedPODArray<UInt64> with
reverse padding, so (*values_offsets)[-1] == 0 is well-defined. This is
the canonical idiom used by ColumnArray::offsetAt(ssize_t i) and
sizeAt(ssize_t i) in src/Columns/ColumnArray.h:235-236, and the
existing 03254_timeseries_range stateless reference already exercises
the empty-array first-row case (`SELECT timeSeriesFromGrid(..., []::Array(Float64))`)
deterministically.

The explicit `(i == 0) ? 0 : (*values_offsets)[i - 1]` does not change
runtime behavior; it makes the intent obvious at the call site without
requiring the reader to know the PaddedPODArray invariant, and removes
the silent dependence on it.

Made-with: Cursor
@JTCunning JTCunning marked this pull request as ready for review April 21, 2026 21:08
@clickhouse-gh
Copy link
Copy Markdown
Contributor

clickhouse-gh Bot commented Apr 21, 2026

LLVM Coverage Report

Metric Baseline Current Δ
Lines 84.00% 84.10% +0.10%
Functions 91.10% 91.10% +0.00%
Branches 76.50% 76.60% +0.10%

Changed lines: 70.49% (43/61) · Uncovered code

Full report · Diff report

@vitlibar vitlibar marked this pull request as draft May 6, 2026 17:24

The current timestamp is increased by `step` until it becomes greater than `end_timestamp`
If the number of the values doesn't match the number of the timestamps, the function throws an exception.
An empty input array `[]` is treated as "no samples for this row" and yields an empty result `[]`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this fix is wrong, I'll make my PR to address the issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp-promql PromQL / time-series subsystem: TimeSeries storage engine, PromQL parser, PromQL-to-SQL converter... experimental feature Bug in the feature that should not be used in production pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants