Skip to content

fix(reader): treat the parquet page index as optional - #10

Merged
anoop-narang merged 1 commit into
mainfrom
fix/page-index-optional
Jul 27, 2026
Merged

fix(reader): treat the parquet page index as optional#10
anoop-narang merged 1 commit into
mainfrom
fix/page-index-optional

Conversation

@anoop-narang

Copy link
Copy Markdown
Collaborator

Problem

The plan-time opener loads parquet footers with PageIndexPolicy::Required. That fails the entire scan with Parquet error: missing offset index on a file that advertises a page index while one of its column chunks carries no offset index.

That shape is valid parquet. Any reader that ingests files written by other tools has to tolerate it — the page index is an optional footer structure, and nothing guarantees uniform coverage across column chunks.

Note that a file with no page index at all is unaffected: when no column chunk declares an index range, range_for_page_index returns None and page index parsing is skipped entirely, so even Required accepts it. The failure needs an index range to exist alongside a missing offset-index pointer.

Why Optional is correct, not just lenient

The page index is an optimization:

  • It feeds page-level pruning, which already no-ops when either index is missing (page_filter.rs checks offset_index().is_none() || column_index().is_none() and returns the access plan unchanged).
  • Row selection without an offset index is still correct — arrow falls back to fetching whole column chunks rather than skipping pages.
  • Optional's degrade path is explicit: on this exact error the parser clears both indexes and returns Ok.

So Optional keeps every existing behaviour when the index is present, and gives up only page-level skipping when it is not — in place of failing the query.

Two supporting points: upstream DataFusion's own parquet source uses Optional, and this crate's metadata cache in plantime/source.rs already did too, so the opener was inconsistent with both.

Tests

src/datafusion-local/src/tests/page_index.rs.

No writer exposes "column index but no offset index" as an option, so the fixture writes a fully indexed file, keeps its data pages and column-index blobs byte-for-byte, and re-encodes only the footer with each chunk's offset-index pointer cleared.

  • fixture_is_rejected_by_required_policy guards the premise — the fixture really is the shape Required rejects, so the scan test cannot pass vacuously. This one runs anywhere.
  • scans_file_without_offset_index scans it through LiquidCache with a predicate (the thing that builds the page-pruning predicate), checks results on both the cold and warm read, and asserts the cache was actually populated so the test cannot silently stop covering the LiquidCache reader.

Heads-up for review: the scan test needs direct_io, like every other cache-backed test here, so it does not run on macOS. CI is the first place it executes.

The plan-time opener loaded footers with PageIndexPolicy::Required, so a
scan failed outright with "missing offset index" on any file that
advertises a page index while one of its column chunks carries no offset
index. Valid parquet is free to have that shape, and readers ingesting
files written elsewhere cannot assume otherwise.

The index is an optimization, not a correctness requirement. It feeds
page-level pruning, which already no-ops when either index is absent,
and row selection without an offset index simply fetches whole column
chunks. Optional keeps both behaviours when the index is present and
degrades instead of failing when it is not — matching upstream
DataFusion's own parquet source, and this crate's metadata cache, which
already used Optional.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Change is correct and minimal: nothing downstream depends on the offset index — prune_plan_with_page_index no-ops when either index is absent, and LiquidStreamBuilder applies the RowSelection after decode rather than via page locations. It also brings the opener in line with the Optional policy already used by the metadata cache in plantime/source.rs:153.

Test fixture is well-constructed: rewriting only the footer over a byte-identical prefix keeps the surviving column-index pointers valid, and fixture_is_rejected_by_required_policy pins the exact rejection reason so the scan test can't pass vacuously.

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@anoop-narang
anoop-narang merged commit a0e3b03 into main Jul 27, 2026
13 checks passed
@github-actions

Copy link
Copy Markdown

📊 Benchmark Comparison

Current: 06dd2740 (Liquid) vs Baseline: 06dd2740 (DataFusionDefault)

Query Cold Time Δ Warm Time Δ CPU Time Δ
Q1 3.0ms (3.0ms) +0.0% 0.000ms (0.000ms) +0.0% 0.000ms (0.000ms) +0.0%
Q2 9.0ms (6.0ms) +50.0% 3.0ms (4.5ms) -33.3% 5.2ms (6.5ms) -19.2%
Q3 13.0ms (12.0ms) +8.3% 5.0ms (11.2ms) -55.6% 2.0ms (22.8ms) -91.2%
Q4 17.0ms (11.0ms) +54.5% 3.8ms (12.0ms) -68.8% 2.2ms (24.5ms) -90.8%
Q5 59.0ms (54.0ms) +9.3% 43.0ms (55.2ms) -22.2% 3.0ms (25.8ms) -88.3%
Q6 149.0ms (115.0ms) +29.6% 67.5ms (114.2ms) -40.9% 20.0ms (80.2ms) -75.1%
Q7 1.0ms (1.0ms) +0.0% 0.000ms (0.500ms) -100.0% 0.000ms (0.000ms) +0.0%
Q8 9.0ms (7.0ms) +28.6% 4.0ms (5.8ms) -30.4% 6.8ms (6.5ms) +3.8%
Q9 78.0ms (82.0ms) -4.9% 66.5ms (81.8ms) -18.7% 4.0ms (44.0ms) -90.9%
Q10 91.0ms (86.0ms) +5.8% 69.2ms (90.5ms) -23.5% 5.0ms (63.5ms) -92.1%
Q11 39.0ms (28.0ms) +39.3% 16.0ms (26.2ms) -39.0% 29.0ms (37.2ms) -22.1%
Q12 40.0ms (31.0ms) +29.0% 18.8ms (29.5ms) -36.4% 31.0ms (43.5ms) -28.7%
Q13 156.0ms (102.0ms) +52.9% 74.2ms (104.0ms) -28.6% 53.2ms (80.5ms) -33.9%
Q14 311.0ms (134.0ms) +132.1% 118.8ms (142.2ms) -16.5% 88.2ms (109.5ms) -19.4%
Q15 180.0ms (96.0ms) +87.5% 69.5ms (101.5ms) -31.5% 62.0ms (95.5ms) -35.1%
Q16 65.0ms (67.0ms) -3.0% 48.5ms (57.8ms) -16.0% 3.0ms (25.5ms) -88.2%
Q17 367.0ms (154.0ms) +138.3% 193.8ms (161.2ms) +20.2% 108.5ms (105.5ms) +2.8%
Q18 436.0ms (161.0ms) +170.8% 200.8ms (158.2ms) +26.9% 109.0ms (104.2ms) +4.6%
Q19 647.0ms (311.0ms) +108.0% 361.8ms (301.2ms) +20.1% 124.0ms (150.0ms) -17.3%
Q20 18.0ms (12.0ms) +50.0% 3.8ms (12.0ms) -68.8% 7.2ms (24.8ms) -70.7%
Q21 1.00s (183.0ms) +449.2% 266.2ms (186.5ms) +42.8% 605.0ms (286.2ms) +111.4%
Q22 1.24s (212.0ms) +485.8% 340.2ms (217.2ms) +56.6% 161.2ms (360.8ms) -55.3%
Q23 2.17s (606.0ms) +257.8% 870.8ms (630.0ms) +38.2% 516.0ms (774.5ms) -33.4%
Q24 14.73s (945.0ms) +1458.3% 827.0ms (948.5ms) -12.8% 631.5ms (2.66s) -76.3%
Q25 193.0ms (72.0ms) +168.1% 15.5ms (58.8ms) -73.6% 37.5ms (120.5ms) -68.9%
Q26 99.0ms (46.0ms) +115.2% 20.2ms (48.2ms) -58.0% 54.2ms (84.5ms) -35.8%
Q27 182.0ms (61.0ms) +198.4% 26.5ms (63.5ms) -58.3% 79.5ms (133.0ms) -40.2%
Q28 954.0ms (210.0ms) +354.3% 264.8ms (208.8ms) +26.8% 363.2ms (296.0ms) +22.7%
Q29 1.80s (1.04s) +72.6% 1.09s (1.06s) +2.8% 389.8ms (360.2ms) +8.2%
Q30 32.0ms (28.0ms) +14.3% 23.8ms (29.0ms) -18.1% 5.8ms (22.0ms) -73.9%
Q31 254.0ms (101.0ms) +151.5% 53.5ms (99.0ms) -46.0% 43.2ms (146.8ms) -70.5%
Q32 439.0ms (97.0ms) +352.6% 76.0ms (98.5ms) -22.8% 56.2ms (150.0ms) -62.5%
Q33 196.0ms (188.0ms) +4.3% 163.8ms (184.8ms) -11.4% 8.8ms (77.0ms) -88.6%
Q34 1.04s (352.0ms) +196.6% 426.5ms (340.5ms) +25.3% 358.0ms (288.2ms) +24.2%
Q35 998.0ms (331.0ms) +201.5% 418.0ms (346.0ms) +20.8% 348.5ms (283.2ms) +23.0%
Q36 60.0ms (61.0ms) -1.6% 47.8ms (55.8ms) -14.3% 3.5ms (24.8ms) -85.9%
Q37 333.0ms (94.0ms) +254.3% 76.2ms (97.0ms) -21.4% 42.0ms (77.8ms) -46.0%
Q38 99.0ms (46.0ms) +115.2% 27.8ms (45.2ms) -38.7% 16.5ms (28.8ms) -42.6%
Q39 337.0ms (51.0ms) +560.8% 13.5ms (50.0ms) -73.0% 12.5ms (76.5ms) -83.7%
Q40 766.0ms (193.0ms) +296.9% 209.8ms (175.0ms) +19.9% 92.2ms (135.2ms) -31.8%
Q41 22.0ms (20.0ms) +10.0% 10.5ms (20.8ms) -49.4% 7.2ms (19.8ms) -63.3%
Q42 24.0ms (18.0ms) +33.3% 11.0ms (19.5ms) -43.6% 8.2ms (17.5ms) -52.9%
Q43 18.0ms (19.0ms) -5.3% 13.0ms (16.5ms) -21.2% 8.5ms (12.2ms) -30.6%

⚠️ LiquidCache is slower on 11 queries (warm)

  • Q22: warm +56.6% (340.2ms vs 217.2ms)
  • Q21: warm +42.8% (266.2ms vs 186.5ms)
  • Q23: warm +38.2% (870.8ms vs 630.0ms)
  • Q18: warm +26.9% (200.8ms vs 158.2ms)
  • Q28: warm +26.8% (264.8ms vs 208.8ms)
  • Q34: warm +25.3% (426.5ms vs 340.5ms)
  • Q35: warm +20.8% (418.0ms vs 346.0ms)
  • Q17: warm +20.2% (193.8ms vs 161.2ms)
  • Q19: warm +20.1% (361.8ms vs 301.2ms)
  • Q40: warm +19.9% (209.8ms vs 175.0ms)
  • Q29: warm +2.8% (1.09s vs 1.06s)

Compared Liquid vs DataFusionDefault on the same runner
Cold Time: first iteration; Warm Time: average of remaining iterations.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant