Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 131 additions & 10 deletions rust/lance-io/src/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,9 @@ struct MutableBatch<F: FnOnce(Response) + Send> {
err: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
// When true, report 0 bytes consumed so the backpressure budget is unaffected
bypass_backpressure: bool,
// Queue the batch's backpressure reservation is refunded to once its response
// is delivered or discarded (see `Response`'s `Drop`).
io_queue: Arc<IoQueue>,
}

impl<F: FnOnce(Response) + Send> MutableBatch<F> {
Expand All @@ -389,6 +392,7 @@ impl<F: FnOnce(Response) + Send> MutableBatch<F> {
priority: u128,
num_reqs: usize,
bypass_backpressure: bool,
io_queue: Arc<IoQueue>,
) -> Self {
Self {
when_done: Some(when_done),
Expand All @@ -398,6 +402,7 @@ impl<F: FnOnce(Response) + Send> MutableBatch<F> {
num_reqs,
err: None,
bypass_backpressure,
io_queue,
}
}
}
Expand All @@ -419,7 +424,8 @@ impl<F: FnOnce(Response) + Send> Drop for MutableBatch<F> {
// We don't really care if no one is around to receive it, just let
// the result go out of scope and get cleaned up
let response = Response {
data: result,
data: Some(result),
io_queue: self.io_queue.clone(),
// Report 0 bytes for bypass tasks so the backpressure budget is unaffected
num_bytes: if self.bypass_backpressure {
0
Expand Down Expand Up @@ -779,12 +785,25 @@ impl Debug for ScanScheduler {
}

struct Response {
data: Result<Vec<Bytes>>,
// `Option` so the caller can take the data out while the response (and its
// backpressure refund on drop) stays intact.
data: Option<Result<Vec<Bytes>>>,
io_queue: Arc<IoQueue>,
priority: u128,
num_reqs: usize,
num_bytes: u64,
}

// Refund the batch's backpressure reservation when the response is dropped, be
// that on delivery or when a cancelled request's undelivered response is
// discarded. This releases the budget even if the caller drops the future early.
impl Drop for Response {
fn drop(&mut self) {
self.io_queue
.on_bytes_consumed(self.num_bytes, self.priority, self.num_reqs);
}
}

#[derive(Debug, Clone, Copy)]
pub struct SchedulerConfig {
/// the # of bytes that can be buffered but not yet requested.
Expand Down Expand Up @@ -966,6 +985,7 @@ impl ScanScheduler {
priority,
request.len(),
bypass_backpressure,
io_queue.clone(),
))));

for (task_idx, iop) in request.into_iter().enumerate() {
Expand Down Expand Up @@ -1004,14 +1024,11 @@ impl ScanScheduler {

self.do_submit_request(reader, request, tx, priority, io_queue, bypass_backpressure);

let io_queue_clone = io_queue.clone();

rx.map(move |wrapped_rsp| {
// Right now, it isn't possible for I/O to be cancelled so a cancel error should
// not occur
let rsp = wrapped_rsp.unwrap();
io_queue_clone.on_bytes_consumed(rsp.num_bytes, rsp.priority, rsp.num_reqs);
rsp.data
rx.map(|wrapped_rsp| {
// A cancel error can't occur: the sender always sends before dropping.
// The reservation is refunded on `Response` drop, so just take the data.
let mut rsp = wrapped_rsp.unwrap();
rsp.data.take().unwrap()
})
Comment on lines +1027 to 1032

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer .expect("reason") over bare .unwrap() in library code.

Both unwraps here are on the caller/hot path in non-test code. Encoding the invariants documented in the comments as .expect() messages preserves them at the panic site.

♻️ Proposed change
-        let mut rsp = wrapped_rsp.unwrap();
-        rsp.data.take().unwrap()
+        let mut rsp = wrapped_rsp.expect("sender always sends before dropping");
+        rsp.data.take().expect("response data is taken exactly once")

As per coding guidelines: "Avoid bare .unwrap(); ... If unavoidable, use .expect(\"reason\")."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
rx.map(|wrapped_rsp| {
// A cancel error can't occur: the sender always sends before dropping.
// The reservation is refunded on `Response` drop, so just take the data.
let mut rsp = wrapped_rsp.unwrap();
rsp.data.take().unwrap()
})
rx.map(|wrapped_rsp| {
// A cancel error can't occur: the sender always sends before dropping.
// The reservation is refunded on `Response` drop, so just take the data.
let mut rsp = wrapped_rsp.expect("sender always sends before dropping");
rsp.data.take().expect("response data is taken exactly once")
})
🤖 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-io/src/scheduler.rs` around lines 1027 - 1032, The hot-path
`rx.map` logic in `scheduler.rs` uses bare `.unwrap()` twice in library code;
replace both with `.expect(...)` so the documented invariants are enforced at
the panic site. Update the `wrapped_rsp.unwrap()` and `rsp.data.take().unwrap()`
calls in this closure with clear reason messages that match the surrounding
comments about the sender always sending before dropping and the reservation
being refunded on `Response` drop.

Source: Coding guidelines

}

Expand Down Expand Up @@ -1964,6 +1981,7 @@ mod tests {
#[derive(Debug)]
struct BlockingReader {
semaphore: Arc<tokio::sync::Semaphore>,
get_range_count: Arc<AtomicU64>,
path: Path,
}

Expand Down Expand Up @@ -1994,6 +2012,7 @@ mod tests {
&self,
range: Range<usize>,
) -> futures::future::BoxFuture<'static, object_store::Result<Bytes>> {
self.get_range_count.fetch_add(1, Ordering::Release);
let semaphore = self.semaphore.clone();
let num_bytes = range.end - range.start;
Box::pin(async move {
Expand Down Expand Up @@ -2030,6 +2049,7 @@ mod tests {
let semaphore = Arc::new(tokio::sync::Semaphore::new(0));
let reader: Arc<dyn Reader> = Arc::new(BlockingReader {
semaphore: semaphore.clone(),
get_range_count: Arc::new(AtomicU64::new(0)),
path: Path::parse("test").unwrap(),
});

Expand Down Expand Up @@ -2336,4 +2356,105 @@ mod tests {
.unwrap();
assert_eq!(bytes_dispatched.load(Ordering::Acquire), 30);
}

// Against a 100-byte budget: submit fut1 (50 bytes, priority 0), drop it while
// its read is still blocked in get_range, then submit fut2 (60 bytes, priority 1).
// fut2's priority can't win the priority-bypass, so it needs 60 of the budget --
// available only if fut1's dropped reservation was refunded. Returns whether fut2
// completed within 2s (false = the reservation leaked and fut2 deadlocked).
async fn run_caller_drop_scenario(use_lite_scheduler: bool) -> (bool, Duration) {
let obj_store = Arc::new(ObjectStore::new(
Arc::new(InMemory::new()),
Url::parse("mem://").unwrap(),
Some(4096),
None,
false,
false,
1,
DEFAULT_DOWNLOAD_RETRY_COUNT,
None,
));
let scheduler = ScanScheduler::new(
obj_store,
SchedulerConfig {
io_buffer_size_bytes: 100,
use_lite_scheduler: Some(use_lite_scheduler),
},
);

let semaphore = Arc::new(tokio::sync::Semaphore::new(0));
let get_range_count = Arc::new(AtomicU64::new(0));
let reader: Arc<dyn Reader> = Arc::new(BlockingReader {
semaphore: semaphore.clone(),
get_range_count: get_range_count.clone(),
path: Path::parse("test").unwrap(),
});

// Step 1: reserve 50 of the 100 budget bytes with a read we never consume.
// Spawn it so we can cancel the caller-side future while it is still parked
// waiting for the (blocked) read to finish.
let fut1 = scheduler.submit_request(reader.clone(), vec![0..50], 0, false);
let handle = tokio::spawn(async move {
let _ = fut1.await;
});

// Wait until the read is genuinely in flight (blocked on the semaphore).
// This guarantees the 50-byte reservation has been taken before we drop
// the caller, closing the race between the I/O loop and the abort.
while get_range_count.load(Ordering::Acquire) == 0 {
tokio::time::sleep(Duration::from_millis(1)).await;
}

// Step 2: drop the caller-side future while its `rx` is still pending.
handle.abort();
let _ = handle.await;

// Step 3: let the in-flight read finish. The reservation should be refunded
// now that the request is done, whether or not the caller is still around.
semaphore.add_permits(1);
// Give the read time to run to completion so the refund would already have
// happened.
tokio::time::sleep(Duration::from_millis(50)).await;

// Step 4: submit the follow-up. Add a permit up front so that, if it *is*
// admitted, its own read can complete rather than block on the semaphore.
semaphore.add_permits(1);
let fut2 = scheduler.submit_request(reader, vec![100..160], 1, false);

let start = std::time::Instant::now();
let outcome = timeout(Duration::from_secs(2), fut2).await;
let elapsed = start.elapsed();
match outcome {
Ok(res) => {
assert_eq!(res.unwrap().iter().map(|b| b.len()).sum::<usize>(), 60);
(true, elapsed)
}
Err(_) => (false, elapsed),
}
}

/// Dropping a standard-scheduler request future while its read is in flight must
/// still refund the backpressure reservation, so a later request that needs the
/// budget does not deadlock.
#[tokio::test(flavor = "multi_thread")]
async fn standard_scheduler_refunds_reservation_on_caller_drop() {
let (completed, elapsed) = run_caller_drop_scenario(false).await;
assert!(
completed,
"standard scheduler deadlocked the follow-up request (elapsed {elapsed:?}); \
the dropped request's reservation was not refunded"
);
}

/// Same guarantee for the lite scheduler: dropping a request future mid-read
/// releases its reservation via the `TaskHandle` drop path.
#[tokio::test(flavor = "multi_thread")]
async fn lite_scheduler_refunds_reservation_on_caller_drop() {
let (completed, elapsed) = run_caller_drop_scenario(true).await;
assert!(
completed,
"lite scheduler deadlocked the follow-up request (elapsed {elapsed:?}); \
the dropped request's reservation was not refunded"
);
}
Comment on lines +2439 to +2459

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

praise: I appreciate the regression tests here.

}
75 changes: 56 additions & 19 deletions rust/lance-io/src/scheduler/lite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,26 @@ enum TaskState {
},
}

impl TaskState {
fn backpressure_reservation(&self) -> Option<BackpressureReservation> {
match self {
Self::Reserved {
backpressure_reservation,
..
}
| Self::Running {
backpressure_reservation,
..
}
| Self::Finished {
backpressure_reservation,
..
} => Some(*backpressure_reservation),
Self::Initial { .. } | Self::Broken => None,
}
}
}

/// A custom error type that might have a backpressure reservation
///
/// This is used instead of Lance's standard error type so we can ensure
Expand All @@ -88,25 +108,14 @@ impl BrokenTaskError {
// This will capture any backpressure reservation the task has and put it into the
// error so we make sure to release it when returning the error.
fn new(task_state: TaskState, message: String) -> Self {
match task_state {
TaskState::Reserved {
backpressure_reservation,
..
}
| TaskState::Running {
backpressure_reservation,
..
}
| TaskState::Finished {
backpressure_reservation,
..
} => Self {
match task_state.backpressure_reservation() {
None => Self {
message,
backpressure_reservation: Some(backpressure_reservation),
backpressure_reservation: None,
},
TaskState::Broken | TaskState::Initial { .. } => Self {
Some(reservation) => Self {
message,
backpressure_reservation: None,
backpressure_reservation: Some(reservation),
},
}
}
Comment on lines 110 to 121

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Collapse the redundant match into a single struct literal.

Both arms build the same Self, differing only in None/Some(reservation), which is exactly what backpressure_reservation() already returns.

♻️ Proposed simplification
     fn new(task_state: TaskState, message: String) -> Self {
-        match task_state.backpressure_reservation() {
-            None => Self {
-                message,
-                backpressure_reservation: None,
-            },
-            Some(reservation) => Self {
-                message,
-                backpressure_reservation: Some(reservation),
-            },
-        }
+        Self {
+            backpressure_reservation: task_state.backpressure_reservation(),
+            message,
+        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn new(task_state: TaskState, message: String) -> Self {
match task_state {
TaskState::Reserved {
backpressure_reservation,
..
}
| TaskState::Running {
backpressure_reservation,
..
}
| TaskState::Finished {
backpressure_reservation,
..
} => Self {
match task_state.backpressure_reservation() {
None => Self {
message,
backpressure_reservation: Some(backpressure_reservation),
backpressure_reservation: None,
},
TaskState::Broken | TaskState::Initial { .. } => Self {
Some(reservation) => Self {
message,
backpressure_reservation: None,
backpressure_reservation: Some(reservation),
},
}
}
fn new(task_state: TaskState, message: String) -> Self {
Self {
backpressure_reservation: task_state.backpressure_reservation(),
message,
}
}
🤖 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-io/src/scheduler/lite.rs` around lines 110 - 121, Collapse the
redundant match in BackpressureError::new into a single struct literal by
assigning task_state.backpressure_reservation() directly to
backpressure_reservation; the None/Some(reservation) branching is unnecessary
because the method already returns the correct Option value. Keep the message
field unchanged and preserve the existing constructor behavior in new.

Expand Down Expand Up @@ -600,9 +609,11 @@ impl IoQueue {
let mut task_result = TaskResult::Ok(());
while !state_ref.pending_tasks.is_empty() {
// Unwrap safe here since we just checked the queue is not empty
let next_task = state_ref.pending_tasks.peek().unwrap();
let Some(task) = state_ref.tasks.get_mut(&next_task.task_id) else {
log::warn!("Task with id {} was lost", next_task.task_id);
let task_id = state_ref.pending_tasks.peek().unwrap().task_id;
let Some(task) = state_ref.tasks.get_mut(&task_id) else {
// The caller dropped this task's handle (see `abandon`); discard the
// stale queue entry instead of spinning on it.
state_ref.pending_tasks.pop();
continue;
};
if !task.is_reserved() {
Expand Down Expand Up @@ -665,6 +676,26 @@ impl IoQueue {
};
emit_scheduler_state_event(event, &self.stats);
}

// Called when a caller drops a task's handle before the task finishes. Removes
// the task and returns any backpressure reservation it holds to the budget, then
// re-checks the queue so newly-affordable tasks can start. Unlike the standard
// release path (`poll`), this runs without the task being polled to completion,
// so a cancelled read does not leak its reservation.
fn abandon(&self, task_id: u64) {
let mut state = self.state.lock().unwrap();
let Some(task) = state.tasks.remove(&task_id) else {
// Already consumed by `poll`; nothing to release.
return;
};

if let Some(reservation) = task.state.backpressure_reservation() {
state.backpressure_throttle.release(reservation);
}
// Freed budget may make queued tasks runnable; there is no caller to surface
// an error to here.
let _ = self.on_task_complete(state);
Comment on lines +695 to +697

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Log the swallowed error instead of discarding it silently.

on_task_complete can fail here (e.g. a BrokenTaskError while starting newly-runnable queued tasks). Since abandon runs from Drop and cannot propagate, discarding via let _ hides a real failure. Emit a warn! so the condition is observable.

♻️ Proposed change
-        // Freed budget may make queued tasks runnable; there is no caller to surface
-        // an error to here.
-        let _ = self.on_task_complete(state);
+        // Freed budget may make queued tasks runnable; there is no caller to surface
+        // an error to here, so log on failure instead of propagating.
+        if let Err(e) = self.on_task_complete(state) {
+            log::warn!("Error re-checking queue after abandoning task {task_id}: {e}");
+        }

As per coding guidelines: "Log warnings on best-effort or cleanup failures instead of silently swallowing or propagating errors."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Freed budget may make queued tasks runnable; there is no caller to surface
// an error to here.
let _ = self.on_task_complete(state);
// Freed budget may make queued tasks runnable; there is no caller to surface
// an error to here, so log on failure instead of propagating.
if let Err(e) = self.on_task_complete(state) {
log::warn!("Error re-checking queue after abandoning task {task_id}: {e}");
}
🤖 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-io/src/scheduler/lite.rs` around lines 700 - 702, The cleanup path
in `lite.rs` is swallowing a failure from `on_task_complete` by assigning it to
`_`, which hides observable errors during `abandon`/`Drop`. Update the
`on_task_complete` call site in `abandon` to capture the result and emit a
`warn!` when it returns an error, using the existing `state`/task context if
available, so best-effort cleanup failures are logged instead of silently
discarded.

Source: Coding guidelines

}
}

pub(super) struct TaskHandle {
Expand All @@ -679,6 +710,12 @@ impl Future for TaskHandle {
}
}

impl Drop for TaskHandle {
fn drop(&mut self) {
self.queue.abandon(self.task_id);
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading