Skip to content

fix(storage): fail the download instead of committing bad chunk data - #71

Open
define-null wants to merge 3 commits into
masterfrom
fix/reject-untrustworthy-chunk-downloads
Open

fix(storage): fail the download instead of committing bad chunk data#71
define-null wants to merge 3 commits into
masterfrom
fix/reject-untrustworthy-chunk-downloads

Conversation

@define-null

Copy link
Copy Markdown
Contributor

Problem

A chunk committed when every file's HTTP status was not 4xx/5xx and the body copied without an I/O error. Nothing looked at the bytes. Two ways bad data was being committed and served until an assignment stopped naming it:

  • A redirect. The client follows no redirects (Make HTTP requests nice #41) and error_for_status passes 3xx, so the redirect response's body was written as the table.
  • A body that isn't the file — truncated, empty, or an error page under a 200. Parquet keeps its metadata in the footer, so these fail every query over the chunk with reading parquet metadata … (server_error), permanently: recovery adopts chunks by directory name and reconciliation only queues absent chunks, so a bad chunk is never re-fetched.

Fix

In download_one:

  • Reject any non-2xx.
  • Parse each downloaded .parquet footer with ParquetMetaDataReader — the same read the query engine does when it opens a chunk. Footer only, page data isn't decoded. parquet was already in the graph via sqd-query and sqd-query-engine; Cargo.lock is unchanged.

A rejected file fails its chunk: the staging directory is swept and the existing retry/give-up path applies unchanged.

Tests

tests/download_integrity.rs drives the real ChunkDownloader against exact raw responses. The first commit adds the tests — 4 of 7 fail on master — and the second makes them pass. The redirect case carries a valid parquet body, so only the status check can reject it; each check was confirmed load-bearing by disabling it in turn.

Not covered here

  • A well-formed but wrong file: the assignment carries no size or digest (GAP-5 remainder).
  • A chunk missing a table (empty file list, escaped legacy file name) still commits, and the dynamic engine answers its logs queries as empty rather than erroring. Belongs at DatasetsIndex::new.
  • Post-commit damage: nothing syncs before the rename (CN-4).

🤖 Generated with Claude Code

define-null and others added 3 commits August 28, 2026 15:48
Drives the real ChunkDownloader against exact raw responses. Four of these
fail today: a redirect, a non-parquet body, a truncated table and an empty
one are all committed as chunk data, because the commit gate is the HTTP
status alone (INV-13, GAP-5).

The redirect case carries a valid table as its body, so only the status can
tell it apart from the file it points at.

The rest guard what already holds: a well-formed table commits, and an
interruption is caught whenever the response framing makes it visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The commit gate was the HTTP status alone, so whatever bytes arrived became
the chunk. Two checks close what a worker can decide on its own.

A 3xx passes `error_for_status`, and the client follows no redirects, so the
redirect response was being written as the table. Reject any non-2xx.

Parse the parquet footer of each downloaded table — the same read the query
engine does when it opens a chunk, so a committed file is one the read path
can open. That covers an empty or substituted file, and a truncation the
response framing left invisible to hyper. Page data is not decoded and the
assignment carries no size or digest, so GAP-5's remainder stays open.

`parquet` was already in the normal dependency graph through sqd-query and
sqd-query-engine; declaring it directly costs no extra compilation.

A rejected file fails its whole chunk: the staging directory is swept and the
existing retry and give-up path applies unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n handle

Open the file read+write and hand the same handle to the footer parse
once the copy is done, instead of re-opening it on the blocking pool. The
parse is two small reads of a file just written, so it runs inline like
the other fs ops in this module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant