Skip to content

fix: support File payloads in ThrottledStore (fixes LocalFileSystem panic)#808

Closed
adriangb wants to merge 1 commit into
apache:mainfrom
pydantic:fix-throttle-localfilesystem-panic
Closed

fix: support File payloads in ThrottledStore (fixes LocalFileSystem panic)#808
adriangb wants to merge 1 commit into
apache:mainfrom
pydantic:fix-throttle-localfilesystem-panic

Conversation

@adriangb

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

No separate issue is filed; the bug and repro are described in full below.

Rationale for this change

ThrottledStore is the crate's built-in wrapper for performance testing (it injects wait_get_per_call first-byte latency, wait_get_per_byte throughput throttling, and so on). But it panics with not implemented on the first get/get_opts/head when it wraps a store that returns a file-backed payload, which is exactly what LocalFileSystem does when the fs feature is enabled, and LocalFileSystem is the obvious inner store you would want to throttle for perf testing.

Every get_opts result is funnelled through the free function throttle_get in src/throttle.rs, whose payload match was:

GetResultPayload::File(_, _) => unimplemented!(),

LocalFileSystem::get_opts returns GetResultPayload::File(..), so the first read hits unimplemented!().

Note that the fixed per-call latency already worked for File payloads (sleep(wait_get_per_call) runs before the inner call in get_opts), and get_ranges already worked (it delegates directly). Only the per-byte stream-wrapping step in throttle_get panicked.

Minimal repro:

use object_store::{throttle::{ThrottledStore, ThrottleConfig}, local::LocalFileSystem, ObjectStore, path::Path};

let dir = tempfile::TempDir::new().unwrap();
let store = ThrottledStore::new(
    LocalFileSystem::new_with_prefix(dir.path()).unwrap(),
    ThrottleConfig::default(),
);
let path = Path::from("file.txt");
store.put(&path, "hello".into()).await.unwrap();
store.get(&path).await.unwrap(); // panics: not implemented

What changes are included in this PR?

throttle_get now handles the File payload instead of panicking. The design preserves the local-file fast path rather than forcing every File read into a byte stream just so it can be throttled:

  • For a Stream payload, behavior is byte-for-byte unchanged: the per-byte throttle is applied lazily per chunk as before.
  • For a File payload, the per-byte component is applied as a single eager sleep computed from the known requested range (result.range), mirroring how get_ranges already throttles a whole vectored read up front, and then the File payload is returned untouched. Because sleep is a no-op on a zero duration, the common case where only wait_get_per_call is set (and wait_get_per_byte is zero) passes the File payload straight through with no allocation or stream conversion.

throttle_get becomes async so the File-path sleep can be awaited; the single caller in get_opts awaits it.

A regression test (get_local_file_system) wraps a real LocalFileSystem in a ThrottledStore with non-zero per-call and per-byte waits, reads an object back, asserts the payload is still a File (fast path preserved), and asserts the bytes are correct. This test panics on the previous code and passes now.

Are there any user-facing changes?

Yes, a bug fix: ThrottledStore no longer panics when wrapping a store that returns a GetResultPayload::File (e.g. LocalFileSystem). No public API changes; throttle_get is a private function.

ThrottledStore panicked with unimplemented!() on the first get when
wrapping a store that returns a GetResultPayload::File, such as
LocalFileSystem, which is the obvious store to throttle for performance
testing. The free function throttle_get funnels every result through a
payload match whose File arm was unimplemented!().

Preserve the File fast path instead of forcing it into a byte stream:
for a File payload, apply the per-byte throttle as a single up-front
sleep computed from the known requested range (mirroring get_ranges),
then return the payload untouched. When wait_get_per_byte is zero this
is a no-op, so the common per-call-only case passes the File straight
through. Stream throttling is unchanged.

Adds a regression test wrapping a LocalFileSystem that panics on the
previous code and passes now.
@adriangb adriangb closed this Jul 18, 2026
@adriangb

Copy link
Copy Markdown
Contributor Author

I decided it was better to make a StreamingStore that wraps every File as a Stream, then ThrottledStore can wrap it.

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