Skip to content

Commit 3edb15b

Browse files
philcunliffeclaude
andauthored
Scan pruning and sort-on-write (#20, #21, #22) (#23)
* Add scan pruning and sort-on-write (closes #20, #21, #22) Implements the Iceberg scan-performance set: prune non-matching data on read, and lay out data sorted so those bounds are tight. #20 — file pruning via manifest column bounds: - New src/write/serde.js: extract the Iceberg single-value codec + type comparator out of stats.js (no behavior change) and add deserializeValue, the read-side inverse of serializeValue. - New fileMightMatch in prune.js (sharing a refactored AND/OR/$nor walker with partitionMightMatch), AND'd into icebergDataSource scan planning. It decodes each data file's lower_bounds/upper_bounds and skips files whose bounds prove no row can match. Conservative: never range-prunes truncated string/binary bounds, keeps on any uncertainty. #21 — parquet row-group pruning (verify + test): - hyparquet's parquetPlan/canSkipRowGroup already skips row groups by per-row-group statistics + bloom filters when a filter is passed, which icebird already does. Added scanPruning.test.js asserting reduced bytes read for a selective predicate on a multi-row-group file, plus a missing-stats safe-fallback test. #22 — sort on append + compaction: - New src/write/sort.js buildSortComparator (direction, null-order, transforms, NaN-last, stable). prepareAppend now orders each written file by the table's default sort order and records the real sort_order_id; a sortOrderId override is threaded through icebergAppend/StageAppend/tx.append. Empty sort order is a no-op. - New src/write/rewrite.js + icebergRewrite (exported): reads every live row (deletes applied), sorts globally, regroups under the target spec, writes consolidated sorted files, and commits a replace snapshot. Not retried on conflict (would risk dropping concurrently-appended rows); v2-only for now (v3 row-lineage preservation is a follow-up). Docs: README Supported Features (Sorting, Scan Pruning) + a compaction snippet; data-source doc comment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix date bound pruning and cover NaN sort branch (PR #23 review) Address the two major findings from the dual-agent review: - date mis-prune: compare() had no date case, so a Date query literal (coerced to milliseconds) was compared against bounds decoded as days-since-epoch, skipping matching files. Add a date case backed by a dateToDays() helper that normalizes Date/bigint/number/ISO-string to one domain and returns NaN (undecidable -> keep file) otherwise. Fixed in the canonical comparator so read-prune, write-stats, and sort all benefit; also fixes a latent bug for date columns mixing Date and number values. - Add fileMightMatch date tests (Date, numeric-days, ISO-string, unparseable). - Add buildSortComparator tests over a double field with NaN: NaN orders greatest under asc, reverses to first under desc, and NaN-vs-NaN is a stable tie. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix typecheck errors in scan-pruning tests CI runs `npx tsc` over src + test with strict/checkJs, but the new test files had several type errors: - prune.bounds: bare-value filters ({ id: 9n }) aren't modelled by hyparquet's ParquetQueryFilter — cast past the type. - scanPruning: type columnData/parquetSchema as ColumnSource[]/ SchemaElement[]; add required 'last-partition-id'; coerce ids with Number() before subtracting in the sort comparator. - sort: sort_order_id is optional, so widen the return to (number | undefined)[]. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9bddfef commit 3edb15b

17 files changed

Lines changed: 2021 additions & 263 deletions

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ import {
140140
icebergCreateTable,
141141
icebergDelete,
142142
icebergExpireSnapshots,
143+
icebergRewrite,
143144
icebergSetRef,
144145
} from 'icebird'
145146

@@ -172,6 +173,17 @@ await icebergSetRef({ catalog, tableUrl, ref: 'main', snapshotId })
172173
await icebergExpireSnapshots({ catalog, tableUrl, snapshotIds: [oldSnapshotId] })
173174
```
174175

176+
If the table is created with a `sortOrder`, `icebergAppend` orders the rows in each written file by that order (tightening per-file column bounds for scan pruning). `icebergRewrite` compacts the current snapshot — reading every live row (deletes applied), sorting globally, and rewriting into consolidated, non-overlapping files via a `replace` snapshot (v2 tables):
177+
178+
```javascript
179+
// compact small files into sorted, non-overlapping ones
180+
await icebergRewrite({ catalog, tableUrl })
181+
// optionally split large partitions and/or re-partition under another spec
182+
await icebergRewrite({ catalog, tableUrl, targetFileRows: 1_000_000, partitionSpecId: 1 })
183+
```
184+
185+
A rewrite is not retried on a concurrent commit (it would risk dropping rows another writer appended meanwhile); on conflict it throws and should be re-run against fresh metadata.
186+
175187
For a REST catalog, swap `fileCatalog(...)` for the connect context and pass `namespace`/`table` instead of `tableUrl`:
176188

177189
```javascript
@@ -212,7 +224,8 @@ Icebird aims to support reading any Iceberg table, but currently only supports a
212224
| Geometry Types || |
213225
| Geography Types || |
214226
| Row Lineage || v3 `_row_id` and `_last_updated_sequence_number` inheritance. |
215-
| Sorting || |
227+
| Sorting || Orders rows by the declared sort order on append; `icebergRewrite` compacts to sorted, non-overlapping files (v2). |
228+
| Scan Pruning || Skips data files via partition tuples and manifest column bounds, and parquet row groups via column statistics. |
216229
| Encryption || |
217230

218231
## References

src/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { IcebergTransactionConflictError, icebergAppend, icebergCreateTable, icebergDelete, icebergDropTable, icebergExpireSnapshots, icebergSetRef, icebergTransaction } from './write/write.js'
1+
export { IcebergTransactionConflictError, icebergAppend, icebergCreateTable, icebergDelete, icebergDropTable, icebergExpireSnapshots, icebergRewrite, icebergSetRef, icebergTransaction } from './write/write.js'
22
export { icebergCreate } from './create.js'
33
export { fileCatalog } from './catalog/file.js'
44
export { restCatalogConnect, restCatalogCreateNamespace, restCatalogDropNamespace, restCatalogListNamespaces, restCatalogListTables, restCatalogLoadCredentials, restCatalogLoadTable, restCatalogRegisterTable, restCatalogRenameTable } from './catalog/rest.js'

src/prune.js

Lines changed: 212 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { typeName } from './schema.js'
12
import { applyTransform } from './write/transform.js'
3+
import { compare, deserializeValue } from './write/serde.js'
24

35
/**
46
* Partition-level scan pruning. Given a hyparquet query filter (keyed by
@@ -26,34 +28,65 @@ export function partitionMightMatch(filter, dataEntry, schema, metadata) {
2628
const spec = metadata['partition-specs'].find(s => s['spec-id'] === dataEntry.partition_spec_id)
2729
// No spec or unpartitioned: nothing to prune on.
2830
if (!spec || spec.fields.length === 0) return true
29-
return nodeMightMatch(filter, { spec, schema, partition: dataEntry.data_file.partition })
31+
/** @type {PruneContext} */
32+
const ctx = { spec, schema, partition: dataEntry.data_file.partition }
33+
return nodeMightMatch(filter, (column, condition) => columnMightMatch(column, condition, ctx))
34+
}
35+
36+
/**
37+
* File-level scan pruning via per-column manifest bounds. Given a hyparquet
38+
* query filter (keyed by iceberg field name) and a data manifest entry, decide
39+
* whether the entry's `lower_bounds` / `upper_bounds` could contain a row
40+
* matching the filter.
41+
*
42+
* Like `partitionMightMatch` this is an inclusive projection: a file is skipped
43+
* only when its bounds prove that no row can match, and any uncertainty keeps
44+
* the file. Bounds for string/binary/fixed/uuid are stored truncated (Iceberg
45+
* `truncate(16)` metrics) so those types are never range-pruned. Pruning never
46+
* changes query results, only which files are read.
47+
*
48+
* @param {ParquetQueryFilter} filter - Filter keyed by iceberg field name.
49+
* @param {ManifestEntry} dataEntry
50+
* @param {Schema} schema - Current schema (filter column names map to its fields).
51+
* @returns {boolean} true if the file must be read, false if it can be skipped.
52+
*/
53+
export function fileMightMatch(filter, dataEntry, schema) {
54+
const { lower_bounds, upper_bounds } = dataEntry.data_file
55+
// No bounds at all: nothing to prune on.
56+
if (lower_bounds === undefined && upper_bounds === undefined) return true
57+
return nodeMightMatch(filter, (column, condition) =>
58+
boundsMightMatch(column, condition, dataEntry.data_file, schema))
3059
}
3160

3261
/**
3362
* @typedef {{ spec: PartitionSpec, schema: Schema, partition: DataFile['partition'] }} PruneContext
3463
*/
3564

3665
/**
66+
* Generic AND/OR/$nor walk over a hyparquet filter. Top-level keys are
67+
* AND-combined: the file is ruled out if any branch is ruled out. The leaf
68+
* evaluator decides a single `{column: condition}` predicate and returns false
69+
* only when it proves the file cannot match.
70+
*
3771
* @param {ParquetQueryFilter} node
38-
* @param {PruneContext} ctx
72+
* @param {(column: string, condition: any) => boolean} leafFn
3973
* @returns {boolean}
4074
*/
41-
function nodeMightMatch(node, ctx) {
75+
function nodeMightMatch(node, leafFn) {
4276
if (!node || typeof node !== 'object') return true
4377
const anyNode = /** @type {Record<string, any>} */ (node)
44-
// Top-level keys are AND-combined: the file is ruled out if any is ruled out.
4578
for (const [key, val] of Object.entries(anyNode)) {
4679
if (key === '$and') {
4780
const subs = /** @type {ParquetQueryFilter[]} */ (val)
48-
if (!subs.every(sub => nodeMightMatch(sub, ctx))) return false
81+
if (!subs.every(sub => nodeMightMatch(sub, leafFn))) return false
4982
} else if (key === '$or') {
5083
const subs = /** @type {ParquetQueryFilter[]} */ (val)
51-
if (!subs.some(sub => nodeMightMatch(sub, ctx))) return false
84+
if (!subs.some(sub => nodeMightMatch(sub, leafFn))) return false
5285
} else if (key === '$nor') {
5386
// NOT(a OR b): can't safely prune, keep.
5487
continue
5588
} else {
56-
if (!columnMightMatch(key, val, ctx)) return false
89+
if (!leafFn(key, val)) return false
5790
}
5891
}
5992
return true
@@ -331,3 +364,175 @@ function numericOf(x) {
331364
function sign(n) {
332365
return n < 0 ? -1 : n > 0 ? 1 : 0
333366
}
367+
368+
/**
369+
* Whether the file could match `condition` on `column` given the column's
370+
* decoded [lower, upper] bounds in the manifest entry. Returns true (keep) on
371+
* any uncertainty, including columns without bounds and non-orderable types.
372+
*
373+
* @param {string} column - iceberg field name
374+
* @param {any} condition - operator object like {$eq: x} or a bare value (eq)
375+
* @param {DataFile} dataFile
376+
* @param {Schema} schema
377+
* @returns {boolean}
378+
*/
379+
function boundsMightMatch(column, condition, dataFile, schema) {
380+
const field = schema.fields.find(f => f.name === column)
381+
if (!field) return true
382+
// Bounds and metric ordering are only defined for orderable scalar types.
383+
// String/binary/fixed/uuid bounds are truncated prefixes, so we never use
384+
// them for pruning (equality on a truncated prefix is undecidable).
385+
if (!isOrderableForBounds(field.type)) return true
386+
387+
const lowerBytes = boundForField(dataFile.lower_bounds, field.id)
388+
const upperBytes = boundForField(dataFile.upper_bounds, field.id)
389+
if (lowerBytes === undefined && upperBytes === undefined) return true
390+
const lo = lowerBytes !== undefined ? deserializeValue(lowerBytes, field.type) : undefined
391+
const hi = upperBytes !== undefined ? deserializeValue(upperBytes, field.type) : undefined
392+
if (lo === undefined && hi === undefined) return true
393+
394+
for (const { op, value } of normalizeCondition(condition)) {
395+
if (!boundsOpMightMatch(op, value, lo, hi, field.type)) return false
396+
}
397+
return true
398+
}
399+
400+
/**
401+
* Look up a column's bound bytes by field id. Read-decoded Iceberg maps arrive
402+
* as an array of `{key, value}` records (Avro int-keyed maps); hand-built
403+
* entries may instead be a plain `Record<fieldId, bytes>`. Handle both.
404+
*
405+
* @param {any} map - lower_bounds or upper_bounds from a manifest data_file
406+
* @param {number} fieldId
407+
* @returns {Uint8Array | undefined}
408+
*/
409+
function boundForField(map, fieldId) {
410+
if (map === undefined || map === null) return undefined
411+
if (Array.isArray(map)) {
412+
const entry = map.find(e => e && Number(e.key) === fieldId)
413+
return entry ? entry.value : undefined
414+
}
415+
const v = map[fieldId]
416+
return v instanceof Uint8Array ? v : undefined
417+
}
418+
419+
/**
420+
* Whether a type has totally-ordered, untruncated single-value bounds suitable
421+
* for range/equality pruning. String/binary/fixed/uuid are excluded because
422+
* their bounds are stored truncated.
423+
*
424+
* @param {IcebergType} type
425+
* @returns {boolean}
426+
*/
427+
function isOrderableForBounds(type) {
428+
const name = typeName(type)
429+
if (name.startsWith('decimal(')) return true
430+
switch (name) {
431+
case 'boolean':
432+
case 'int':
433+
case 'long':
434+
case 'float':
435+
case 'double':
436+
case 'date':
437+
case 'time':
438+
case 'timestamp':
439+
case 'timestamptz':
440+
case 'timestamp_ns':
441+
case 'timestamptz_ns':
442+
return true
443+
default:
444+
return false
445+
}
446+
}
447+
448+
/**
449+
* Whether a value range [lo, hi] (either side may be open/undefined) could
450+
* satisfy `op value`. Mirrors hyparquet's `canSkipRowGroup` operator semantics
451+
* but at file granularity. Returns true (keep) on any uncertainty; returns
452+
* false only when the predicate is provably unsatisfiable for the whole range.
453+
*
454+
* @param {string} op - mongo-style operator
455+
* @param {any} value - the predicate literal (array for $in/$nin)
456+
* @param {any} lo - decoded lower bound, or undefined (open below)
457+
* @param {any} hi - decoded upper bound, or undefined (open above)
458+
* @param {IcebergType} type
459+
* @returns {boolean}
460+
*/
461+
function boundsOpMightMatch(op, value, lo, hi, type) {
462+
switch (op) {
463+
case '$lt': {
464+
// need some x < value; smallest is lo. Skip if lo >= value.
465+
if (lo === undefined) return true
466+
const c = safeCompare(lo, value, type)
467+
return c === undefined ? true : c < 0
468+
}
469+
case '$lte': {
470+
if (lo === undefined) return true
471+
const c = safeCompare(lo, value, type)
472+
return c === undefined ? true : c <= 0
473+
}
474+
case '$gt': {
475+
// need some x > value; largest is hi. Skip if hi <= value.
476+
if (hi === undefined) return true
477+
const c = safeCompare(hi, value, type)
478+
return c === undefined ? true : c > 0
479+
}
480+
case '$gte': {
481+
if (hi === undefined) return true
482+
const c = safeCompare(hi, value, type)
483+
return c === undefined ? true : c >= 0
484+
}
485+
case '$eq':
486+
return eqInRange(value, lo, hi, type)
487+
case '$in':
488+
if (!Array.isArray(value)) return true
489+
// Keep if any listed value could fall in [lo, hi].
490+
return value.some(x => eqInRange(x, lo, hi, type))
491+
// $ne / $nin can only prune a single-valued file fully covered by the
492+
// excluded value(s); too rare to bother — keep.
493+
default:
494+
return true
495+
}
496+
}
497+
498+
/**
499+
* Whether `value` could lie within [lo, hi] (open sides allowed). Keep on any
500+
* undecidable comparison.
501+
*
502+
* @param {any} value
503+
* @param {any} lo
504+
* @param {any} hi
505+
* @param {IcebergType} type
506+
* @returns {boolean}
507+
*/
508+
function eqInRange(value, lo, hi, type) {
509+
if (lo !== undefined) {
510+
const c = safeCompare(value, lo, type)
511+
if (c !== undefined && c < 0) return false
512+
}
513+
if (hi !== undefined) {
514+
const c = safeCompare(value, hi, type)
515+
if (c !== undefined && c > 0) return false
516+
}
517+
return true
518+
}
519+
520+
/**
521+
* Type-aware comparison that returns undefined (rather than throwing or
522+
* returning NaN) when the two values cannot be meaningfully ordered, so the
523+
* caller keeps the file.
524+
*
525+
* @param {any} a
526+
* @param {any} b
527+
* @param {IcebergType} type
528+
* @returns {number | undefined}
529+
*/
530+
function safeCompare(a, b, type) {
531+
if (a === null || a === undefined || b === null || b === undefined) return undefined
532+
try {
533+
const c = compare(a, b, type)
534+
return Number.isNaN(c) ? undefined : c
535+
} catch {
536+
return undefined
537+
}
538+
}

src/sql/icebergDataSource.js

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { fetchDeleteMaps, urlResolver } from '../fetch.js'
33
import { icebergManifests, splitManifestEntries } from '../manifest.js'
44
import { icebergMetadata } from '../metadata.js'
55
import { readDataFile } from '../read.js'
6-
import { partitionMightMatch } from '../prune.js'
6+
import { fileMightMatch, partitionMightMatch } from '../prune.js'
77
import { whereToParquetFilter } from './whereFilter.js'
88

99
/**
@@ -22,11 +22,13 @@ import { whereToParquetFilter } from './whereFilter.js'
2222
* - Column projection (`columns`) is pushed into the parquet read so only the
2323
* requested columns are decoded. Equality-delete predicate columns and row
2424
* lineage columns are read regardless when needed.
25-
* - WHERE is pushed down to hyparquet (row-group pruning via statistics and
26-
* bloom filters, plus per-row matching) when the expression can be fully
27-
* converted to a parquet filter (comparisons, IN, AND/OR/NOT on identifier
28-
* vs literal). Unsupported nodes (LIKE, functions, arithmetic, identifier
29-
* vs identifier) leave WHERE for the engine to apply.
25+
* - WHERE prunes whole data files before they are opened, using each manifest
26+
* entry's partition tuple and per-column `lower_bounds`/`upper_bounds`, and
27+
* is pushed down to hyparquet (row-group pruning via statistics and bloom
28+
* filters, plus per-row matching) when the expression can be fully converted
29+
* to a parquet filter (comparisons, IN, AND/OR/NOT on identifier vs literal).
30+
* Unsupported nodes (LIKE, functions, arithmetic, identifier vs identifier)
31+
* leave WHERE for the engine to apply.
3032
* - When WHERE is resolved at scan time (either absent or fully pushed) we
3133
* cap the scan at `offset + limit` rows so the source terminates early.
3234
* OFFSET is also pushed into the parquet seek when there are no deletes;
@@ -89,13 +91,16 @@ export async function icebergDataSource({ tableUrl, metadataFileName, metadata,
8991
// the engine must re-apply it.
9092
const filter = whereToParquetFilter(where)
9193
const appliedWhere = where !== undefined && filter !== undefined
92-
// Partition-level scan pruning: drop data files whose partition tuple
93-
// proves no row can match the filter. Manifest entries are already in
94-
// memory, so this is a cheap synchronous pre-filter that avoids opening
95-
// the pruned files entirely. Pruning never drops a file with a matching
96-
// row, so query results are unchanged.
94+
// Scan pruning: drop data files whose partition tuple OR per-column
95+
// manifest bounds prove no row can match the filter. Manifest entries are
96+
// already in memory, so this is a cheap synchronous pre-filter that
97+
// avoids opening the pruned files entirely. Both pruners are inclusive
98+
// projections (they never drop a file with a matching row), so query
99+
// results are unchanged.
97100
const scanEntries = filter
98-
? dataEntries.filter(entry => partitionMightMatch(filter, entry, schema, tableMetadata))
101+
? dataEntries.filter(entry =>
102+
partitionMightMatch(filter, entry, schema, tableMetadata) &&
103+
fileMightMatch(filter, entry, schema))
99104
: dataEntries
100105
const pruned = scanEntries.length < dataEntries.length
101106
// Treat a fully-pushed-down WHERE the same as "no WHERE" for the

src/types.d.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,9 @@ export interface Snapshot {
206206
// 'spark.app.id'?: string
207207
'added-data-files'?: string
208208
'added-records'?: string
209+
'deleted-data-files'?: string
210+
'deleted-records'?: string
211+
'removed-files-size'?: string
209212
'added-delete-files'?: string
210213
'removed-delete-files'?: string
211214
'added-position-deletes'?: string
@@ -298,7 +301,7 @@ export type TableUpdate =
298301
* accumulated updates ship in one commit when the callback resolves.
299302
*/
300303
export interface IcebergTransaction {
301-
append(options: { records: Record<string, any>[] }): Promise<void>
304+
append(options: { records: Record<string, any>[], sortOrderId?: number }): Promise<void>
302305
delete(options: {
303306
deletes: { file_path: string, pos: bigint | number }[]
304307
mode?: 'puffin' | 'parquet'

0 commit comments

Comments
 (0)