Skip to content

Commit 42624b6

Browse files
ryan-williamsclaude
andcommitted
plan: add columnChunkAggregation opt-in for column-chunk coalescing
Existing behavior: `parquetPlan` coalesces a row group's column chunks into one byte range when (a) no `columns` projection is set, AND (b) the group span is < 32MB. With `columns` set, every chunk became a separate fetch. That's painful on high-latency stores (R2 / S3): a `metric=all` query over 7 daily parquets with 5 projected cols × ~12 row groups = ~420 range GETs. On Cloudflare Workers each GET carries non-trivial CPU overhead and the 1102 (CPU exceeded) limit kicks in. Add `columnChunkAggregation?: number` to `BaseParquetReadOptions`. When caller passes a value, coalesce up to that threshold even with `columns` projected — the bounded overfetch (≤ rg span) is a much better trade than N range GETs. Default behavior unchanged (32MB when `columns` unset; 0 when set). Pass 0 explicitly to disable coalescing entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b81f95d commit 42624b6

3 files changed

Lines changed: 82 additions & 5 deletions

File tree

src/plan.js

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { parquetSchema } from './metadata.js'
33
import { getPhysicalColumns } from './schema.js'
44
import { concat } from './utils.js'
55

6-
// Combine column chunks into a single byte range if less than 32mb
7-
const columnChunkAggregation = 1 << 25 // 32mb
6+
// Default: combine column chunks into a single byte range if less than 32mb
7+
// (only applied when no `columns` projection is set, unless caller opts in via
8+
// `columnChunkAggregation`).
9+
const defaultColumnChunkAggregation = 1 << 25 // 32mb
810

911
/**
1012
* @import {AsyncBuffer, ByteRange, ColumnMetaData, GroupPlan, ParquetReadOptions, QueryPlan} from '../src/types.js'
@@ -16,8 +18,13 @@ const columnChunkAggregation = 1 << 25 // 32mb
1618
* @param {ParquetReadOptions} options
1719
* @returns {QueryPlan}
1820
*/
19-
export function parquetPlan({ metadata, rowStart = 0, rowEnd = Infinity, columns, filter }) {
21+
export function parquetPlan({ metadata, rowStart = 0, rowEnd = Infinity, columns, filter, columnChunkAggregation }) {
2022
if (!metadata) throw new Error('parquetPlan requires metadata')
23+
// When unset, preserve legacy behavior: coalesce only when reading all
24+
// columns. When set explicitly, honor the threshold even with `columns`
25+
// projected (callers on high-latency stores e.g. R2 prefer a small amount
26+
// of overfetch over many tiny range GETs).
27+
const aggBytes = columnChunkAggregation ?? (columns ? 0 : defaultColumnChunkAggregation)
2128
/** @type {GroupPlan[]} */
2229
const groups = []
2330
/** @type {ByteRange[]} */
@@ -48,8 +55,8 @@ export function parquetPlan({ metadata, rowStart = 0, rowEnd = Infinity, columns
4855

4956
// map group plan to ranges
5057
const groupSize = ranges[ranges.length - 1]?.endByte - ranges[0]?.startByte
51-
if (!columns && groupSize < columnChunkAggregation) {
52-
// full row group
58+
if (aggBytes > 0 && groupSize < aggBytes) {
59+
// coalesce all selected column chunks of this row group into one fetch
5360
fetches.push({
5461
startByte: ranges[0].startByte,
5562
endByte: ranges[ranges.length - 1].endByte,

src/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export interface BaseParquetReadOptions {
4444
utf8?: boolean // decode byte arrays as utf8 strings (default true)
4545
parsers?: ParquetParsers // custom parsers to decode advanced types
4646
geoparquet?: boolean // parse geoparquet metadata and set logical type to geometry/geography for geospatial columns (default true)
47+
columnChunkAggregation?: number // coalesce a row group's selected column chunks into one byte range when their span is below this many bytes. Default: 32mb when `columns` is unset, 0 (no coalescing) when `columns` is set. Pass an explicit value (e.g. on high-latency stores) to coalesce even with `columns` projected; pass 0 to always issue one fetch per chunk.
4748
}
4849

4950
interface ArrayRowFormat {

test/plan.test.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,73 @@ describe('parquetPlan', () => {
3636
],
3737
})
3838
})
39+
40+
describe('columnChunkAggregation option', () => {
41+
it('default behavior: columns set → no coalescing (one fetch per chunk)', async () => {
42+
const file = await asyncBufferFromFile('test/files/page_indexed.parquet')
43+
const metadata = await parquetMetadataAsync(file)
44+
const plan = parquetPlan({ file, metadata, columns: ['row', 'quality'] })
45+
// Both chunks of each row group emitted as separate fetches.
46+
expect(plan.fetches).toEqual([
47+
{ startByte: 4, endByte: 832 },
48+
{ startByte: 832, endByte: 1166 },
49+
{ startByte: 1166, endByte: 1998 },
50+
{ startByte: 1998, endByte: 2326 },
51+
])
52+
})
53+
54+
it('columns + columnChunkAggregation > groupSize → coalesces within each rg', async () => {
55+
const file = await asyncBufferFromFile('test/files/page_indexed.parquet')
56+
const metadata = await parquetMetadataAsync(file)
57+
const plan = parquetPlan({
58+
file, metadata, columns: ['row', 'quality'], columnChunkAggregation: 1 << 25,
59+
})
60+
// Same shape as default no-columns case: one fetch per row group.
61+
expect(plan.fetches).toEqual([
62+
{ startByte: 4, endByte: 1166 },
63+
{ startByte: 1166, endByte: 2326 },
64+
])
65+
})
66+
67+
it('columns + columnChunkAggregation < groupSize → no coalescing', async () => {
68+
const file = await asyncBufferFromFile('test/files/page_indexed.parquet')
69+
const metadata = await parquetMetadataAsync(file)
70+
// groupSize per rg is 1162 bytes; threshold of 1000 disables coalescing.
71+
const plan = parquetPlan({
72+
file, metadata, columns: ['row', 'quality'], columnChunkAggregation: 1000,
73+
})
74+
expect(plan.fetches).toEqual([
75+
{ startByte: 4, endByte: 832 },
76+
{ startByte: 832, endByte: 1166 },
77+
{ startByte: 1166, endByte: 1998 },
78+
{ startByte: 1998, endByte: 2326 },
79+
])
80+
})
81+
82+
it('no columns + columnChunkAggregation: 0 → disables default coalescing', async () => {
83+
const file = await asyncBufferFromFile('test/files/page_indexed.parquet')
84+
const metadata = await parquetMetadataAsync(file)
85+
const plan = parquetPlan({ file, metadata, columnChunkAggregation: 0 })
86+
expect(plan.fetches).toEqual([
87+
{ startByte: 4, endByte: 832 },
88+
{ startByte: 832, endByte: 1166 },
89+
{ startByte: 1166, endByte: 1998 },
90+
{ startByte: 1998, endByte: 2326 },
91+
])
92+
})
93+
94+
it('columns subset + coalesce: still one fetch per rg covering subset span', async () => {
95+
const file = await asyncBufferFromFile('test/files/page_indexed.parquet')
96+
const metadata = await parquetMetadataAsync(file)
97+
const plan = parquetPlan({
98+
file, metadata, columns: ['quality'], columnChunkAggregation: 1 << 25,
99+
})
100+
// Only the second chunk of each rg is selected; "coalesce" with one
101+
// chunk just returns that chunk's range.
102+
expect(plan.fetches).toEqual([
103+
{ startByte: 832, endByte: 1166 },
104+
{ startByte: 1998, endByte: 2326 },
105+
])
106+
})
107+
})
39108
})

0 commit comments

Comments
 (0)