Cubestore: Stop GCS list_prefix infinite collection causing OOM - #11381
Conversation
list_with_metadata_and_map() uses collect::<Vec<_>>().await.into_iter() .flatten() which silently swallows errors and never short-circuits. When Object::list_prefix() returns errors (e.g. transient GCS failure), the stream may retry indefinitely, causing unbounded memory growth. Fix: use while let + ? for immediate error propagation.
|
/bot-deep-review |
|
Claude finished @ovr's task in 6m 12s —— View job Deep review complete — the fix is correct and I'd land it. Found 0 blocking, 1 medium, 2 low, plus 3 follow-ups. Two inline comments posted.Full review — root-cause verification, caller impact analysis, findings, and follow-upsTodo list
VerdictThe change is correct, minimal, and moves GCS onto the same shape the other drivers already use. Worth noting the sibling drivers were already correct, which makes GCS the outlier this PR brings into line:
The OOM story holds together, and it's worse than the title suggestsThe per-iteration allocation on the error path is small — one I could not verify the The strongest argument for this PR isn't in the descriptionRemoving the error-swallowing closes a silent data-loss hazard, which is a bigger deal than the OOM. The chain:
On a cold router — new pod, empty volume — that means coming up with an empty metastore over a healthy remote snapshot, which subsequent uploads then overwrite. Propagating the error converts that into a clean startup failure. I'd add this to the PR description; it's the reason this is worth backporting. Caller impact: no regressions foundEvery caller either propagates with
Implementation details — checked, all fine
I was not able to run Findings1. [medium] 2. [low] 3. [low] No telemetry is emitted on the error path. With the early 4. [low] The Follow-ups — out of scope, worth tracking separatelyA. The success path is still unbounded. This PR removes the error-driven growth but not the fundamental accumulation. B. The upstream bug is still there for every other consumer. C. Testing. No test accompanies the fix. That's understandable rather than negligent: async fn collect_pages<T, F, S>(stream: S, f: F) -> Result<Vec<T>, CubeError>
where
S: Stream<Item = Result<Vec<Object>, cloud_storage::Error>>,
F: FnMut(Object) -> T + Copy,
{ /* the while-let body */ }That makes the contract unit-testable with SecurityNothing of concern. No change to authentication, credential handling, or input validation; |
…ning msg, mut f - Replace estimated page count (result.len() / 1_000) with actual counter (pages_count += 1 per page) - Fix copy-paste error: "S3 list returned" -> "GCS list returned" - Change f: F to mut f: F and use &mut f to match S3 implementation
896282b to
951fe89
Compare
|
/bot-deep-review |
|
Claude finished @ovr's task in 4m 50s —— View job Re-review after
|
| Prior finding | Status | Evidence |
|---|---|---|
[medium] pages_count estimate reports 0 for empty listings |
fixed | gcs.rs:326 let mut pages_count: i64 = 0;, gcs.rs:329 pages_count += 1; inside the loop; the result.len() / 1_000 estimate and the % 1_000 block are gone, and the now-redundant as i64 cast was dropped at gcs.rs:336 |
[medium, same edit] warning said "S3 list" inside the GCS driver |
fixed | gcs.rs:333 now reads "GCS list returned more than 100 pages" |
[low] map(f) copies the mapper per page, resetting FnMut state |
fixed | gcs.rs:317 mut f: F, gcs.rs:330 .map(&mut f) — matches s3.rs:504 |
No new inline comments this round; there's nothing actionable left that isn't either a nit or a separate follow-up.
Verdict
The while let + ? loop is the correct fix, and the metrics cleanup makes GCS the best-instrumented of the three drivers rather than the worst. The two defects the PR set out to fix are both provable from the old code independent of the upstream diagnosis: Stream::collect::<Vec<_>>() does not short-circuit on Err, and .flatten().flatten() discards Err variants outright.
Final shape, gcs.rs:314-343:
let list = Object::list_prefix(self.bucket.as_str(), prefix.as_str()).await?;
tokio::pin!(list);
let mut result = Vec::new();
let mut pages_count: i64 = 0;
while let Some(objects) = list.next().await {
let objects = objects?;
pages_count += 1;
result.extend(objects.into_iter().map(&mut f));
}Against the siblings, GCS is now the one that's converged:
| Driver | Page loop | Error handling | Page metric |
|---|---|---|---|
S3 (s3.rs:501-535) |
explicit loop over list_page |
f(obj)? per object, ? per page |
real counter |
MinIO (minio.rs:314-329) |
iter().flat_map() (SDK pre-collects pages) |
collect::<Result<Vec<_>, _>>()? |
none emitted |
| GCS (before) | collect::<Vec<_>>() |
errors silently dropped, no short-circuit | estimated from len() |
| GCS (after) | while let + ? |
? per page |
real counter |
The strongest argument for this PR still isn't in the description
Removing the error-swallowing closes a silent data-loss hazard, which matters more than the OOM. Two paths, both re-verified against the current tree:
-
Cold-start metastore clobber.
RocksStoreFs::is_remote_metadata_exists()(rocks_fs.rs:339-347) is justlist(format!("{}-current", name)).await?.len() > 0.prepare_metastore(rocks_fs.rs:432-434) gates the snapshot download on it. Under the old code a GCS auth or transient failure returnedOk(vec![])— indistinguishable from "bucket is empty" — so the check wentfalse, control fell torocks_fs.rs:462info!("Creating {} from scratch in {}", ...), and a fresh empty metastore was built withNoneas the snapshot (rocks_fs.rs:467-472). On a new pod with an empty volume that means booting empty over a healthy remote snapshot, which later uploads overwrite. Now it's a clean startup failure. -
Misattributed upload failures.
CommonRemoteFsUtils::check_upload_file(mod.rs:143-165) runs after every upload and, on an empty list, raises "File X can't be listed after upload. Either there's Cube Store cluster misconfiguration, or storage can't provide the required consistency." A swallowed GCS error produced exactly that message — pointing operators at consistency/config instead of at the real API error.mod.rs:164Err(e) => Err(e)now surfaces the actual cause.
Both are worth a line in the PR description; they're the reason this is worth backporting.
Caller impact: re-checked, no regressions
Every caller of list / list_with_metadata either ?-propagates or matches Err explicitly — I swept all 20 non-test call sites. Specifically checked for the failure mode a new early-Err could introduce (a caller that retries without backoff, turning the old OOM into a CPU spin):
remotefs/cleanup.rs:92-99— matches onErr, logs,continues. The loop sleeps at the top of each iteration (cleanup.rs:58-65), socontinuewaits outcleanup_interval. No hot retry. Previously a truncated list under-deleted orphans (a leak); now the round is skipped cleanly.remotefs/queue.rs:292— insidedownload_file'srecv()loop, but the?returns from the function rather than re-looping. No spin.metastore/rocks_fs.rs:150, 342, 352, 402, 511, 549— all?-propagating; benefit as described above.import/mod.rs:991,remotefs/mod.rs:96/143/356,sql/mod.rs:2488/2495— propagate or match explicitly.
Implementation details — verified
add_with_tagstakesi64(util/metrics.rs:76), so passing thei64counter directly and deleting the cast is right.futures::StreamExtis in scope (gcs.rs:10), so.next()resolves.tokio::pin!is correct and available.StreamExt::next()requiresSelf: Unpin; the macro shadowslistwith aPin<&mut S>, which isUnpin. It's#[macro_export]ed unconditionally, so no feature gate is involved (andtokiois onfeatures = ["full"],Cargo.toml:18, regardless)..map(&mut f)type-checks.impl<A, F: FnMut<A>> FnMut<A> for &mut Fis in core, so&mut Fsatisfies theFnMut(Object) -> Tbound. Themutonfis load-bearing for the reborrow, so nounused_mut. Reborrowing per iteration is fine — the borrow ends whenextendreturns.- One HTTP request always yields one stream item, including for a zero-match prefix (
ListState::Startalways issues a request; GCS omitsitemsand the crate maps that to an emptyVec). Sopages_count >= 1for any successful listing — which is precisely what fixes the empty-listing undercount.
Verification gap, stated plainly: I could not compile this. I wrote a standalone reproduction of the generic-mapper/reborrow/pin shape to type-check with rustc directly, but the sandbox denied both the scratch write and the rustc invocation, so the points above are analysis, not execution. The Rust workflow (run 30412178978) and Build (run 30412179037) are both still in_progress and should be the gate. I also could not read cloud-storage v0.7.1's resources/object.rs to confirm the cited line numbers — WebFetch is unavailable here and the crate isn't vendored — though the pin is confirmed (cubestore/Cargo.toml:74 declares "0.7.0", Cargo.lock resolves 0.7.1). The fix is correct whether the upstream loop is infinite or merely lossy.
Remaining nits — neither worth a change here
1. + Copy on the F bound is now vestigial — gcs.rs:320. With &mut f the mapper is reborrowed rather than copied, so Copy no longer carries weight. s3.rs:507 has the identical leftover, so keeping it is the consistent choice; dropping it in both files would be a separate tidy-up.
2. No telemetry on the error path — the early ? means a failed list emits no REMOTE_FS_OPERATION_CORE sample, where the old (broken) code always emitted one. Slightly unfortunate given the failure mode this PR fixes is "GCS listing errors." But s3.rs:516-518 behaves identically — ? on list_page returns before pages_count_app_metrics_and_logging — so GCS now matches the house pattern rather than diverging from it. Fixing it is a cross-driver change, not a GCS one.
Follow-ups — out of scope, worth tracking
A. The success path is still unbounded. This PR removes the error-driven growth, not the accumulation itself. GCSRemoteFs takes the default ExtendedRemoteFs impl (gcs.rs:296-298, under its existing // TODO: Make a faster implementation), and that default list_by_page (mod.rs:91-104) says so outright: // Note, this implementation doesn't actually paginate. It calls list() and wraps the whole result in a one-shot stream — so cleanup.rs:92's list_with_metadata("") still materialises every object in the bucket. s3.rs:454-476 has a real streaming list_by_page that GCS could be modelled on.
B. The pages_count > 100 warning is a post-mortem, not a circuit breaker. It fires after the loop drains, so during a genuine runaway (pathological repeating next_page_token, or the scale that motivated the threshold) it can't warn until the memory is already committed. Pre-existing and shared with s3.rs:542-548, but if >100k objects is an anticipated scale, warning inside the loop every N pages — or capping pages outright — would make the guard actionable. Cross-driver change.
C. Metric continuity, for whoever owns the dashboards. REMOTE_FS_OPERATION_CORE{operation:list, driver:gcs} will step up after this ships: empty listings now count 1 instead of 0, and short pages count fully instead of being floor-divided. That's the metric becoming correct, but any alert threshold calibrated on the old estimate will want a look.
D. Testing — still the highest-value addition available. No test accompanies the fix, which is understandable rather than negligent: list_with_metadata_and_map calls Object::list_prefix directly, so there's no seam to inject a stream and no way to exercise it without live GCS credentials. The cheap version is extracting the collection logic behind a Stream bound:
async fn collect_pages<T, F, S>(stream: S, mut f: F) -> Result<(Vec<T>, i64), CubeError>
where
S: Stream<Item = Result<Vec<Object>, cloud_storage::Error>>,
F: FnMut(Object) -> T,
{ /* the while-let body, returning (result, pages_count) */ }That makes the contract unit-testable with futures::stream::iter([Ok(vec![obj]), Err(..), Ok(vec![obj])]) — assert the call returns Err and that only the first two items were consumed. A test pinning "does not swallow, does not keep polling" is a genuine guard against the old shape creeping back, and it'd cover the pages_count == 1 empty-listing case too.
Security
Nothing of concern. No change to authentication, credential handling, or input validation — ensure_credentials_init (gcs.rs:23-...) and gcs_path (gcs.rs:345-354) are untouched. Net-positive for availability: it removes a resource-exhaustion path reachable from an unauthenticated-error condition, and net-positive for diagnosability, since credential failures now surface as errors instead of empty result sets.
53a3941 to
f5a28b1
Compare
|
@ovr Hello, could you help review this PR? |
|
/bot-deep-review |
|
Claude finished @ovr's task in 6m 3s —— View job Re-review: PR content unchanged since
|
| Run | Status |
|---|---|
| Rust 30928395088 | cubestore_linux (x86_64-unknown-linux-musl) failed at "Setup Rust toolchain" — curl: (7) Couldn't connect to server fetching sh.rustup.rs (job log line 357). Zero compiler diagnostics in the log. All other jobs (incl. Debian Rust nightly-2025-08-01, which is the one that actually type-checks) still in_progress. |
| Build 30928394889 | in_progress |
| Claude Code PR Review 30928396338 | failure — this bot's own workflow, not the change |
| Pull Request Automatic Labeler | success |
Verification gaps, stated plainly and unchanged from last round: rustc/cargo invocation isn't permitted in this sandbox and there's no populated target/, so I still have not compiled this — the type-level claims below are analysis. WebFetch is also unavailable, so I still could not read cloud-storage v0.7.1's resources/object.rs to confirm the cited stream::unfold line numbers. The pin is confirmed (cubestore/Cargo.toml declares "0.7.0"; Cargo.lock:1123-1126 resolves 0.7.1). The fix is correct whether the upstream loop is genuinely infinite or merely lossy.
Type-level points, re-checked by reading: futures::StreamExt is imported (gcs.rs:10) so .next() resolves; tokio::pin! shadows list with a Pin<&mut S>, satisfying next()'s Self: Unpin; &mut F implements FnMut when F: FnMut, so .map(&mut f) type-checks and the mut on f is load-bearing; add_with_tags takes i64 (util/metrics.rs:76), so dropping the as i64 cast is right.
Cross-driver state after this change
| Driver | Page loop | Error handling | Page metric |
|---|---|---|---|
S3 (s3.rs:501-535) |
explicit loop over list_page |
f(obj)? per object, ? per page |
real counter |
MinIO (minio.rs:306-330) |
SDK bucket.list() pre-collects pages |
? on the call, collect::<Result<Vec<_>,_>>()? per object |
none emitted |
| GCS (before) | collect::<Vec<_>>() |
Err dropped, no short-circuit |
estimated from len() |
| GCS (after) | while let + ? |
? per page |
real counter |
Remaining nits — neither worth changing here
+ Copyon theFbound is vestigial (gcs.rs:320). With&mut fthe mapper is reborrowed, not copied.s3.rs:507carries the identical leftover, so keeping it is the consistent choice; dropping it in both files is a separate tidy-up.- No telemetry on the error path. The early
?means a failed list emits noREMOTE_FS_OPERATION_COREsample, where the old (broken) code always emitted one — slightly unfortunate given the failure mode being fixed is GCS listing errors. Buts3.rs:516-518behaves identically (?returns beforepages_count_app_metrics_and_logging), so GCS now matches the house pattern rather than diverging. Cross-driver change if anyone wants it.
Cosmetic, take it or leave it: S3 factors the metric + warning into pages_count_app_metrics_and_logging(pages_count, log_op) (s3.rs:537-549); GCS inlines the same two things at gcs.rs:332-341. Fine for one call site.
Suggested description edit
Worth adding two lines to the PR body, since the current description undersells it and these are what a backport decision hinges on:
Beyond the OOM, the swallowed
Errmade a failed GCS listing indistinguishable from an empty bucket. That (a) letqueue.rs:292's existence check turn a transient listing failure intoCubeError::corrupt_data, whichdeactivate_table_on_corrupt_dataescalates totable_ready(table_id, false)— deactivating tables during a GCS incident; and (b) made cold-start metastore recovery skip the remote snapshot and/or its WAL logs, silently coming up empty or stale. Propagating the error converts both into a clean, diagnosable failure.Behavior change to note: a persistently unreachable/misconfigured GCS now fails router startup instead of silently starting with an empty metastore.
Follow-ups — out of scope, worth tracking separately
A. The success path is still unbounded. GCSRemoteFs takes the default ExtendedRemoteFs impl (gcs.rs:296-298, under its existing // TODO: Make a faster implementation), and that default list_by_page (remotefs/mod.rs:91-104) says so outright: // Note, this implementation doesn't actually paginate. It calls list() and wraps the whole result in a one-shot stream, so cleanup.rs:92's list_with_metadata("") still materialises every object in the bucket. s3.rs:454-487 has a real streaming list_by_page GCS could be modelled on.
B. The pages_count > 100 warning is a post-mortem, not a circuit breaker. It fires after the loop drains, so during a genuine runaway (pathological repeating next_page_token) it can't warn until the memory is already committed. Pre-existing and shared with s3.rs:542-548.
C. The upstream bug remains for every other consumer. cloud-storage 0.7.1 pulls reqwest 0.11, base64 0.13, openssl. A [patch.crates-io] fork or a move to google-cloud-storage / object_store would fix the class rather than this instance.
D. Metric continuity. REMOTE_FS_OPERATION_CORE{operation:list, driver:gcs} steps up after this ships — empty listings now count 1 instead of 0, short pages count fully instead of being floor-divided. The metric is becoming correct; alert thresholds calibrated on the old estimate want a look.
E. Testing — still the highest-value addition available. No test accompanies the fix, which is understandable rather than negligent: list_with_metadata_and_map calls Object::list_prefix directly, so there's no seam to inject a stream and no way to exercise it without live GCS credentials (gcs.rs has no #[cfg(test)] module at all). The cheap version is extracting the collection logic behind a Stream bound:
async fn collect_pages<T, F, S>(stream: S, mut f: F) -> Result<(Vec<T>, i64), CubeError>
where
S: Stream<Item = Result<Vec<Object>, cloud_storage::Error>>,
F: FnMut(Object) -> T,
{ /* the while-let body, returning (result, pages_count) */ }Then futures::stream::iter([Ok(vec![obj]), Err(..), Ok(vec![obj])]) pins the contract: returns Err, and consumes only the first two items. It'd cover the pages_count == 1 empty-listing case too. Given finding (2) above — that the old shape could deactivate tables — a regression test guarding "does not swallow, does not keep polling" earns its keep.
Security
Nothing of concern. No change to authentication, credential handling, or input validation — ensure_credentials_init (gcs.rs:24-89) and gcs_path (gcs.rs:345-354) are untouched. Net-positive on availability (removes a resource-exhaustion path reachable from an unauthenticated-error condition), on integrity (removes the table-deactivation and metastore-clobber paths above), and on diagnosability (credential failures surface as errors instead of empty result sets).
Action for the author: nothing to change in the code. Someone with write access should re-run the Rust workflow — the musl job died on a rustup download flake, so there's no green compile signal yet. The description edit above is optional but recommended.
· branch fix/cubestore-gcs-list-oom
|
Thank you, @Xuxiaotuan, for your contribution! 🍰 👍 |
When using GCS as CubeStore remote storage,
list_with_metadata_and_map()can cause Router OOM.Root cause (two layers):
Layer 1 —
cloud_storagev0.7.1 ([source](https://github.com/ThouCheese/cloud-storage-rs)):Object::list_from()usesstream::unfoldwith aListStateenum. On all 5 error paths, the state is returned unchanged instead of being advanced toDone:Compare with the success path (L427-432) which correctly advances:
Because the error paths return
stateunchanged (e.g.Start), the nextpoll_next()retries the same request → infinite error stream.Layer 2 —
list_with_metadata_and_map():collect::<Vec<_>>()does not short-circuit onErr. Combined with the infinite stream from Layer 1, this grows unbounded → OOM.Fix:
First error propagates immediately, no more infinite collection.