Skip to content

feat(query): make lazy row fetch page-aware#20166

Open
dbsid wants to merge 9 commits into
databendlabs:mainfrom
dbsid:codex/page-aware-row-fetch-20260716
Open

feat(query): make lazy row fetch page-aware#20166
dbsid wants to merge 9 commits into
databendlabs:mainfrom
dbsid:codex/page-aware-row-fetch-20260716

Conversation

@dbsid

@dbsid dbsid commented Jul 16, 2026

Copy link
Copy Markdown

I hereby agree to the terms of the CLA available at: https://docs.databend.com/dev/policies/cla/

Summary

Extract the page-aware lazy RowFetch changes from #20144 into an independently reviewable storage PR.

The existing generic lazy plan scans filter/order columns plus _row_id, applies Sort and Limit, then fetches the remaining projected columns. Before this PR, each hit block's projected columns were read and decoded in full. This PR maps the selected row ids to their Parquet page range and reads only that range before taking the requested rows.

This implementation does not depend on ordered top-k, Sort(PresortedMerge), PagePruner, composite/prefix page statistics, a planner cache, or a query-result cache. The captured lazy plans remain RowFetch -> Limit -> Sort(Single) -> TableScan and contain no PresortedMerge.

Implementation

  1. Cache projected block metadata by storage root, table identity and sequence, snapshot, page size, projection, and block id so repeated RowFetch calls avoid rebuilding it without reusing stale metadata across table changes.
  2. Group row ids by block and calculate the contiguous Parquet page range covering the minimum and maximum selected row, using the table's existing data_page_rows option.
  3. Load Parquet offset indexes on demand, issue page-range reads for projected columns, decode that range, and translate the original row ids to range-relative indices.
  4. Allow the in-memory table-data cache to hold both existing decoded column arrays and range-keyed decoded DataBlock entries.
  5. Bound per-lane work with a completion-ordered task window and bound query-wide decoded chunks with a shared semaphore; abort outstanding tasks when RowFetch is dropped.
  6. Fall back to the existing full-block RowFetch path when page size is unavailable or covers the full block, source block metadata is unavailable, offset indexes are absent, the file layout is unsupported, or a safe range cache key cannot be built.

lazy_read_threshold=0 deliberately bypasses RowFetch, so this optimization is expected to be neutral in no-lazy mode.

Benchmark

Driver: https://github.com/dbsid/hbx-web3

Environment and methodology:

  • EC2 i4i.4xlarge, 16 vCPU Intel Xeon Platinum 8375C, single query node, S3 in us-west-2;
  • release binaries built on the EC2 host with RUSTFLAGS="-C target-cpu=native";
  • enterprise license, 100 GiB on-disk table-data cache, and 16 GiB in-memory table-data cache;
  • max_threads=2, max_storage_io_requests=16, distributed pruning off;
  • 5 and 10 concurrent connections with random parameters, 3,000 measured requests per query and case, seed 2026071305;
  • round 1 warms the path and round 2 is authoritative;
  • independent server restart for lazy_read_threshold=1000 and 0;
  • enable_query_result_cache=0; the pre-existing mainline enable_planner_cache=1 is identical for baseline and PR.

Binaries:

  • merge-base baseline 7dc41477f7: SHA256 f3d3766575b1b00dce4ccb37597a4a271d0727bfb5b12e97d09ceb930a74899f;
  • PR head 643c2fc22b: SHA256 cc32a5abdee80b193ef61aa2b5a5c045e9ebd0097777add433ff7d29864883e9;
  • driver: SHA256 77f587e268ea4147dc9235f6101443ea51e9ee7a7c9528de9bb0e0f3a4e5a06f.

Authoritative round-2 results:

Connections Build lazy threshold Query mean ms p50 ms p95 ms p99 ms QPS
5 baseline 1000 Q1 124.04 103.37 190.38 308.12 40.20
5 PR 1000 Q1 28.78 28.80 32.91 34.66 173.63
5 baseline 1000 Q2 125.87 112.46 200.73 222.87 39.71
5 PR 1000 Q2 31.36 31.19 36.37 38.68 159.39
5 baseline 1000 Flex 130.04 114.50 206.05 268.99 38.43
5 PR 1000 Flex 32.31 32.18 37.45 39.57 154.70
5 baseline 0 Q1 30.72 30.63 35.41 37.92 162.69
5 PR 0 Q1 30.51 30.38 35.08 37.66 163.76
5 baseline 0 Q2 43.81 43.37 54.91 59.44 114.07
5 PR 0 Q2 43.83 43.50 54.76 59.60 114.01
5 baseline 0 Flex 44.56 44.34 55.51 59.87 112.11
5 PR 0 Flex 44.39 44.28 55.39 60.26 112.58
10 baseline 1000 Q1 157.53 139.97 262.76 358.95 63.40
10 PR 1000 Q1 43.83 43.81 49.24 51.91 228.00
10 baseline 1000 Q2 160.41 148.11 255.00 307.29 62.27
10 PR 1000 Q2 46.70 46.53 54.01 58.09 213.89
10 baseline 1000 Flex 166.12 151.14 269.70 318.34 59.97
10 PR 1000 Flex 48.15 48.06 56.64 60.83 207.49
10 baseline 0 Q1 46.06 45.83 53.49 58.30 216.95
10 PR 0 Q1 46.01 45.98 52.79 56.22 217.12
10 baseline 0 Q2 61.73 61.35 76.43 82.24 161.82
10 PR 0 Q2 62.04 61.49 76.58 82.27 161.00
10 baseline 0 Flex 62.76 62.47 77.24 83.06 159.21
10 PR 0 Flex 63.23 62.87 78.22 84.11 157.96

With generic lazy RowFetch enabled, mean latency improves by 75.09%-76.80% (4.01x-4.31x) at 5 connections and by 70.88%-72.18% (3.43x-3.59x) at 10 connections. With lazy reads disabled, mean changes range from -0.68% to +0.75%, consistent with benchmark noise and the intentional no-lazy bypass.

These results isolate page-aware RowFetch from PresortedMerge: the branch contains no ordered-top-k planner/executor changes, all lazy plans contain RowFetch -> Limit -> Sort(Single) -> TableScan, and all no-lazy plans omit RowFetch; none contain PresortedMerge.

Correctness

  • Baseline and PR each completed 12 authoritative runs / 36,000 requests with zero errors.
  • For both builds, full ordered-result comparison between lazy_read_threshold=0 and 1000 passed Q1 200/200, Q2 200/200, and Flex 200/200, with zero mismatches and zero errors.
  • Lazy plans contain RowFetch over Limit -> Sort(Single) -> TableScan; no-lazy plans contain Limit -> Sort(Single) -> TableScan.

Validation

  • cargo fmt --all -- --check
  • git diff --check
  • cargo check -p databend-common-storages-fuse
  • cargo test -p databend-common-storages-fuse range --lib (6 passed)
  • cargo test -p databend-common-storages-fuse --lib (90 passed)
  • cargo test -p databend-storages-common-cache test_table_data_cache_mixed_entries --lib (1 passed)
  • cargo clippy -p databend-common-storages-fuse --lib --tests -- -D warnings
  • prewhere integration suite (3 passed)
  • bash -n tests/task/test-private-task.sh
  • release databend-query build with RUSTFLAGS="-C target-cpu=native"

Review follow-up

The initial review found that PageIndexPolicy::Required caused lazy RowFetch to fail on Parquet files without an OffsetIndex before the documented full-block fallback could run. Commit 2a5fd58f25 changes the metadata read to PageIndexPolicy::Optional; missing indexes now return None from the page-aware path and use the existing full-block RowFetch implementation.

A second review found that the process-wide projected-block metadata cache key used table_id/seq, although time-travel FuseTable instances document ident.seq as a placeholder. Commit 1d9d44e295 adds operator-root and snapshot identity to the key, with regression coverage for snapshot and storage isolation.

The final deep review found two remaining cache-safety gaps: table identity/sequence and data_page_rows were not both represented in the metadata key, and failure to construct a collision-safe page-range data key did not force the documented full-block fallback. Commit 0b507412a3 adds all invalidation dimensions and makes an unavailable range key return None immediately so the existing full-block path runs.

CI also exposed a race in the private-task integration test: its second fan-out execution could begin before the first three child runs reached a terminal state. Commit 643c2fc22b waits for all first-run children to become SUCCEEDED before mutating the root run and starting the second execution.

Tests

  • Unit Test
  • Logic Test
  • Benchmark Test
  • No Test - Explain why

Type of change

  • Bug Fix (non-breaking change which fixes an issue)
  • New Feature (non-breaking change adds functionality)
  • Breaking Change (fix or feature that would cause existing functionality not to work as expected)
  • Documentation Update
  • Refactoring
  • Performance Improvement
  • Other (please describe):

This change is Reviewable

@github-actions github-actions Bot added the pr-feature this PR introduces a new feature to the codebase label Jul 16, 2026
@sundy-li
sundy-li requested a review from zhang2014 July 17, 2026 00:13
@dbsid
dbsid marked this pull request as ready for review July 18, 2026 23:10

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e3d52b27f4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +295 to +297
.with_offset_index_policy(PageIndexPolicy::Required)
.load_and_finish(&mut file_reader, part.file_size)
.await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Fall back when offset indexes are absent

When lazy RowFetch reaches a block without a Parquet OffsetIndex, this PageIndexPolicy::Required load returns an error and the ? propagates it, so the code never reaches the intended metadata.offset_index().is_none() fallback. This affects data_page_rows tables written by the current BlockParquetWriter path, which the repo documents as omitting OffsetIndex for multi-page chunks, causing qualifying lazy RowFetch queries to fail instead of using the existing full-block path; use an optional policy or handle the missing-index error as Ok(None).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 2a5fd58f25: the metadata reader now uses PageIndexPolicy::Optional. Files without an OffsetIndex return None from the page-aware path and fall back to the existing full-block RowFetch path. Validation on the rebased PR head: fuse check passed, fuse lib tests 89/89, cache test 1/1, and clippy with -D warnings passed.

@dbsid

dbsid commented Jul 19, 2026

Copy link
Copy Markdown
Author

CI fixture failure fixed in 636093f497:

  • Initialized FuseBlockPartInfo.file_size with the generated Parquet payload length in src/query/service/tests/it/storages/fuse/operations/prewhere.rs.
  • EC2 verification: cargo fmt --all -- --check; cargo test -p databend-query --test it prewhere (3 passed); cargo test -p databend-common-storages-fuse --lib (90 passed); cargo check --workspace --tests passed.
  • A literal cargo test --no-run --workspace reached linking but the EC2 image lacks the unversioned libpython3.12.so; this is an environment linker issue, while the workspace test compilation itself passed via cargo check --workspace --tests.

The PR head is now 636093f4975f169bec40c40c255af72c6afa630a. Dev and Typos are still action_required because fork workflow approval requires upstream repository admin access.

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

Labels

pr-feature this PR introduces a new feature to the codebase

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant