Skip to content

Commit 45eb977

Browse files
authored
fix: preserve structural page load errors (#7571)
## Problem When a structural page load fails, for example because an upstream object store range read times out, the original I/O error can be hidden by a later panic from the decoder. An observed Python failure looked like this (abridged): ```text OSError: range read timed out ... thread 'tokio-runtime-worker' panicked at rust/lance-encoding/src/encodings/logical/primitive.rs:3663:58: called `Option::unwrap()` on a `None` value ... pyo3_runtime.PanicException: called `Option::unwrap()` on a `None` value ``` The panic was misleading because the root cause was the earlier page-load timeout. ## Root cause `StructuralBatchDecodeStream` updated `rows_scheduled` before awaiting the `UnloadedPageShard`. If that page future returned an error, `into_stream` still emitted the error as a `ReadBatchTask` item but left the stream state pollable. Downstream readers use `.buffered(batch_readahead)`, so the stream could be polled again before the first error was consumed. On the next poll, the decoder saw rows as already scheduled even though the failed page was never accepted into the primitive page queue. That eventually reached `front_mut().unwrap()` on an empty page queue and surfaced as a PyO3 panic. ## Changes - Stop `StructuralBatchDecodeStream` after a page-load error while still returning the original error from the emitted `ReadBatchTask`. - Replace the primitive decoder `unwrap()` with an internal error that includes field name, data type, requested rows, remaining rows, current-page drained rows, and queued page count. - Add regression coverage for the readahead path and the primitive decoder guard. ## Testing - `cargo fmt --all --check` - `cargo test -p lance-encoding test_structural_decode_stream_stops_after_page_load_error` - `cargo test -p lance-encoding test_primitive_decoder_empty_page_queue_returns_error` - `cargo test -p lance-encoding --lib` - `cargo clippy --all --tests --benches -- -D warnings`
1 parent 66d28cc commit 45eb977

2 files changed

Lines changed: 270 additions & 70 deletions

File tree

rust/lance-encoding/src/decoder.rs

Lines changed: 228 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,34 +1508,41 @@ impl BatchDecodeStream {
15081508

15091509
pub fn into_stream(self) -> BoxStream<'static, ReadBatchTask> {
15101510
let stream = futures::stream::unfold(self, |mut slf| async move {
1511-
let next_task = slf.next_batch_task().await;
1512-
let next_task = next_task.transpose().map(|next_task| {
1513-
let num_rows = next_task.as_ref().map(|t| t.num_rows).unwrap_or(0);
1514-
let emitted_batch_size_warning = slf.emitted_batch_size_warning.clone();
1515-
let task = async move {
1516-
let next_task = next_task?;
1517-
// Real decode work happens inside into_batch, which can block the current
1518-
// thread for a long time. By spawning it as a new task, we allow Tokio's
1519-
// worker threads to keep making progress.
1520-
let (batch, _data_size) =
1521-
tokio::spawn(
1522-
async move { next_task.into_batch(emitted_batch_size_warning) },
1523-
)
1511+
let next_task = match slf.next_batch_task().await {
1512+
Ok(Some(next_task)) => next_task,
1513+
Ok(None) => return None,
1514+
Err(err) => {
1515+
slf.rows_remaining = 0;
1516+
return Some((
1517+
ReadBatchTask {
1518+
task: async move { Err(err) }.boxed(),
1519+
num_rows: 0,
1520+
},
1521+
slf,
1522+
));
1523+
}
1524+
};
1525+
let num_rows = next_task.num_rows;
1526+
let emitted_batch_size_warning = slf.emitted_batch_size_warning.clone();
1527+
let task = async move {
1528+
// Real decode work happens inside into_batch, which can block the current
1529+
// thread for a long time. By spawning it as a new task, we allow Tokio's
1530+
// worker threads to keep making progress.
1531+
let (batch, _data_size) =
1532+
tokio::spawn(async move { next_task.into_batch(emitted_batch_size_warning) })
15241533
.await
15251534
.map_err(|err| Error::wrapped(err.into()))??;
1526-
Ok(batch)
1527-
};
1528-
(task, num_rows)
1529-
});
1530-
next_task.map(|(task, num_rows)| {
1531-
// This should be true since batch size is u32
1532-
debug_assert!(num_rows <= u32::MAX as u64);
1533-
let next_task = ReadBatchTask {
1535+
Ok(batch)
1536+
};
1537+
// This should be true since batch size is u32
1538+
debug_assert!(num_rows <= u32::MAX as u64);
1539+
Some((
1540+
ReadBatchTask {
15341541
task: task.boxed(),
15351542
num_rows: num_rows as u32,
1536-
};
1537-
(next_task, slf)
1538-
})
1543+
},
1544+
slf,
1545+
))
15391546
});
15401547
stream.boxed()
15411548
}
@@ -1913,54 +1920,61 @@ impl StructuralBatchDecodeStream {
19131920

19141921
pub fn into_stream(self) -> BoxStream<'static, ReadBatchTask> {
19151922
let stream = futures::stream::unfold(self, |mut slf| async move {
1916-
let next_task = slf.next_batch_task().await;
1917-
let next_task = next_task.transpose().map(|next_task| {
1918-
let num_rows = next_task.as_ref().map(|t| t.num_rows).unwrap_or(0);
1919-
let emitted_batch_size_warning = slf.emitted_batch_size_warning.clone();
1920-
let bytes_per_row_feedback = slf.bytes_per_row_feedback.clone();
1921-
// Capture the per-stream policy once so every emitted batch task follows the
1922-
// same throughput-vs-overhead choice made by the scheduler.
1923-
let spawn_batch_decode_tasks = slf.spawn_batch_decode_tasks;
1924-
let task = async move {
1925-
let next_task = next_task?;
1926-
let (batch, data_size) = if spawn_batch_decode_tasks {
1927-
tokio::spawn(
1928-
async move { next_task.into_batch(emitted_batch_size_warning) },
1929-
)
1923+
let next_task = match slf.next_batch_task().await {
1924+
Ok(Some(next_task)) => next_task,
1925+
Ok(None) => return None,
1926+
Err(err) => {
1927+
slf.rows_remaining = 0;
1928+
return Some((
1929+
ReadBatchTask {
1930+
task: async move { Err(err) }.boxed(),
1931+
num_rows: 0,
1932+
},
1933+
slf,
1934+
));
1935+
}
1936+
};
1937+
let num_rows = next_task.num_rows;
1938+
let emitted_batch_size_warning = slf.emitted_batch_size_warning.clone();
1939+
let bytes_per_row_feedback = slf.bytes_per_row_feedback.clone();
1940+
// Capture the per-stream policy once so every emitted batch task follows the
1941+
// same throughput-vs-overhead choice made by the scheduler.
1942+
let spawn_batch_decode_tasks = slf.spawn_batch_decode_tasks;
1943+
let task = async move {
1944+
let (batch, data_size) = if spawn_batch_decode_tasks {
1945+
tokio::spawn(async move { next_task.into_batch(emitted_batch_size_warning) })
19301946
.await
19311947
.map_err(|err| Error::wrapped(err.into()))??
1948+
} else {
1949+
next_task.into_batch(emitted_batch_size_warning)?
1950+
};
1951+
let num_rows = batch.num_rows() as u64;
1952+
if num_rows > 0 {
1953+
let bpr = data_size / num_rows;
1954+
let prev = bytes_per_row_feedback.load(Ordering::Relaxed);
1955+
let next = if prev == 0 || bpr >= prev {
1956+
// First batch or actual size is larger than estimate:
1957+
// adopt immediately to avoid OOM.
1958+
bpr
19321959
} else {
1933-
next_task.into_batch(emitted_batch_size_warning)?
1960+
// Actual size is smaller: degrade gradually toward
1961+
// the true value to avoid over-correcting on a
1962+
// single anomalous batch.
1963+
(prev + bpr) / 2
19341964
};
1935-
let num_rows = batch.num_rows() as u64;
1936-
if num_rows > 0 {
1937-
let bpr = data_size / num_rows;
1938-
let prev = bytes_per_row_feedback.load(Ordering::Relaxed);
1939-
let next = if prev == 0 || bpr >= prev {
1940-
// First batch or actual size is larger than estimate:
1941-
// adopt immediately to avoid OOM.
1942-
bpr
1943-
} else {
1944-
// Actual size is smaller: degrade gradually toward
1945-
// the true value to avoid over-correcting on a
1946-
// single anomalous batch.
1947-
(prev + bpr) / 2
1948-
};
1949-
bytes_per_row_feedback.store(next.max(1), Ordering::Relaxed);
1950-
}
1951-
Ok(batch)
1952-
};
1953-
(task, num_rows)
1954-
});
1955-
next_task.map(|(task, num_rows)| {
1956-
// This should be true since batch size is u32
1957-
debug_assert!(num_rows <= u32::MAX as u64);
1958-
let next_task = ReadBatchTask {
1965+
bytes_per_row_feedback.store(next.max(1), Ordering::Relaxed);
1966+
}
1967+
Ok(batch)
1968+
};
1969+
// This should be true since batch size is u32
1970+
debug_assert!(num_rows <= u32::MAX as u64);
1971+
Some((
1972+
ReadBatchTask {
19591973
task: task.boxed(),
19601974
num_rows: num_rows as u32,
1961-
};
1962-
(next_task, slf)
1963-
})
1975+
},
1976+
slf,
1977+
))
19641978
});
19651979
stream.boxed()
19661980
}
@@ -2915,6 +2929,60 @@ pub async fn decode_batch(
29152929
// test coalesce indices to ranges
29162930
mod tests {
29172931
use super::*;
2932+
use crate::previous::decoder::{DecoderReady, LogicalPageDecoder};
2933+
use std::collections::VecDeque;
2934+
2935+
#[derive(Debug)]
2936+
struct FailingPageDecoder {
2937+
page_data_type: DataType,
2938+
total_rows: u64,
2939+
load_error_message: &'static str,
2940+
}
2941+
2942+
impl FailingPageDecoder {
2943+
fn new(
2944+
page_data_type: DataType,
2945+
total_rows: u64,
2946+
load_error_message: &'static str,
2947+
) -> Self {
2948+
Self {
2949+
page_data_type,
2950+
total_rows,
2951+
load_error_message,
2952+
}
2953+
}
2954+
}
2955+
2956+
impl LogicalPageDecoder for FailingPageDecoder {
2957+
fn wait_for_loaded(&'_ mut self, _rows_needed: u64) -> BoxFuture<'_, Result<()>> {
2958+
let load_error_message = self.load_error_message;
2959+
async move { Err(Error::io(load_error_message)) }.boxed()
2960+
}
2961+
2962+
fn rows_loaded(&self) -> u64 {
2963+
0
2964+
}
2965+
2966+
fn num_rows(&self) -> u64 {
2967+
self.total_rows
2968+
}
2969+
2970+
fn rows_drained(&self) -> u64 {
2971+
0
2972+
}
2973+
2974+
fn drain(&mut self, requested_rows: u64) -> Result<NextDecodeTask> {
2975+
Err(Error::internal(format!(
2976+
"failing page decoder should not be drained after load error \
2977+
(requested_rows={})",
2978+
requested_rows
2979+
)))
2980+
}
2981+
2982+
fn data_type(&self) -> &DataType {
2983+
&self.page_data_type
2984+
}
2985+
}
29182986

29192987
#[test]
29202988
fn test_read_zero_dimension_fsl_errors_instead_of_panicking() {
@@ -2962,6 +3030,100 @@ mod tests {
29623030
);
29633031
}
29643032

3033+
#[tokio::test]
3034+
async fn test_legacy_stream_stops_on_load_error() {
3035+
use arrow_schema::Field as ArrowField;
3036+
3037+
let rows_per_batch = 1;
3038+
let total_rows = 2;
3039+
let scheduled_rows = 1;
3040+
let page_rows = 1;
3041+
let batch_readahead = 2;
3042+
let load_error_message = "simulated page load failure";
3043+
let fields = Fields::from(vec![ArrowField::new("vector", DataType::Float32, true)]);
3044+
let root_decoder = SimpleStructDecoder::new(fields, total_rows);
3045+
let (tx, rx) = unbounded_channel();
3046+
3047+
tx.send(Ok(DecoderMessage {
3048+
scheduled_so_far: scheduled_rows,
3049+
decoders: vec![MessageType::DecoderReady(DecoderReady {
3050+
decoder: Box::new(FailingPageDecoder::new(
3051+
DataType::Float32,
3052+
page_rows,
3053+
load_error_message,
3054+
)),
3055+
path: VecDeque::from([0]),
3056+
})],
3057+
}))
3058+
.unwrap();
3059+
drop(tx);
3060+
3061+
let stream =
3062+
BatchDecodeStream::new(rx, rows_per_batch, total_rows, root_decoder).into_stream();
3063+
let mut batches = stream.map(|task| task.task).buffered(batch_readahead);
3064+
3065+
let err = batches
3066+
.next()
3067+
.await
3068+
.expect("stream should emit the legacy page-load error")
3069+
.unwrap_err();
3070+
assert!(
3071+
err.to_string().contains(load_error_message),
3072+
"unexpected error: {}",
3073+
err
3074+
);
3075+
assert!(
3076+
batches.next().await.is_none(),
3077+
"stream should stop after the legacy page-load error"
3078+
);
3079+
}
3080+
3081+
#[tokio::test]
3082+
async fn test_structural_stream_stops_on_load_error() {
3083+
let rows_per_batch = 1;
3084+
let total_rows = 2;
3085+
let scheduled_rows = 1;
3086+
let batch_readahead = 2;
3087+
let load_error_message = "simulated page load failure";
3088+
let fields = Fields::from(vec![ArrowField::new("vector", DataType::Float32, true)]);
3089+
let root_decoder = StructuralStructDecoder::new(fields, false, /*is_root=*/ true).unwrap();
3090+
let (tx, rx) = unbounded_channel();
3091+
let failed_page = async move { Err(Error::io(load_error_message)) }.boxed();
3092+
3093+
tx.send(Ok(DecoderMessage {
3094+
scheduled_so_far: scheduled_rows,
3095+
decoders: vec![MessageType::UnloadedPage(UnloadedPageShard(failed_page))],
3096+
}))
3097+
.unwrap();
3098+
drop(tx);
3099+
3100+
let stream = StructuralBatchDecodeStream::new(
3101+
rx,
3102+
rows_per_batch,
3103+
total_rows,
3104+
root_decoder,
3105+
/*spawn_batch_decode_tasks=*/ true,
3106+
None,
3107+
)
3108+
.into_stream();
3109+
let mut batches = stream.map(|task| task.task).buffered(batch_readahead);
3110+
3111+
let err = batches
3112+
.next()
3113+
.await
3114+
.expect("stream should emit the page-load error")
3115+
.unwrap_err();
3116+
assert!(
3117+
err.to_string().contains(load_error_message),
3118+
"unexpected error: {}",
3119+
err
3120+
);
3121+
assert!(
3122+
batches.next().await.is_none(),
3123+
"stream should stop after the page-load error"
3124+
);
3125+
}
3126+
29653127
#[test]
29663128
fn test_coalesce_indices_to_ranges_with_single_index() {
29673129
let indices = vec![1];

0 commit comments

Comments
 (0)