Skip to content

perf(cubesql): Migrate streaming to columnar JSON batches#11235

Open
ovr wants to merge 4 commits into
masterfrom
perf/sqlapi-streaming-columnar-v1
Open

perf(cubesql): Migrate streaming to columnar JSON batches#11235
ovr wants to merge 4 commits into
masterfrom
perf/sqlapi-streaming-columnar-v1

Conversation

@ovr

@ovr ovr commented Jul 13, 2026

Copy link
Copy Markdown
Member

The SQL API streaming path shipped rows across the JS->Rust native bridge as an array of row objects and rebuilt each RecordBatch cell-by-cell through per-cell Neon/NAPI downcasts (JsValueObject::get). This mirrors the row-oriented transport the /load path already replaced with a columnar JSON Buffer.

Benchmark

Measured on a local Postgres (Docker) with a pre-loaded 10M-row orders table, streaming the ungrouped result through the SQL API to psql, so the data source is not the bottleneck (server-side seq-scan ≈ 0.3s). Server-side = Cube Load Request duration (the streaming pipeline itself). The dimension-width axis mirrors our k6 benchmark (status, user_id, product_id + dim1..dimN).

10M rows

dims master (row + per-cell NAPI) this PR (columnar + JSON) speedup
3 18.2s 12.4s 1.47×
8 37.9s 23.9s 1.58×
16 65.9s 42.7s 1.54×
32 173.6s 98.7s 1.76×

2M rows

dims master this PR speedup
3 4.0s 2.4s 1.68×
8 7.5s 4.9s 1.53×
16 13.1s 9.1s 1.44×
32 34.3s 19.9s 1.72×

~1.4–1.8× faster server-side across all widths and row counts, with the largest gain at 32 dimensions — per-cell NAPI cost scales with the number of cells, so wide rows benefit most. The 10M × 32-dim case drops from ~173s to ~99s (−43%).

ovr added 4 commits July 10, 2026 13:55
The SQL API streaming path shipped rows across the JS->Rust native bridge
as an array of row objects and rebuilt each `RecordBatch` cell-by-cell
through per-cell Neon/NAPI downcasts (`JsValueObject::get`). This mirrors
the row-oriented transport the `/load` path already replaced with a
columnar JSON `Buffer`.
@ovr ovr requested review from a team as code owners July 13, 2026 08:46
@github-actions github-actions Bot added driver:postgres Issues relating to the Postgres driver rust Pull requests that update Rust code javascript Pull requests that update Javascript code data source driver labels Jul 13, 2026
@claude

claude Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ovr's task in 3m 57s —— View job


Code review — verdict: LGTM, small cleanups suggested (0 high, 1 medium inline, ~4 low)

Full review (click to expand)
  • Load context
  • Review Rust changes (cubeorchestrator, cubesql)
  • Review native bridge changes (orchestrator.rs, stream.rs)
  • Review JS/TS changes (ResultWrapper, ColumnarResponse, PostgresDriver, QueryStream)
  • Check tests
  • Post final review

Overall

Nice, focused refactor. The end-to-end story is coherent:

  1. ColumnarResponse (new base-driver Transform) pivots pg's rowMode: 'array' output into { members, columns } batches — the pivot moves to the earliest point that has typed field metadata.
  2. The native bridge stops walking a JsArray cell-by-cell through Neon (JsValueObject) and instead decodes a JsBuffer with serde_json::from_slice into JsonColumnarValueObject — this is the biggest single win.
  3. transform_response is now single-shape (ColumnarValueObject); the row-mode ValueObject/JsonValueObject and the build_column_iter_loop! row/columnar dispatch macro are gone. Nice de-duplication.
  4. ColumnarArray gains an Arrow variant so CubeStore Arrow results can flow through the transform + serialize path without materializing per-cell primitives — clones become Arc bumps per chunk, has_string_values() gates the identity-transform fast path, and the test test_columnar_transform_shares_arrow_columns covers the shared-column invariant end-to-end.

Serialization contract between DBResponsePrimitive and DBResponseValueRef is well-called-out in comments — please keep those in lockstep if numeric-string vs numeric-JSON encodings ever change.

Findings

Medium — hardcoded sentinel (packages/cubejs-backend-native/js/index.ts:296)
The columnar branch checks row.$type === 'ColumnarResponse' as a string literal instead of the exported COLUMNAR_RESPONSE_TYPE constant from @cubejs-backend/base-driver (posted inline). Every other consumer (QueryStream.ts, ColumnarResponse.ts, its tests) uses the constant. If the sentinel value ever changes, this site silently regresses to buffering columnar batches as row objects and then throwing when rowsToColumnarBuffer tries to Object.keys(row) on {$type, members, columns}.

Low — ColumnarArray::push panics for Arrow variant (rust/cube/cubeorchestrator/src/query_result_transform.rs, new push impl)
All constructors currently produce Cells, so unreachable today, but the invariant lives only in a runtime panic!. A future refactor that flows through push after a .clone() of a shared column will blow up in prod. Consider a comment on the Arrow variant declaring immutability, or a stricter constructor.

Low — db_value_to_field_value doesn't fast-path StringOwned in get_cubestore_result (packages/cubejs-backend-native/src/orchestrator.rs:281-284)
The added DBResponseValueRef::Str(s) => cx.string(s).upcast() avoids a re-allocation, but StringOwned (Arrow decimals) still falls through to other.to_string() which allocates a fresh String from Display and then hands it to cx.string. A StringOwned(s) => cx.string(&s).upcast() arm would match. Small win; wide-Arrow-decimal columns benefit most.

Low — LiteralRowsValueObject::column silently returns Nulls (rust/cubesql/cubesql/src/compile/engine/df/scan.rs, new type)
The doc says "every schema field is MemberField::Literal, so column() is never actually invoked" — good invariant. But the impl still returns a stream of Nulls if it is called (e.g. schema drift). Given the MemberField::Literal arm in build_column_custom_builder! never calls column(), an unreachable!() would fail-fast if someone reuses the type in a non-literal context.

Low — QueryStream._transform builds a fresh batch object even when aliasNameToMember is null (packages/cubejs-query-orchestrator/src/orchestrator/QueryStream.ts:61)
When no aliasing is needed, chunk.members === members and the { $type, members, columns: chunk.columns } re-construction produces an equivalent object. Not a bug (references reused), but you can skip the allocation with callback(null, chunk) in the no-alias case.

Tests

Coverage for the new ColumnarResponse transform (4 unit tests) and the Arrow-shared column path (test_columnar_transform_shares_arrow_columns) is appropriate. Consider a small parser test that a mixed Utf8 / Utf8View / LargeUtf8 ArrowColumn correctly reports has_string_values() == true — the current test uses Utf8 only.

Docs

Nothing user-facing — columnar in StreamOptions is an internal hint. No doc changes required.

· branch: `perf/sqlapi-streaming-columnar-v1`

@claude

claude Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ovr's task in 4m 18s —— View job


Claude PR Review — 0 high, 0 medium, 3 low

Nice, focused perf win. The row→columnar consolidation on the streaming path mirrors the /load path cleanly, and dropping the JsValueObject per-cell NAPI downcasts in favor of a single serde_json::from_slice on a Buffer is the right shape. The ValueObject trait / transform_columnar_response naming cleanup is a nice side benefit. Benchmarks in the description are convincing (1.4–1.8× server-side across widths).

Details — checklist and findings
  • Load context
  • Review Rust changes (cubesql/scan.rs, transport)
  • Review native bridge (orchestrator.rs, stream.rs, transport.rs)
  • Review JS/TS changes (ResultWrapper, index.ts)
  • Check tests & benches
  • Post final review

Findings (all low)

  1. limit as usize at scan.rs:763 — negative-limit behavior change. Option<i32> limit; old code used for _ in 0..limit (empty range for negative). New code wraps to usize::MAX on any negative i32 and would try to allocate ~2^64 NULLs. Suggest usize::try_from(limit).unwrap_or(0). Inline comment posted with a suggestion block. Same comment also flags a related subtlety: the old JsonValueObject errored loudly if a MemberField::Member reached this path; the new LiteralRowsValueObject::column() silently returns NULLs.

  2. rowsToColumnar derives schema from rows[0] (ResultWrapper.ts:39). If any streaming chunk's first row omits a key that later rows carry, that column is silently dropped. Uniform-shape row streams (Postgres cursor, MSSQL, etc.) make this safe in practice — but this invariant now matters per streaming chunk. Worth an explicit doc note. Inline comment posted.

  3. query.response_format = Some(Columnar) at service.rs:313 is unconditional — clobbers caller intent. Correct given the response type below, but deserves a one-line comment. Inline comment posted.

Also noted (no action needed)

  • Resolved a prior stale review thread of mine referencing COLUMNAR_RESPONSE_TYPE — that constant/branch does not exist in the current tree.
  • Benches file trims the row-format path cleanly; convert_transport_response_columnarconvert_transport_response rename cascades through tests, mod.rs mock helper (simple_load_response transposes to columnar), and test/mod.rs type aliases — all consistent.
  • stream.rs: serde_json::from_slice(chunk_buffer.as_slice(&cx)) is the intended replacement for the Neon JsValueDeserializer walk; error path calls cx.throw_error before the callback is scheduled, matching the previous behavior on parse failures.
· [branch](https://github.com/cube-js/cube/tree/perf/sqlapi-streaming-columnar-v1)

Comment thread packages/cubejs-backend-native/js/index.ts

let mut response = JsonValueObject::new(data);
let mut response = LiteralRowsValueObject {
row_count: limit as usize,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low — negative-limit behavior change. request.limit is Option<i32>; the old path used for _ in 0..limit, which for a negative limit (e.g. Some(-1)) produced an empty range. The new limit as usize wraps to usize::MAX, so LiteralRowsValueObject::len() returns ~2^64 and the per-column loop in build_column_custom_builder! will try to append that many NULLs (OOM / hang). The pre-clamp at scan.rs:510 only guards against > query_limit, not negatives.

Cheap defensive fix:

Suggested change
row_count: limit as usize,
let mut response = LiteralRowsValueObject {
row_count: usize::try_from(limit).unwrap_or(0),
};

Separately: the doc comment on LiteralRowsValueObject claims column() is "never actually invoked" because every field is a MemberField::Literal, but the old JsonValueObject explicitly errored if a MemberField::Member slipped through this path (rows were Value::Null, as_object() returned None). The new impl silently returns a NULL column instead. If this invariant ever breaks, the new behavior masks the bug; consider returning an error from column() to keep the loud-fail semantics.

* (~11 ms on the same payload). On a real server it should be 3-6× slower,
* so avoiding the JsValue walk matters even more there.
*/
export function rowsToColumnarBuffer(rawData: any): Buffer {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low — rowsToColumnar derives the schema from rows[0] only. Object.keys(rows[0]) at line 39 fixes the column set from row 0. If later rows in the stream buffer ever include additional keys, those columns are silently dropped before crossing the bridge. Postgres cursors return uniform row shape today, so this is likely safe — but this invariant now matters per streaming chunk (not just per full result), so worth a short doc note here or in rowsToColumnar making it explicit.

},
};

query.response_format =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low — unconditional format override. This clobbers whatever response_format the caller set on query. Given the deserialization target is now V1LoadResponse<V1LoadResultDataColumnar>, this is intentional and correct, but worth a one-line comment explaining "we force columnar because the response type below assumes it" so a future reader doesn't try to make the format caller-controlled.

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

Labels

data source driver driver:postgres Issues relating to the Postgres driver javascript Pull requests that update Javascript code rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant