-
Notifications
You must be signed in to change notification settings - Fork 2.1k
perf(cubeorchestrator): Arrow-backed CubeStore results + columnar driver batches #11236
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: perf/sqlapi-streaming-columnar-v1
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import * as stream from 'stream'; | ||
|
|
||
| /** | ||
| * Marker set on every batch object emitted by `ColumnarResponse`. Downstream consumers | ||
| * (the query orchestrator's `QueryStream` and `cubejs-backend-native`) key on this to tell | ||
| * a columnar batch apart from a plain row object. | ||
| */ | ||
| export const COLUMNAR_RESPONSE_TYPE = 'ColumnarResponse'; | ||
|
|
||
| /** | ||
| * A single columnar batch as it flows through the object-mode stream. | ||
| * `members[j]` names `columns[j]`; every `columns[j]` has the same length (the batch's | ||
| * row count). Matches the `{ members, columns }` shape decoded on the Rust side | ||
| * (`JsonColumnarValueObject`). | ||
| */ | ||
| export interface ColumnarBatch { | ||
| $type: typeof COLUMNAR_RESPONSE_TYPE; | ||
| members: string[]; | ||
| columns: any[][]; | ||
| } | ||
|
|
||
| /** | ||
| * Object-mode Transform that pivots position-indexed array rows (e.g. pg `rowMode: 'array'`) | ||
| * into columnar batches. Buffering by column avoids allocating a per-row object per row and | ||
| * moves the row→columnar pivot out of `cubejs-backend-native`, so the SQL-API streaming path | ||
| * can hand columnar batches straight to Rust. | ||
| * | ||
| * Input: `unknown[]` rows whose cells are ordered to match `members`. | ||
| * Output: `ColumnarBatch` objects, one every `batchSize` rows plus a final partial batch. | ||
| */ | ||
| export class ColumnarResponse extends stream.Transform { | ||
| private readonly members: string[]; | ||
|
|
||
| private readonly batchSize: number; | ||
|
|
||
| private columns: any[][]; | ||
|
|
||
| private count = 0; | ||
|
|
||
| public constructor({ members, batchSize }: { members: string[]; batchSize: number }) { | ||
| super({ objectMode: true }); | ||
| this.members = members; | ||
| this.batchSize = batchSize; | ||
| this.columns = members.map(() => []); | ||
| } | ||
|
|
||
| public _transform(row: unknown[], _encoding: BufferEncoding, callback: stream.TransformCallback) { | ||
| for (let j = 0; j < this.members.length; j++) { | ||
| this.columns[j].push(row[j]); | ||
| } | ||
| this.count++; | ||
| if (this.count >= this.batchSize) { | ||
| this.flushBatch(); | ||
| } | ||
| callback(); | ||
| } | ||
|
|
||
| public _flush(callback: stream.TransformCallback) { | ||
| if (this.count > 0) { | ||
| this.flushBatch(); | ||
| } | ||
| callback(); | ||
| } | ||
|
|
||
| private flushBatch() { | ||
| const batch: ColumnarBatch = { | ||
| $type: COLUMNAR_RESPONSE_TYPE, | ||
| members: this.members, | ||
| columns: this.columns, | ||
| }; | ||
| this.push(batch); | ||
| this.columns = this.members.map(() => []); | ||
| this.count = 0; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import { Readable } from 'stream'; | ||
| import { ColumnarResponse, COLUMNAR_RESPONSE_TYPE } from '../../src'; | ||
|
|
||
| async function collect(members: string[], batchSize: number, rows: unknown[][]) { | ||
| const source = Readable.from(rows, { objectMode: true }); | ||
| const columnar = new ColumnarResponse({ members, batchSize }); | ||
| const batches: any[] = []; | ||
| source.pipe(columnar); | ||
| for await (const batch of columnar) { | ||
| batches.push(batch); | ||
| } | ||
| return batches; | ||
| } | ||
|
|
||
| describe('ColumnarResponse', () => { | ||
| const members = ['a', 'b']; | ||
|
|
||
| test('emits a batch at every batchSize boundary and a final partial batch', async () => { | ||
| const rows = [ | ||
| [1, 'x'], | ||
| [2, 'y'], | ||
| [3, 'z'], | ||
| [4, 'w'], | ||
| [5, 'v'], | ||
| ]; | ||
|
|
||
| const batches = await collect(members, 2, rows); | ||
|
|
||
| expect(batches).toHaveLength(3); // 2 + 2 + 1 | ||
| batches.forEach((batch) => { | ||
| expect(batch.$type).toBe(COLUMNAR_RESPONSE_TYPE); | ||
| expect(batch.members).toEqual(members); | ||
| }); | ||
|
|
||
| expect(batches[0].columns).toEqual([[1, 2], ['x', 'y']]); | ||
| expect(batches[1].columns).toEqual([[3, 4], ['z', 'w']]); | ||
| expect(batches[2].columns).toEqual([[5], ['v']]); | ||
| }); | ||
|
|
||
| test('transposes positional array rows into columns', async () => { | ||
| const rows = [ | ||
| [1, 'x'], | ||
| [2, 'y'], | ||
| ]; | ||
|
|
||
| const batches = await collect(members, 100, rows); | ||
|
|
||
| expect(batches).toHaveLength(1); | ||
| expect(batches[0]).toEqual({ | ||
| $type: COLUMNAR_RESPONSE_TYPE, | ||
| members, | ||
| columns: [[1, 2], ['x', 'y']], | ||
| }); | ||
| }); | ||
|
|
||
| test('does not emit anything for an empty stream', async () => { | ||
| const batches = await collect(members, 2, []); | ||
| expect(batches).toHaveLength(0); | ||
| }); | ||
|
|
||
| test('reuses fresh column arrays per batch (no shared references)', async () => { | ||
| const rows = [ | ||
| [1, 'x'], | ||
| [2, 'y'], | ||
| [3, 'z'], | ||
| [4, 'w'], | ||
| ]; | ||
|
|
||
| const batches = await collect(members, 2, rows); | ||
|
|
||
| expect(batches).toHaveLength(2); | ||
| // First batch's columns must not be mutated by the second batch. | ||
| expect(batches[0].columns).toEqual([[1, 2], ['x', 'y']]); | ||
| expect(batches[1].columns).toEqual([[3, 4], ['z', 'w']]); | ||
| expect(batches[0].columns).not.toBe(batches[1].columns); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ import { types, FieldDef } from 'pg'; | |
| import { TypeId, TypeFormat } from 'pg-types'; | ||
| import { | ||
| BaseDriver, | ||
| ColumnarResponse, | ||
| DownloadQueryResultsOptions, DownloadTableMemoryData, DriverInterface, | ||
| GenericDataBaseType, IndexesSQL, TableStructure, StreamOptions, | ||
| StreamTableDataWithTypes, QueryOptions, DownloadQueryResultsResult, DriverCapabilities, TableColumn, createPoolName, | ||
|
|
@@ -353,7 +354,7 @@ export class PostgresDriver<Config extends PostgresDriverConfiguration = Postgre | |
| public async stream( | ||
| query: string, | ||
| values: unknown[], | ||
| { highWaterMark }: StreamOptions | ||
| { highWaterMark, columnar }: StreamOptions | ||
| ): Promise<StreamTableDataWithTypes> { | ||
| PostgresDriver.checkValuesLimit(values); | ||
|
|
||
|
|
@@ -366,17 +367,38 @@ export class PostgresDriver<Config extends PostgresDriverConfiguration = Postgre | |
| types: { | ||
| getTypeParser: this.getTypeParser, | ||
| }, | ||
| highWaterMark | ||
| }); | ||
| highWaterMark, | ||
| // In columnar mode read rows as positional arrays instead of per-row objects, so | ||
| // `ColumnarResponse` can bucket cells into columns without any object allocation. | ||
| // @types/pg-query-stream (1.0.3) does not declare `rowMode`, hence the cast. | ||
| ...(columnar ? { rowMode: 'array' } : {}), | ||
| } as any); | ||
| const rowStream: QueryStream = await conn.query(queryStream); | ||
| const fields = await rowStream.fields(); | ||
| const mappedTypes = this.mapFields(fields); | ||
| const release = async () => { | ||
| await this.pool.release(conn); | ||
| }; | ||
|
|
||
| if (columnar) { | ||
| const members = fields.map((f) => f.name); | ||
| const columnarStream = new ColumnarResponse({ members, batchSize: highWaterMark }); | ||
| // `pipe()` does not forward errors — propagate manually so the consumer sees the | ||
| // failure and `release()` still runs during cleanup. | ||
| rowStream.once('error', (e) => columnarStream.destroy(e)); | ||
| rowStream.pipe(columnarStream); | ||
|
Comment on lines
+383
to
+389
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two things worth checking here:
|
||
|
|
||
| return { | ||
| rowStream: columnarStream, | ||
| types: mappedTypes, | ||
| release, | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| rowStream, | ||
| types: this.mapFields(fields), | ||
| release: async () => { | ||
| await this.pool.release(conn); | ||
| } | ||
| types: mappedTypes, | ||
| release, | ||
| }; | ||
| } catch (e) { | ||
| await this.pool.release(conn); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import * as stream from 'stream'; | ||
| import { getEnv } from '@cubejs-backend/shared'; | ||
| import { COLUMNAR_RESPONSE_TYPE } from '@cubejs-backend/base-driver'; | ||
|
|
||
| export class QueryStream extends stream.Transform { | ||
| private timeout = 5 * 60 * 1000; | ||
|
|
@@ -43,6 +44,24 @@ export class QueryStream extends stream.Transform { | |
| if (this.streams.has(this.queryKey)) { | ||
| this.streams.delete(this.queryKey); | ||
| } | ||
|
|
||
| // Columnar batch (see `ColumnarResponse` in base-driver): remap the `members` array | ||
| // (aliases → member names) once and forward the batch untouched. | ||
| if (chunk && chunk.$type === COLUMNAR_RESPONSE_TYPE) { | ||
| const members = this.aliasNameToMember | ||
| ? chunk.members.map((alias) => this.aliasNameToMember[alias]) | ||
| : chunk.members; | ||
| const rowCount = chunk.columns[0] ? chunk.columns[0].length : 0; | ||
| if (this.counter + rowCount < this.readableHighWaterMark) { | ||
| this.counter += rowCount; | ||
| } else { | ||
| this.counter = 0; | ||
| this.debounce(); | ||
| } | ||
| callback(null, { $type: COLUMNAR_RESPONSE_TYPE, members, columns: chunk.columns }); | ||
|
Comment on lines
+50
to
+61
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two small concerns in the columnar branch:
|
||
| return; | ||
| } | ||
|
|
||
| let row = {}; | ||
| if (this.aliasNameToMember) { | ||
| Object.keys(chunk).forEach((alias) => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: this uses the string literal
'ColumnarResponse'whilepackages/cubejs-base-driver/src/ColumnarResponse.tsexportsCOLUMNAR_RESPONSE_TYPEas the canonical marker. Importing the constant here (from@cubejs-backend/base-driver) would keep the two ends in lockstep — if the sentinel ever changes, the native writer won't silently fall through to the row-accumulation path.