Cubestore: Stop GCS list_prefix infinite collection causing OOM - #11381
Cubestore: Stop GCS list_prefix infinite collection causing OOM#11381Xuxiaotuan wants to merge 8 commits into
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
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.