Skip to content

perf(filtered-read): reuse plan-time fragment metadata for the read stream#7792

Open
LuQQiu wants to merge 7 commits into
lance-format:mainfrom
LuQQiu:lu/take-single-load
Open

perf(filtered-read): reuse plan-time fragment metadata for the read stream#7792
LuQQiu wants to merge 7 commits into
lance-format:mainfrom
LuQQiu:lu/take-single-load

Conversation

@LuQQiu

@LuQQiu LuQQiu commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Problem

Masked reads (FilteredReadExec with a RowSet input) load every fragment's metadata twice per query:

  1. get_or_create_plan_impl loads all fragments to compute the plan (plan_scan needs deletion vectors / physical row counts), then throws the loaded set away;
  2. FilteredReadStream::try_new immediately loads the same fragments again to build the read stream.

Where it was found and how big it is

Found while stage-timing take-shaped masked reads (an exact row-id mask selecting ~100 scattered rows) on a 100M-row, 100-fragment dataset, 64 concurrent queries, page-cached NVMe. Per-query planning cost breaks down as:

stage per query
input exec + mask deserialize 0.08 ms
fragment load, pass 1 (plan) 0.33 ms
plan_scan 0.32 ms
fragment load, pass 2 (stream) 0.41 ms
scheduler create 0.02 ms

The two loads together are 0.74 ms — ~64% of planning cost, all of it running in the first poll of the stream (i.e., on the caller's task). For a take-shaped query whose whole read is only a couple of milliseconds this is a significant fixed tax; it scales linearly with fragment count and query rate. Long scans amortize it to noise, which is why it went unnoticed.

Fix

The plan cell now keeps the loaded fragments alongside the resolved plan, and stream construction reuses them. The distributed path (with_plan, plan computed on another node, e.g. a query node planning for executor nodes) carries no fragment metadata and loads at stream construction exactly as before — the plan phase stays independently callable.

Tests

filtered_read suite (28, single-threaded) and scanner suite (169) green; fmt + clippy clean. No behavior change — the same loaded set is used, just not loaded twice.

🤖 Generated with Claude Code

https://claude.ai/code/session_015RpPYGnQ9JYaP9B2BbujBn

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Filtered reads now retain fragment handles in cached planning results and reuse them during stream construction. Take-shaped masked or indexed reads can conditionally coalesce small ordered batches while preserving stream ordering and completion behavior.

Changes

Take-shaped filtered reads

Layer / File(s) Summary
Batch consolidation utilities
rust/lance/src/io/exec/filtered_read.rs
Adds ordered batch coalescing toward a target row count with end-of-stream and error handling.
Planned fragment metadata reuse
rust/lance/src/io/exec/filtered_read.rs
Passes cached fragment handles into stream construction and falls back to asynchronous fragment loading when unavailable.
Planned read cache wiring
rust/lance/src/io/exec/filtered_read.rs
Stores plans with optional fragment handles, preserves distributed-plan behavior, and updates plan and stream accessors for the new cache container.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FilteredReadExec
  participant FilteredReadStream
  participant FileFragment
  participant RecordBatchStream
  FilteredReadExec->>FilteredReadExec: Cache PlannedRead with plan and fragment handles
  FilteredReadExec->>FilteredReadStream: Create stream with cached handles
  FilteredReadStream->>FileFragment: Build scoped fragment reads
  FileFragment->>RecordBatchStream: Produce ordered record batches
  FilteredReadExec->>RecordBatchStream: Coalesce take-shaped batches
Loading

Possibly related PRs

Suggested labels: performance

Suggested reviewers: westonpace, wjones127, bubblecal

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: reusing plan-time fragment metadata for filtered reads.
Description check ✅ Passed The description directly explains the same masked-read metadata reuse fix and its impact.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rust/lance/src/io/exec/filtered_read.rs (1)

1978-2050: 🚀 Performance & Scalability | 🔵 Trivial

Confirm the streaming-latency trade-off for sparse masked reads is acceptable.

When is_sparse_plan is true and total planned rows are below target, coalesce_batches only emits once the inner stream is exhausted (ready_to_emit requires exhausted && !buffered.is_empty()), so consolidated_stream withholds all output until the entire masked scan across all touched fragments completes. Previously, per-fragment batches would stream out incrementally as each fragment finished. For a caller relying on early termination (e.g. a LimitExec sitting above a read whose scan_range_after_filter couldn't be pushed down because of a recheck filter), this increases time-to-first-batch even though it doesn't change total I/O volume.

This is likely intentional for the take-shaped scenario this PR targets, but worth confirming it doesn't regress latency for interactive/streaming consumers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/io/exec/filtered_read.rs` around lines 1978 - 2050, Review the
sparse masked-read path in the stream construction around is_sparse_plan and
consolidated_stream, and confirm that withholding batches until all touched
fragments finish is acceptable for interactive consumers and upstream early
termination such as LimitExec. If the latency regression is not acceptable,
constrain consolidation to the intended take-shaped scenario or preserve
incremental per-fragment emission for other masked reads; otherwise document or
validate the intentional trade-off without changing unrelated batch-size
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/lance/src/io/exec/filtered_read.rs`:
- Around line 2639-2709: The test_take_shaped_mask_consolidation coverage only
uses the default threading mode; extend it with a
FilteredReadThreadingMode::MultiplePartitions case. Configure the read options
with MultiplePartitions(n), execute the same take-shaped mask, and verify
consolidation preserves correct, disjoint row sets and expected batch results
across partitions without relying on single-partition ordering assumptions.

---

Outside diff comments:
In `@rust/lance/src/io/exec/filtered_read.rs`:
- Around line 1978-2050: Review the sparse masked-read path in the stream
construction around is_sparse_plan and consolidated_stream, and confirm that
withholding batches until all touched fragments finish is acceptable for
interactive consumers and upstream early termination such as LimitExec. If the
latency regression is not acceptable, constrain consolidation to the intended
take-shaped scenario or preserve incremental per-fragment emission for other
masked reads; otherwise document or validate the intentional trade-off without
changing unrelated batch-size behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7cd1a99c-22d0-4c8b-9d69-5cadc0d469f7

📥 Commits

Reviewing files that changed from the base of the PR and between 6f6601f and e955296.

📒 Files selected for processing (1)
  • rust/lance/src/io/exec/filtered_read.rs

Comment thread rust/lance/src/io/exec/filtered_read.rs
LuQQiu added a commit to LuQQiu/lance that referenced this pull request Jul 14, 2026
…anch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tream

Masked reads loaded every fragment's metadata twice per query: once in
get_or_create_plan_impl to compute the plan and again in
FilteredReadStream::try_new to build the read. The plan cell now keeps
the loaded fragments alongside the plan and stream construction reuses
them. The distributed path (with_plan, plan computed on another node)
carries no fragments and loads them at stream construction as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LuQQiu
LuQQiu force-pushed the lu/take-single-load branch from e955296 to 8b5c856 Compare July 14, 2026 23:05

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/lance/src/io/exec/filtered_read.rs`:
- Around line 2015-2033: Add a log::debug! inside the sparse-plan branch where
is_sparse_plan is computed, logging touched_fragments, planned_rows, and the
resolved batch_target_rows when the heuristic evaluates true. Keep consolidation
behavior unchanged and ensure the message identifies that masked-read
consolidation was triggered.
- Around line 337-446: Extract the consolidation utilities from filtered_read.rs
into a dedicated consolidate submodule, including consolidated_stream,
coalesce_batches, and the CONSOLIDATE_MIN_FRAGMENTS and
CONSOLIDATE_MAX_AVG_PLANNED_ROWS_PER_FRAGMENT constants. Update their callers
and visibility/imports so planning and execution behavior remains unchanged
while the main module retains only the wiring.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4ccf83f8-cf9c-453e-bafd-c102cffd94be

📥 Commits

Reviewing files that changed from the base of the PR and between e955296 and 8b5c856.

📒 Files selected for processing (1)
  • rust/lance/src/io/exec/filtered_read.rs

@coderabbitai coderabbitai 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.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/lance/src/io/exec/filtered_read.rs`:
- Around line 2015-2033: Add a log::debug! inside the sparse-plan branch where
is_sparse_plan is computed, logging touched_fragments, planned_rows, and the
resolved batch_target_rows when the heuristic evaluates true. Keep consolidation
behavior unchanged and ensure the message identifies that masked-read
consolidation was triggered.
- Around line 337-446: Extract the consolidation utilities from filtered_read.rs
into a dedicated consolidate submodule, including consolidated_stream,
coalesce_batches, and the CONSOLIDATE_MIN_FRAGMENTS and
CONSOLIDATE_MAX_AVG_PLANNED_ROWS_PER_FRAGMENT constants. Update their callers
and visibility/imports so planning and execution behavior remains unchanged
while the main module retains only the wiring.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4ccf83f8-cf9c-453e-bafd-c102cffd94be

📥 Commits

Reviewing files that changed from the base of the PR and between e955296 and 8b5c856.

📒 Files selected for processing (1)
  • rust/lance/src/io/exec/filtered_read.rs
🛑 Comments failed to post (2)
rust/lance/src/io/exec/filtered_read.rs (2)

337-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting the consolidation utilities into a dedicated submodule.

consolidated_stream/coalesce_batches (plus the two threshold constants) are ~110 lines of self-contained, new algorithmic logic added directly into an already very large file (filtered_read.rs spans thousands of lines). Traced the state machine by hand (partial-buffer emission on oversized incoming batches, single-item passthrough, exhaustion handling) — the logic itself is correct and well-commented.

As per coding guidelines, "Extract substantial new logic such as bin packing or scheduling into dedicated submodules instead of inlining it into large files." Moving this to e.g. filtered_read/consolidate.rs would improve discoverability and keep the main file focused on planning/execution wiring.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/io/exec/filtered_read.rs` around lines 337 - 446, Extract the
consolidation utilities from filtered_read.rs into a dedicated consolidate
submodule, including consolidated_stream, coalesce_batches, and the
CONSOLIDATE_MIN_FRAGMENTS and CONSOLIDATE_MAX_AVG_PLANNED_ROWS_PER_FRAGMENT
constants. Update their callers and visibility/imports so planning and execution
behavior remains unchanged while the main module retains only the wiring.

Source: Coding guidelines


2015-2033: 📐 Maintainability & Code Quality | 🔵 Trivial

Consider a debug log when the sparse-plan consolidation heuristic fires.

The is_sparse_plan decision (touched_fragments/planned_rows vs. thresholds) is computed once per query but never logged anywhere, making it hard to confirm in production whether a given masked read actually triggered consolidation. A log::debug! with touched_fragments, planned_rows, and the resolved batch_target_rows would help diagnose unexpected batch shapes without needing to re-derive the heuristic from metrics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/io/exec/filtered_read.rs` around lines 2015 - 2033, Add a
log::debug! inside the sparse-plan branch where is_sparse_plan is computed,
logging touched_fragments, planned_rows, and the resolved batch_target_rows when
the heuristic evaluates true. Keep consolidation behavior unchanged and ensure
the message identifies that masked-read consolidation was triggered.

@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.00000% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance/src/io/exec/filtered_read.rs 95.00% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@Xuanwo
Xuanwo self-requested a review July 15, 2026 17:53

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rust/lance/src/io/exec/filtered_read.rs (1)

2690-2690: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the required RecordBatch access style in the new test.

Access the named column with batches[0]["value"] instead of the positional .column(0). As per coding guidelines: “Use column_by_name() for RecordBatch column access in production code; use batch["column_name"] in tests.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/io/exec/filtered_read.rs` at line 2690, Update the test
assertion near the RecordBatch access to use the named-column indexing style,
replacing positional column access with batches[0]["value"] while preserving the
existing expected-value comparison.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@rust/lance/src/io/exec/filtered_read.rs`:
- Line 2690: Update the test assertion near the RecordBatch access to use the
named-column indexing style, replacing positional column access with
batches[0]["value"] while preserving the existing expected-value comparison.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d797de89-813d-484d-920e-f7610734ba1c

📥 Commits

Reviewing files that changed from the base of the PR and between 8b5c856 and dfe57c6.

📒 Files selected for processing (1)
  • rust/lance/src/io/exec/filtered_read.rs

Comment thread rust/lance/src/io/exec/filtered_read.rs Outdated
/// fragments and the stream loads them itself.
struct PlannedRead {
plan: FilteredReadInternalPlan,
loaded_fragments: Option<Arc<Vec<LoadedFragment>>>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Keeping the full Vec<LoadedFragment> in PlannedRead pins every candidate fragment's deletion vector, row-id sequence, row counts, and FileFragment for the lifetime of the exec, even though stream construction only consumes the ordered FileFragment handles. This changes the optimization from a transient O(N) metadata load into retained O(concurrent execs × N) state and can keep metadata alive past cache eviction on highly fragmented datasets.

Could PlannedRead instead retain only the plan-selected ordered (original_priority, Arc<FileFragment>) descriptors, with None for with_plan so the distributed fallback remains unchanged? That would eliminate the duplicate local load while preserving fragment order, retry behavior, and the wire contract without extending the lifetime of planning-only metadata.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the suggestion! Improved the PR according to the suggestion!

LuQQiu and others added 2 commits July 20, 2026 13:56
Planning-only metadata (deletion vectors, row-id sequences, row counts)
now drops when planning finishes instead of living as long as the exec;
stream construction only consumes the ordered FileFragment handles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RpPYGnQ9JYaP9B2BbujBn

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rust/lance/src/io/exec/filtered_read.rs (1)

424-443: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Flush buffered batches before propagating input errors.

At Line 432, ? returns the input error while this.buffered may contain successfully read rows below target; those rows are then dropped. Track a pending error, emit the buffered batch first, and return the error on the next poll. Add a regression test for Ok(short_batch) followed by Err(...).

As per coding guidelines, every bugfix and feature must have corresponding tests.

Proposed fix
 struct Coalescer {
     input: SendableRecordBatchStream,
     schema: SchemaRef,
     target: usize,
     buffered: Vec<RecordBatch>,
     buffered_rows: usize,
     exhausted: bool,
+    pending_error: Option<DataFusionError>,
 }

+if let Some(error) = this.pending_error.take() {
+    return Err(error);
+}
+
-match this.input.try_next().await? {
+match this.input.try_next().await {
+    Err(error) if !this.buffered.is_empty() => {
+        this.pending_error = Some(error);
+        let output = this.emit()?;
+        return Ok(Some((output, this)));
+    }
+    Err(error) => return Err(error),
+    Ok(Some(batch)) => { /* existing handling */ }
+    Ok(None) => this.exhausted = true,
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/io/exec/filtered_read.rs` around lines 424 - 443, Update the
try_unfold state machine around the input.try_next call to retain any input
error as pending state when buffered rows exist, emit those rows first, and
propagate the pending error on the following poll; preserve immediate error
propagation when nothing is buffered. Add a regression test covering an Ok short
batch followed by Err(...) and verify the short batch is emitted before the
error.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@rust/lance/src/io/exec/filtered_read.rs`:
- Around line 424-443: Update the try_unfold state machine around the
input.try_next call to retain any input error as pending state when buffered
rows exist, emit those rows first, and propagate the pending error on the
following poll; preserve immediate error propagation when nothing is buffered.
Add a regression test covering an Ok short batch followed by Err(...) and verify
the short batch is emitted before the error.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: f84790f8-47f2-45c1-bb00-ed715d1ce088

📥 Commits

Reviewing files that changed from the base of the PR and between dfe57c6 and f4af3c5.

📒 Files selected for processing (1)
  • rust/lance/src/io/exec/filtered_read.rs

…load

# Conflicts:
#	rust/lance/src/io/exec/filtered_read.rs

@coderabbitai coderabbitai 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.

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rust/lance/src/io/exec/filtered_read.rs (1)

385-421: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

coalesce_batches panics/errors immediately when target == 0.

ready_to_emit() returns buffered_rows >= target, which is trivially true (0 >= 0) before any input is ever read when target == 0. That routes straight into emit() with an empty buffer, which returns the internal "coalesce_batches emitted with an empty buffer" error instead of reading any data. This is reachable from RowStreamRead::apply (line ~2662), which derives batch_target_rows from read_options.batch_size — a user-settable Option<u32> via with_batch_size — with no visible guard against 0 in this file (contrast with obtain_stream's masked-read path, which explicitly checks batch_target_rows > 0 before calling consolidated_stream).

🐛 Proposed fix: clamp target to at least 1
 pub fn coalesce_batches(
     input: SendableRecordBatchStream,
     target: usize,
 ) -> impl Stream<Item = DataFusionResult<RecordBatch>> {
+    // A target of 0 would make ready_to_emit() fire before any input is
+    // read, emitting from an empty buffer; treat 0 as "no coalescing".
+    let target = target.max(1);
     struct Coalescer {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/io/exec/filtered_read.rs` around lines 385 - 421, Update
coalesce_batches to normalize the target value to at least 1 before constructing
or using Coalescer, so ready_to_emit cannot become true with an empty buffer
when callers provide zero. Preserve existing batching behavior for positive
targets and keep the change scoped to the target handling in coalesce_batches.
🟡 Other comments (1)
rust/lance/src/io/exec/filtered_read.rs-2739-2766 (1)

2739-2766: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Latent panic via HashMap indexing in attach_read_columns.

key_to_index[key] panics if key is absent. It's currently unreachable because keys was just filtered above to only contain keys present in key_to_index, but that invariant lives several lines away and isn't enforced by the type system — a future refactor of the filter step could silently reintroduce a panic on production data. As per coding guidelines, "Avoid bare .unwrap(); use if let, match, let ... else, ?, or combinators... If unavoidable, use .expect("reason")" and "Never use .unwrap(), .expect(), panic!(), or assert!() in library code for fallible operations."

🛡️ Proposed fix: make the invariant explicit
-    let indices = UInt32Array::from_iter_values(keys.values().iter().map(|key| key_to_index[key]));
+    let indices = UInt32Array::from_iter_values(
+        keys.values()
+            .iter()
+            .map(|key| *key_to_index.get(key).expect("key was filtered above to exist in key_to_index")),
+    );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance/src/io/exec/filtered_read.rs` around lines 2739 - 2766, In
attach_read_columns, replace the direct key_to_index[key] lookup when building
indices with a non-panicking lookup that explicitly handles missing keys, such
as filtering or propagating an appropriate error. Preserve the existing
input-order gathering behavior for present keys and ensure absent keys cannot
trigger a HashMap indexing panic.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@rust/lance/src/io/exec/filtered_read.rs`:
- Around line 385-421: Update coalesce_batches to normalize the target value to
at least 1 before constructing or using Coalescer, so ready_to_emit cannot
become true with an empty buffer when callers provide zero. Preserve existing
batching behavior for positive targets and keep the change scoped to the target
handling in coalesce_batches.

---

Other comments:
In `@rust/lance/src/io/exec/filtered_read.rs`:
- Around line 2739-2766: In attach_read_columns, replace the direct
key_to_index[key] lookup when building indices with a non-panicking lookup that
explicitly handles missing keys, such as filtering or propagating an appropriate
error. Preserve the existing input-order gathering behavior for present keys and
ensure absent keys cannot trigger a HashMap indexing panic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: 5574e2b7-0d5c-4149-aa40-d6cbe70c4320

📥 Commits

Reviewing files that changed from the base of the PR and between f4af3c5 and b64b5a8.

📒 Files selected for processing (1)
  • rust/lance/src/io/exec/filtered_read.rs

…field

PlannedRead was inserted mid-doc-comment, stealing FilteredReadExec's
docs. StreamFragments no longer mirrors its fragments as a second handle
vec; read_batch builds the handle list per batch instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RpPYGnQ9JYaP9B2BbujBn
Comment thread rust/lance/src/io/exec/filtered_read.rs Outdated
// construction doesn't reload every fragment
let plan =
FilteredReadStream::plan_scan(&loaded_fragments, &evaluated_index, options);
let fragment_handles = loaded_fragments

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks—the updated version fixes the previous LoadedFragment lifetime issue: deletion vectors and row-id sequences now drop after planning. However, fragment_handles still contains every candidate fragment, not just those selected by plan.rows. Since FileFragment owns a cloned Fragment descriptor, sparse plans retain O(all fragments) descriptor state for the lifetime of each exec. This owned-handle API also makes RowStreamRead::read_batch allocate and Arc-clone an all-fragment vector for every input batch, adding an unmeasured O(batches × fragments) hot-path cost.

Stream construction only needs FileFragment, and FileFragment::new(dataset, metadata) is I/O-free. Could we instead enumerate the existing ordered options.fragments/dataset descriptors, filter by plan.rows, and construct handles only for selected fragments while preserving original priority? The row-stream path could then keep borrowing its existing loaded fragments. This removes the duplicate metadata load without a plan-lifetime sidecar, per-batch full-vector clone, or wire change. It would also be helpful to add a load-count regression test and a current-head base/PR benchmark, including a many-fragment row-stream case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

thanks for the good advice! addressed the comments, please take a look

FileFragment::new is I/O-free, so nothing needs to be retained between
planning and execution: plan_to_scoped_fragments enumerates the manifest
descriptors and constructs handles only for the fragments the plan
selects, with the candidate-list position as priority. The PlannedRead
sidecar is gone, planning-only metadata drops when planning finishes,
and the row-stream path no longer builds a per-batch handle vector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RpPYGnQ9JYaP9B2BbujBn
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants