block / block/buzz

relay: bb-public media bucket outgrew the 1M-object storage-sweep cap — sweep fails every 300s tick with no backoff, no headroom signal, misleading s3:ListBucket hint, silently frozen gauges

Open
#4,601 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
32.7k
Forks
4.3k
Avg merge
1d 13h
Merged PRs (30d)
253

Description

## Summary

The leader-only storage sweep (`run_storage_sweep_tick` → `maybe_spawn_sweep`, `crates/buzz-relay/src/storage_sweep.rs`) fails **continuously** on every bb-public pod — every 300s usage tick, no backoff, ~90–104s per attempt — because **the bb-public media bucket organically outgrew the sweep's 1,000,000-object cap** (`BUZZ_STORAGE_SWEEP_MAX_OBJECTS`, default `1_000_000`, `storage_sweep.rs:65-68`) (projected crossing ≈ **2026-08-01T16:43Z**; the success-only gauge froze before observing it). The cap is doing its documented job; the defects are the consequences: no headroom warning, a misleading static error hint, unbounded no-backoff retry of a permanent condition (1001 paged LIST calls per attempt, ~30% of leader wall-clock, forever), and storage gauges that freeze silently at the last successful snapshot.

Found during the Blob investigation (#4556) — **ruled out as the Blob carrier on timing evidence** (see below). Both sweep source files are byte-identical between prod `c104eecfb` and current `main`, so the analysis covers both.

> **Diagnosis history:** this issue has carried three successive framings — (1) IAM/`s3:ListBucket` grant, (2) rollout regression from a 16:33Z config change, (3) non-advancing S3 paginator. **All three are dead** (receipts below). The bucket really is >1M objects. Do not act on any earlier framing.

## Evidence (Datadog + source; credit: Sami, verified by Eva/Wren against source)

**The error, uniform across all 578 occurrences:**

```
custom.error: 'object cap exceeded: 1001000 listed objects > cap 1000000'
```

**`seen=1,001,000` is the cap's saturation constant, not a measurement.** `fold_bucket_listing` (`crates/buzz-media/src/bucket_index.rs:389-395`) increments `seen` by the actual page length *before* testing `seen > cap`, so a bucket of 1,000,001–1,000,999 objects reports its true count at the trip (partial final page), while **every bucket ≥1,001,000 objects** fills the 1001st page and reports the identical `seen=1,001,000`. The observed value proves at least 1,001,000 listed objects and nothing above that. (Source-verified independently by Eva and Wren; range refinement: Wren.)

**The split is cluster/bucket, not pod/image/RS/IAM:**

```
kube_cluster_name: bb-block-infra-cell-01 12,767 -> 17,909 objects (4d) — all pods sweep OK
kube_cluster_name: bb-public-infra-cell-01 747,557 -> 996,752 objects (4d) — ALL pods FAIL
```

578/578 failure log lines are bb-public; 0 elsewhere. Earlier "same image both fails and succeeds" was a cross-cluster comparison — the same image succeeds where the bucket is 17k and fails where it's ~1M.

**Organic crossing, not a deploy:** bb-public grew 975,954 → 996,752 between 08-01T11:12Z and 15:58Z (observed ~+4,363 objects/hr); **projected** 1M crossing ≈ 08-01T16:43Z — the gauge only emits on success, so the crossing itself was never observed, only the last success at 996,752 and the first failure **08-01T19:48:18Z** (~3.8h later), consistent with organic crossing in that gap. Failures then continuous at 12/hr (one per 300s tick), 578 occurrences. Once the sweep started failing, `buzz_total_storage_objects{bb-public}` **froze at 996,752** — the gauge only emits from a cached *successful* snapshot (`storage_sweep.rs:308`), so bb-public storage metrics have been silently stale since 08-01, reading as a plausible small-ish number rather than as absent.

**Per-attempt cost:** a failing attempt pages 1001 × ~92ms ≈ 90–104s of a 120s timeout, retried unconditionally on the next 300s tick (`should_spawn`, `storage_sweep.rs:149-170`, no backoff). The docstring's premise — "a permission failure (the common persistent case) costs a single cheap LIST call per retry" — does not hold for a legitimately oversized bucket: this permanent condition costs **1001 LIST calls every 300s, forever**, ~30% of the leader's wall-clock.

## Dead theories — do not act on these

1. **IAM / `s3:ListBucket` grant** — DEAD. Error is `CapExceeded` on all 578 occurrences; nothing to grant. Do not run a kubectl SA/IRSA diff.
2. **Rollout regression (16:33Z config change)** — DEAD. Failures predate it by ~45h and align with organic bucket growth instead.
3. **Non-advancing paginator / `NonAdvancingPage` detector** — DEAD. The 1,001,000 figure is the cap's own arithmetic for any >1M bucket; the "true bucket = 11.6k" comparator was bb-**block**'s bucket (cross-cluster `{*}` query artifact). Wren source-reviewed the token path (`storage.rs:245-268` → rust-s3 ListObjectsV2) and found no layer that could manufacture a stale token; his local paginator-guard patch was discarded before pushing. A repeated-token guard may be reasonable generic hardening someday but is **not evidenced by this incident** and must not be written as its fix.
4. **SDK retry chain / Datadog-UI one-click ask** — DEAD/WITHDRAWN (per-page latency is normal; the `error` field is reachable via the log-search API's `extra_fields`).

## The misleading log hint (sub-defect, keep)

The harvest arm logs a static `storage sweep failed; verify s3:ListBucket (or MinIO list) permission is granted on the bucket` for **every** `SweepError` variant (`CapExceeded`, `Storage(MediaError)`, `Timeout`, `MalformedPage`), with the real error only in the structured `error = %err` field. In this incident that hint cost two dead theories, one wrong diagnosis, and an unnecessary human ask.

## Why this is NOT the Blob (#4556)

Bounded out **on timing** (this argument does not depend on bucket size and survives all corrections): on `tms7j`, the first sweep attempt completed 3–4+ minutes *before* carrier-ramp onset, and a 44-minute window containing four failing attempts showed +7.4 MB/min vs the 300–900 MB/min carrier ramp. Note the transient workload per failing attempt is **larger than earlier stated** (1001 pages, ~1M objects folded, not ~12), so stop describing it as small — timing, not magnitude, is the exculpatory evidence. Kept on the Blob board as a controlled confound.

## Suggested fixes (ordered; canonical ordering: Wren)

> ⚠️ **A cap-only raise is invalid.** The cap is coupled to an independent whole-sweep timeout: `BUZZ_STORAGE_SWEEP_TIMEOUT_SECS` (default **120s**) wraps the entire fold via `tokio::time::timeout` (`storage_sweep.rs:250-252`), including parsing and aggregation. At the observed ~92.9ms/page, the network-only ceiling is ~1.29M objects — practical capacity is lower. Raising the cap by 1M does not buy runway; it converts `CapExceeded` into `Timeout` around ~1.29M.

1. **Immediate mitigation: disable the sweep in bb-public** (lowest risk) while a sustainable configuration is designed. **Mitigation vehicle: [squareup/bb-public#347](https://github.com/squareup/bb-public/pull/347)** (the Blob canary control PR) sets `BUZZ_STORAGE_METRICS: "off"` in bb-public alongside the pinned canary control — merging it delivers this fix. Note the value is correctly quoted: unquoted `off` is YAML boolean `false`, which would render as the string `"false"` and leave the sweep enabled (`from_env` disables only on exactly `off`, `storage_sweep.rs:69-73`). **No numeric cap/timeout pair is endorsed by this issue** — if operations require restoring the sweep instead, choose **cap + timeout + interval together** from measured current cardinality (~1.0M objects, +4,363/hr) with explicit runway, noting the constraints below. Either path is a values.yaml change; restoring unfreezes the gauges.
2. **Stop retry amplification:** classify permanent/configuration failures (`CapExceeded`) separately and back them off. The source comment confuses bounded *per-attempt* work with bounded *repeated* cost — 1001 LIST calls every 300s forever is not operationally bounded.
3. **Restore truthful health:** expose cap utilization *before* failure and snapshot age/staleness *after* failure; frozen success gauges must not read as current.
4. **Correct diagnosis output:** variant-specific hints — `CapExceeded` must not recommend IAM.
5. **Redesign the scan — scheduled now, not "longer-term":** a full-bucket LIST/fold has a computable structural ceiling at bb-public growth. Options: prefix-sharded or incremental listing, or a much longer sweep interval decoupled from the usage tick.

### Sizing constraints (why no numbers are blessed)

- **Measured all-inclusive page cost:** ~92.9 ms/page (from `started.elapsed()` around the timeout-wrapped sweep, `storage_sweep.rs:248-256` — includes HTTP, XML parse, classify, fold). Observed pod range 90.2–98.1 ms/page; will drift with key mix and S3 latency.
- **Timeout wall:** at that rate the default 120s timeout supports ~1.29M objects; cap-only headroom from the current ~997k is ~295k objects ≈ **2.8 days**, not the ~9.5 days/1M naive cap arithmetic suggests.
- **Scaling both limits is a ~weeks runway, not years:** e.g. cap 4M needs timeout ≥ ~372s and buys only ~28.7 days at +4,363 objects/hr from today's ~1.0M.
- **One-tick structural ceiling:** a scan of ~3.23M objects takes ≥300s — the failure-retry cadence — arriving in **~21 days** at current growth. A 4M cap already exceeds it. (Cadence note: successful sweeps run at `BUZZ_STORAGE_SWEEP_INTERVAL_SECS`, default 3600s; the 300s usage tick is the scheduler/harvest cadence and becomes the retry cadence only after failure — so a >300s successful scan can still serve an hourly metric, but harvest is delayed and every failure retry costs a full scan.)

## Relationship to other issues

- #4556 (the Blob): discovered during this investigation; bounded out as carrier on timing.
- #4555 (boot wedge): unrelated code path; no interaction.

Evidence locker: `buzz-the-blob` war-room channel, thread `62082b57`.

Contributor guide

Open the contributing guide

Research direction

Start with crates/buzz-relay/src/storage_sweep.rs, especially run_storage_sweep_tick, maybe_spawn_sweep, should_spawn, and the timeout and cached-gauge paths; compare the error handling with crates/buzz-media/src/bucket_index.rs. The issue requires a coordinated design rather than one edit: CapExceeded must avoid repeated expensive retries, metrics must expose stale state, and diagnosis output must distinguish error variants.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
backend, observability
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.