quickwit-oss/quickwit · error · io::Error (UnexpectedEof)

body stream ended with {} bytes pending; expected {target}

Error message

body stream ended with {} bytes pending; expected {target}

What it means

`fill_pending` in the parquet streaming reader is topping up its internal pending buffer until it reaches `target` bytes. When the underlying body stream (HTTP object-storage body) returns 0 bytes (EOF) before the target is met, the reader raises UnexpectedEof, since the Parquet page header/data expected by the caller (`read_one_page`) is truncated.

Source

Thrown at quickwit/quickwit-parquet-engine/src/storage/streaming_reader.rs:559

fn try_parse_page_header(buf: &[u8]) -> Result<(PageHeader, usize), thrift::Error> {
    use parquet::thrift::TSerializable;
    let mut cursor = io::Cursor::new(buf);
    let mut prot = TCompactInputProtocol::new(&mut cursor);
    let header = PageHeader::read_from_in_protocol(&mut prot)?;
    Ok((header, cursor.position() as usize))
}

/// Ensure `state.pending` has at least `target` bytes, reading from
/// the body stream as needed. Errors on premature EOF.
async fn fill_pending(state: &mut ReadingState, target: usize) -> io::Result<()> {
    while state.pending.len() < target {
        let buf_len = state.pending.len();
        let to_alloc = (target - buf_len).max(8 * 1024);
        state.pending.resize(buf_len + to_alloc, 0);
        let n = state.body.read(&mut state.pending[buf_len..]).await?;
        state.pending.truncate(buf_len + n);
        if n == 0 {
            return Err(io::Error::new(
                io::ErrorKind::UnexpectedEof,
                format!(
                    "body stream ended with {} bytes pending; expected {target}",
                    state.pending.len(),
                ),
            ));
        }
    }
    Ok(())
}

/// Fill `state.pending` toward `target` bytes, but tolerate EOF
/// (return `Ok(())` even if we can't reach the target). Used by the
/// page-header parser, which iterates and decides whether the buffer
/// is sufficient.
async fn fill_pending_best_effort(state: &mut ReadingState, target: usize) -> io::Result<()> {
    while state.pending.len() < target {
        let buf_len = state.pending.len();

View on GitHub (pinned to a39730c5cd)

Solutions

  1. Retry the read/query — the reader's owner should re-open a fresh range request for the split bytes.
  2. Verify the split file integrity in object storage (size/checksum) and re-upload if truncated.
  3. Check for proxies/load balancers between Quickwit and the storage backend dropping long connections; raise their timeouts.
  4. If reproducible, enable storage request logging to capture the HTTP status that ended the body stream.
Defensive patterns

Strategy: retry

Type guard

fn is_truncated_body(err: &std::io::Error) -> bool { err.kind() == std::io::ErrorKind::UnexpectedEof }

Try / catch

match res {
    Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
        warn!("truncated body, retrying with fresh range request");
        retry_with_backoff(|| reopen_and_read())
    }
    other => other,
}

Prevention

When it happens

Trigger: Calling read_one_page on a StreamingPageReader whose remote body terminates early: connection reset mid-body, truncated upload in object storage, proxy timeout cutting the response, or a Content-Length/ETag mismatch.

Common situations: Unstable network links to S3/Azure/GCS; long-running queries whose HTTP connections are dropped by a load balancer; reading splits uploaded incompletely; storage gateway or CDN with aggressive idle timeouts.

Understand the failure class

Background: 'Something went wrong' / 'Request failed (500)' / 'HTTP error! status: 404' — what failed HTTP requests actually mean and how to find the real cause — this error's family across 28 libraries.

Related errors


AI-assisted analysis of quickwit-oss/quickwit@a39730c5cd (2026-09-08). Data as JSON: /api/errors/9342a6931dd117fe. Report an issue: GitHub.