Skip to content

Commit 8242857

Browse files
authored
Fix: pushed-down WHERE + LIMIT/OFFSET silently drops matching rows (#27)
icebergDataSource.scan() pushed LIMIT/OFFSET down by physical row position (seeking past `offset` rows and bounding the per-file read at `fileRowStart + remaining`) whenever the WHERE was *resolved* — which includes a WHERE fully pushed into the parquet read. But a pushed-down WHERE is matched per row, so the first N physical rows of a file may contain fewer than N (or zero) matches. Bounding by position then reads only the leading rows and silently drops every match that sorts later in the file. Example: `SELECT ... WHERE node_type = 'File' LIMIT 5` over a file whose leading 1000 rows are all `Session` reads physical rows [0,5), matches nothing, and returns 0 rows — while `COUNT(*)` (no LIMIT) returns the true count. Any `WHERE <pushable predicate> LIMIT n` can under-return. Gate position-based pushdown on `!where` instead of `whereResolved`, so a pushed-down filter takes the same path deletes already use: emit up to `offset + limit` matched rows and let the engine apply the final slice. Early termination via the per-row `remaining` break is preserved. Adds a regression test (a match that sorts after the LIMIT window must still be returned) and corrects two tests that asserted the buggy `appliedLimitOffset === true` contract for a pushed WHERE.
1 parent ab23eaf commit 8242857

2 files changed

Lines changed: 76 additions & 21 deletions

File tree

src/sql/icebergDataSource.js

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,13 @@ import { whereToParquetFilter } from './whereFilter.js'
3131
* leave WHERE for the engine to apply.
3232
* - When WHERE is resolved at scan time (either absent or fully pushed) we
3333
* cap the scan at `offset + limit` rows so the source terminates early.
34-
* OFFSET is also pushed into the parquet seek when there are no deletes;
35-
* with deletes the engine still applies OFFSET itself (record_count is
36-
* pre-delete, so seeking by record_count would miscount visible rows in
37-
* any file with applicable deletes).
34+
* OFFSET is also pushed into the parquet seek, and the per-file read bounded
35+
* by row position, only when there is no WHERE at all: a pushed-down WHERE is
36+
* matched per row, so physical row positions no longer line up with result
37+
* positions and a position-based bound would drop matching rows that sort
38+
* later in the file. Deletes disable position pushdown for the same reason
39+
* (record_count is pre-delete). In those cases the engine applies the final
40+
* LIMIT/OFFSET slice over the (at most offset+limit) rows the source emits.
3841
*
3942
* @param {object} options
4043
* @param {string} options.tableUrl - Base URL or path of the table.
@@ -104,18 +107,26 @@ export async function icebergDataSource({ tableUrl, metadataFileName, metadata,
104107
: dataEntries
105108
const pruned = scanEntries.length < dataEntries.length
106109
// Treat a fully-pushed-down WHERE the same as "no WHERE" for the
107-
// purpose of LIMIT/OFFSET pushdown.
110+
// purpose of capping how many rows the source emits (LIMIT).
108111
const whereResolved = !where || appliedWhere
109-
// OFFSET pushdown (seeking past rows in the parquet file) is only safe
110-
// when the WHERE is fully resolved at scan time AND the table has no
111-
// deletes: record_count is pre-delete, so seeking by it would skip the
112-
// wrong visible rows. It also assumes the cumulative record_count tracks
113-
// row positions, so disable it once pruning has removed any file.
114-
const canPushOffset = whereResolved && !hasDeletes && !pruned
112+
// Position-based pushdown — seeking past `offset` physical rows and the
113+
// `fileRowEnd` LIMIT bound below — translates a row *count* into a
114+
// physical row *position*, which is only correct when every physical row
115+
// is also a result row. That holds only when there is NO WHERE at all.
116+
// A pushed-down WHERE (appliedWhere) is matched per-row inside the
117+
// parquet read, so the first N physical rows may contain fewer than N
118+
// (or zero) matches; bounding by position would silently drop matching
119+
// rows that sort later in the file (e.g. WHERE node_type='File' LIMIT 5
120+
// when the leading rows are all 'Session'). It is likewise unsafe with
121+
// deletes (record_count is pre-delete) or once pruning has dropped a
122+
// file (cumulative record_count no longer tracks row positions). In all
123+
// those cases we keep emitting up to `offset + limit` matched rows and
124+
// let the engine apply the final LIMIT/OFFSET slice.
125+
const canPushOffset = !where && !hasDeletes && !pruned
115126
const skip = canPushOffset ? offset ?? 0 : 0
116-
// LIMIT pushdown (early termination) is safe whenever WHERE is
117-
// resolved: with deletes we yield offset+limit rows and let the engine
118-
// apply the slice, which still saves reading later files.
127+
// LIMIT (early termination) is safe whenever WHERE is resolved: we yield
128+
// at most offset+limit rows and, when offset isn't pushed, let the engine
129+
// apply the slice. This still saves reading later files/row groups.
119130
let take = Infinity
120131
if (whereResolved && limit !== undefined) {
121132
take = canPushOffset ? limit : (offset ?? 0) + limit

test/sql/icebergDataSource.test.js

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,9 @@ describe.concurrent('icebergDataSource', () => {
102102
})
103103
const { rows, appliedWhere, appliedLimitOffset } = source.scan({ where })
104104
expect(appliedWhere).toBe(true)
105-
expect(appliedLimitOffset).toBe(true)
105+
// A pushed-down WHERE disables position-based LIMIT/OFFSET pushdown, so the
106+
// engine owns the final slice even though none was requested here.
107+
expect(appliedLimitOffset).toBe(false)
106108

107109
const collected = []
108110
for await (const row of rows()) collected.push(row.resolved)
@@ -113,7 +115,11 @@ describe.concurrent('icebergDataSource', () => {
113115
}
114116
})
115117

116-
it('pushes WHERE down and combines with LIMIT/OFFSET when no deletes', async () => {
118+
it('pushes WHERE down but lets the engine apply LIMIT/OFFSET', async () => {
119+
// A pushed-down WHERE is matched per row, so OFFSET cannot be pushed by
120+
// physical position (the first N physical rows are not the first N
121+
// matches). appliedLimitOffset must be false; the source emits up to
122+
// offset+limit matched rows and the engine slices the final window.
117123
const source = await icebergDataSource({
118124
tableUrl,
119125
resolver,
@@ -125,12 +131,50 @@ describe.concurrent('icebergDataSource', () => {
125131
left: { type: 'identifier', name: 'Popularity Rank' },
126132
right: { type: 'literal', value: 10n },
127133
})
128-
const { rows, appliedWhere, appliedLimitOffset } = source.scan({ where, limit: 2, offset: 1 })
129-
expect(appliedWhere).toBe(true)
130-
expect(appliedLimitOffset).toBe(true)
134+
// Full matched set in physical order, for the slice oracle.
135+
const matched = []
136+
for await (const row of source.scan({ where }).rows()) matched.push(row.resolved)
137+
138+
const offset = 1
139+
const limit = 2
140+
const plan = source.scan({ where, limit, offset })
141+
expect(plan.appliedWhere).toBe(true)
142+
expect(plan.appliedLimitOffset).toBe(false)
131143
const collected = []
132-
for await (const row of rows()) collected.push(row.resolved)
133-
expect(collected).toHaveLength(2)
144+
for await (const row of plan.rows()) collected.push(row.resolved)
145+
// Source emits the first offset+limit matches; engine slices [offset, offset+limit).
146+
expect(collected).toEqual(matched.slice(0, offset + limit))
147+
expect(collected.slice(offset, offset + limit)).toEqual(matched.slice(offset, offset + limit))
148+
})
149+
150+
it('pushed WHERE + LIMIT returns matches that sort after the LIMIT window', async () => {
151+
// Regression: with a pushed-down filter, bounding the per-file read at
152+
// `offset + limit` physical rows silently dropped any match positioned
153+
// after that window. Pick the LAST physical row and query for it with
154+
// LIMIT 1: a position bound would read only row 0 and return nothing.
155+
const source = await icebergDataSource({
156+
tableUrl,
157+
resolver,
158+
metadataFileName: 'v2.metadata.json',
159+
})
160+
const all = []
161+
for await (const row of source.scan({}).rows()) all.push(row.resolved)
162+
expect(all.length).toBeGreaterThan(1)
163+
const target = /** @type {Record<string, any>} */ (all[all.length - 1])
164+
const targetRank = /** @type {bigint} */ (target['Popularity Rank'])
165+
166+
// Equality on a unique column → pushable, matches exactly the last row.
167+
const where = /** @type {ExprNode} */ ({
168+
type: 'binary',
169+
op: '=',
170+
left: { type: 'identifier', name: 'Popularity Rank' },
171+
right: { type: 'literal', value: targetRank },
172+
})
173+
const plan = source.scan({ where, limit: 1 })
174+
expect(plan.appliedWhere).toBe(true)
175+
const collected = []
176+
for await (const row of plan.rows()) collected.push(row.resolved)
177+
expect(collected).toEqual([target])
134178
})
135179

136180
it('pushed WHERE still respects row-level deletes', async () => {

0 commit comments

Comments
 (0)