Skip to content

object-store: provider-neutral seam + native Google Cloud Storage provider with generation CAS - #6762

Open
NateJowett wants to merge 7 commits into
block:mainfrom
NateJowett:mozart/gcs-object-store
Open

object-store: provider-neutral seam + native Google Cloud Storage provider with generation CAS#6762
NateJowett wants to merge 7 commits into
block:mainfrom
NateJowett:mozart/gcs-object-store

Conversation

@NateJowett

@NateJowett NateJowett commented Aug 25, 2026

Copy link
Copy Markdown

Why

Buzz Git publication requires an exact compare-and-swap boundary. Google Cloud Storage S3 interoperability accepts If-Match on PUT but does not provide the native generation semantics this protocol needs, so this change adds a native GCS adapter instead of routing GCS through S3 compatibility.

Provider-neutral architecture

buzz-object-store::ObjectStore is the only storage interface consumed by media, Git, and deletion behavior. Provider construction is confined to process composition roots:

  • BUZZ_OBJECT_STORE_PROVIDER=gcs selects native GCS and requires BUZZ_OBJECT_STORE_BUCKET.
  • BUZZ_OBJECT_STORE_PROVIDER=s3 selects the existing S3 adapter and reads BUZZ_S3_* settings.
  • Inactive-provider variables are never parsed; stale S3 settings cannot break a GCS deployment.
  • GCS uses Application Default Credentials. S3 uses its existing static/interoperability or AWS credential-chain behavior.
  • Media and Git share one constructed provider per relay process. The standalone deletion process independently selects the same provider through the same deployment contract.

Domain code no longer constructs S3 clients or imports provider types. MediaStorage::new and GitStore::from_s3_config are removed; the provider-neutral with_store / new(Arc<dyn ObjectStore>) facades remain.

Safety semantics

The seam includes buffered and streaming transfer, range reads, listing, bulk deletion, immutable writes, exact-revision conditional writes, and provider-neutral exact-version enumeration/deletion.

  • GCS maps native generations to opaque revision/version tokens, uses ifGenerationMatch=0 for create-only writes, and uses the observed generation for CAS and exact deletion.
  • S3 maps ETags and version IDs behind the same contract.
  • Cross-provider, invalid, or sentinel tokens fail closed before a backend mutation.
  • GCS bucket admission fails closed unless object versioning is off and soft-delete retention is zero, preserving Buzz deletion semantics.
  • Classified conflicts, throttles, transient failures, terminal failures, and ambiguous transport outcomes remain distinct.
  • The GCS conformance profile paces same-key races for the provider limit while still failing on two winners, missing generations, stale-generation acceptance, or an unproven race.

Portability and cutover

GCS is a deployment choice, not a key-layout or schema choice. The S3 adapter remains first-class for a future AWS deployment. docs/git-on-object-storage.md records the cutover proof:

  1. provision the target bucket with the deletion contract satisfied and run its conformance profile;
  2. drain writers;
  3. copy every key and byte;
  4. prove source/target key-stream digests, object counts, and byte totals;
  5. switch provider configuration and prove media reads, Git hydrate/clone, CAS publication, version enumeration, and exact deletion;
  6. retain the source read-only for rollback. Reverse-copy any target delta before rollback.

Bucket names are configuration and never appear in object keys or domain records. No AWS resources or production object copy are part of this PR.

Evidence

Local deterministic gates on the refreshed upstream base:

  • buzz-object-store: 47/47 unit tests passed.
  • buzz-media: 113/113 unit tests passed.
  • buzz-deletion: 17 passed, 9 integration tests ignored.
  • relay Git conformance profile: 13/13 passed.
  • relay configuration: 48/48 passed, including inactive-provider isolation.
  • all object-store integration targets compile.
  • clippy with -D warnings passed for object-store, media, deletion, relay, and test-client, all targets.
  • formatting and diff checks passed.
  • live S3/MinIO proof: 7/7 passed across media round-trip, never-versioned and versioned exact deletion, Git CAS/revision round-trip, and the 8 × 2 startup conformance race with zero double winners.

Live native-GCS proof against a disposable, isolated test bucket: 12/12 passed. This covers bucket admission/refusal, exact-generation listing and deletion, foreign-token refusal, idempotent create, CAS with exactly one winner, full/range/stream agreement, prefix pagination, idempotent deletion outcomes, a 96 MiB resumable upload/stream round trip, and rapid same-key transitions paced rather than failed.

The broader relay suite reached 1001 passing and 91 ignored; 6 database-backed media tests could not acquire the absent local Postgres pool (Sqlx(PoolTimedOut)). The storage-specific and affected suites above are green.

Operational constraint

GCS has a per-object-name write ceiling, so one repository manifest pointer cannot sustain arbitrary publication frequency. Distinct repositories use distinct pointer names and remain independently concurrent. The provider-specific startup profile validates this behavior before the relay serves traffic.

@NateJowett
NateJowett force-pushed the mozart/gcs-object-store branch 2 times, most recently from b9ccfb0 to e16c8c7 Compare August 31, 2026 12:29
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is bc006f67087b049e2f9c4d8a2f26faceff628225...c91702dfbc4d1acdb49fef08f1d75b7de25793b1.
A new review must complete for this exact range. When manual authorization
is required, a Block organization member must comment exactly
@buzz-security-review c91702dfbc4d1acdb49fef08f1d75b7de25793b1 to authorize a new review.
Any previous review applies only to its recorded range.

@NateJowett
NateJowett marked this pull request as ready for review August 31, 2026 12:32
@NateJowett
NateJowett requested a review from a team as a code owner August 31, 2026 12:32
Buzz keeps media blobs and the content-addressed Git object store on one
bucket, but each held its own rust-s3 client and spoke in S3-shaped
vocabulary: ETags, If-Match, If-None-Match: *. That made the storage
provider a property of every call site rather than a property of the
deployment, and left no place to add a second provider.

Add `buzz-object-store`, which owns the seam:

- `ObjectStore` — exactly the operations Buzz performs, no more: buffered
  and streaming upload, full/bounded/range/streaming read, head, create-only
  and revision-matched conditional writes, paginated prefix listing, single
  and bulk delete, connectivity and versioning admission checks.
- `Revision` / `WriteCondition` / `ConditionalWrite` / `ImmutableWrite` —
  provider-safe types, so a compare-and-swap token is never a bare string
  with implied semantics. A revision minted by one provider cannot predicate
  a write against another; the accessor rejects it.
- `ObjectStoreError` — an explicit taxonomy separating a *classified*
  provider answer (not found, precondition failed, throttled, transient,
  permanent) from an *unknown* transport outcome. That distinction is load
  bearing: the Git conformance probe drops unknown outcomes from its
  observer set rather than counting them as a lost race, so
  `TransportAmbiguous` stays reserved for pre-classification failures.
- `ProviderKind`, so a deployment names its provider once.

No behavior change: this commit adds the crate, its unit tests, and
registers it in the workspace. The domain facades move onto it next.

Signed-off-by: mozarthq <nate@mozarthq.com>
`MediaStorage` and `GitStore` were each a concrete `rust-s3` wrapper. They
built their own `Bucket`, matched on `S3Error`, and carried S3 vocabulary
through domain code. Move all of that into `providers::s3` — now the only
module in the tree that knows what an ETag is — and leave behind the parts
that are genuinely domain logic.

Media keeps its content-addressed blob keys, the community-scoped metadata
sidecar that gates tenant reads, and its error surface. `MediaStorage::new`
still takes a `MediaConfig` and builds the S3 provider, so every existing
caller is unchanged; `MediaStorage::with_store` wraps an already-constructed
provider and `object_store()` hands it out, which lets the relay build one
client per process instead of two against one bucket. `S3AddressingStyle`
moves to the object-store crate — it configures the provider, not the media
domain — and is re-exported from `buzz_media::config` so its parsing tests
are untouched.

Git keeps its content-addressed keys, digest verification, the idx sidecar
layout, and the conformance probe. `ETag` becomes `Revision`, `Precond`
becomes `WriteCondition`, and `CasOutcome` becomes `ConditionalWrite`.
`StoreError::Backend` wraps `ObjectStoreError`, and the `From` impl lifts
not-found / too-large / digest-mismatch into their own variants so every
existing call site keeps matching on them directly.

Probe semantics are unchanged. Its drop-and-floor rule — a racer that never
got a classified provider response is dropped from the observer set rather
than counted as a lost race — previously keyed off `S3Error::{Reqwest, Http,
Io}` and now keys off `ObjectStoreError::is_ambiguous`, which the S3 provider
maps from exactly those three variants. Everything else stays a classified
observation that fails the probe closed.

Behavior is preserved, including the paths that historically surfaced a
backend 404 as a generic storage failure rather than a media `NotFound`.
Neither `buzz-media` nor `buzz-relay` depends on `rust-s3` any more.

Signed-off-by: mozarthq <nate@mozarthq.com>
Cloud Storage's S3-interoperability endpoint accepts `If-Match` on PUT and
then ignores it. Under the Git conformance probe that is not a degraded
result but a semantic compare-and-swap violation: two racers predicating a
write on the same revision both reported a commit, so the last writer
silently destroyed the other's pointer update. Nothing above the provider
can recover a lost update it was told did not happen, so Cloud Storage needs
a provider that uses the native API, where the precondition is enforced.

Add `providers::gcs`, implementing the seam against the official
`google-cloud-storage` client with Application Default Credentials. No key
file, HMAC pair, or S3 interoperability path is involved.

Compare-and-swap uses native object generations rather than ETags:
`ifGenerationMatch=0` is the create-only precondition and
`ifGenerationMatch=<g>` the revision-matched replace. A stale precondition
returns HTTP 412 and is reported as an ordinary `ConditionalWrite::Conflict`,
and a pointer read takes its body and its generation from the same response.

Three details are worth calling out:

- **Bucket contract at construction.** The S3 provider detects versioning by
  writing a probe object and looking for a version id. That heuristic is
  meaningless on Cloud Storage, where every object carries a generation
  whether or not old ones are retained, so this provider reads bucket
  metadata instead and fails closed unless object versioning is off and
  soft-delete retention is zero. Either setting would leave a restorable copy
  behind after a delete, so a deletion could otherwise report success while
  the bytes stayed reachable. Checking at construction also catches
  configuration drift on every boot.

- **Retries are owned here.** The client's own retry loop is disabled and
  this module runs one bounded policy: capped exponential backoff with equal
  jitter, honouring `Retry-After`, drawing on a throttle budget separate from
  the transient-error budget so pacing cannot consume the allowance for real
  failures. An exhausted budget surfaces as `Throttled`, reaching the caller
  as backpressure rather than as a lost race. A conditional write is only
  ever retried carrying its exact original precondition. `put_file` is the
  one exception, delegating to the client's resumable upload retry so a large
  media blob resumes instead of restarting.

- **Ambiguity is resolved by rereading, not guessing.** A 412 arriving after
  an attempt that never got a classified answer could be another writer's
  commit or this writer's own. The object is reread and its body decides.

`ProviderSelection`/`ObjectStoreConfig` and an async `connect()` let a
deployment select its provider once, in configuration.
`BUZZ_OBJECT_STORE_PROVIDER` chooses it in the relay, defaulting to `s3` so
an existing deployment is unaffected. Selecting `gcs` requires
`BUZZ_OBJECT_STORE_BUCKET` and deliberately does not fall back to
`BUZZ_S3_BUCKET`: a Cloud Storage deployment carries no `BUZZ_S3_*` values at
all, and a default bucket name would address storage nobody configured.
Startup connects through the seam, so a provider's admission checks run
before the relay serves traffic — a bucket whose configuration would break
deletion fails the boot rather than the first delete.

Signed-off-by: mozarthq <nate@mozarthq.com>
The conformance probe's S3 profile races 32 writers against one object name,
three rounds over, as fast as it can. Cloud Storage publishes a roughly
one-write-per-second ceiling per object name, so that shape would have spent
the whole probe being throttled and then read the resulting throttles as
conformance evidence — a gate that passes because the backend refused to
answer is not a gate.

Add a second profile the provider's own characteristics justify: a narrow
race of 3 writers, 2 rounds, with same-key rounds spaced past the published
ceiling (>1 s). It proves the same properties the S3 profile does, on a
backend that must be paced to prove anything:

- body and generation read from one response and checked against what was
  just committed;
- a compare-and-swap on the observed generation, which must report a
  different one;
- a replay of the superseded generation, which must conflict;
- a narrow race on one generation, repeated;
- the winning generation predicating the next successful write.

Three rules keep pacing from becoming leniency. Two committed racers is
always fatal. A commit reported with no generation is fatal wherever it
appears — the caller would have nothing to predicate its next write on, and
dropping the precondition is a blind overwrite. And a round that proves
nothing, because every racer was throttled or too few were classified to
witness a race, is re-run within a bounded budget rather than scored;
exhausting the budget fails the probe. `Throttled` is never counted as a lost
race. Conflicts with no acknowledged winner stay fatal, since that is a lost
update announcing itself.

Startup previously built the probe config from hardcoded S3 defaults, so a
Cloud Storage deployment would have run the wrong gate. The defaults now come
from the provider the process actually connected to, and the two environment
overrides continue to apply on top for either profile. The gate stays enabled
and fatal; only the shape of the evidence changed.

The report and the admission log line carry the profile, the throttle and
re-run counts, the shortest same-key interval actually observed, and any
probe object cleanup could not remove — so a backend that is degrading is
visible while it is still passing. Probe objects are deleted on the success
and failure paths alike: a failed probe is the one that gets re-run.

Tests drive a scripted store through the seam, because a live bucket cannot
be asked to commit two writers on one generation — covering two winners, an
all-throttled round re-run and then bounded, a mixed conflict/throttle round,
a commit with no generation on both the create and the race path, a store
that honours a stale generation, a round where everyone loses, and the
measured spacing.

Signed-off-by: mozarthq <nate@mozarthq.com>
…profiles

Unit tests prove how the code judges answers; only a real bucket proves which
answers Cloud Storage actually gives. Add the suites that close that gap, all
`#[ignore]`d and additionally gated on `BUZZ_GCS_LIVE=1` so a bare `--ignored`
run without credentials skips rather than fails. Credentials come from
Application Default Credentials, exactly as in production, and every test
works under its own uuid-scoped namespace and asserts its own cleanup emptied
it.

- `tests/gcs_live.rs` — provider conformance against a live bucket: bucket
  admission accepted and refused, permission denied refusing to hand out a
  client, foreign revisions never reaching the backend, create-only
  idempotence, compare-and-swap admitting exactly one writer, reads agreeing
  across full/range/stream, large objects streaming through a resumable
  upload, prefix pagination, idempotent deletion with per-key outcomes, and
  rapid same-key transitions being paced rather than failed.

- `tests/gcs_scale.rs` — store-level scale evidence, additionally gated on
  `BUZZ_GCS_SCALE=1`: a repeated hot-pointer race that must never admit two
  winners, concurrent creates across distinct names that must all commit, and
  a sustained mixed read/write/list load reporting its latency percentiles and
  outcome mix. Each reports its numbers so a regression in throttling or
  contention behaviour is legible rather than merely non-fatal.

- `api::git::store::gcs_live` — the deployment gate itself, run against the
  provider it was written for: the profile admitting a real bucket, the
  pointer cycle publishing exactly one state, content-addressed objects round
  tripping with corruption detected, and a chunked seed committing every
  sequential transition.

Also documents the two conformance profiles and the provider-neutral
implementation vocabulary in `docs/git-on-object-storage.md`, so the reason
the Cloud Storage profile is shaped differently is recorded next to the
design it belongs to rather than only in the probe source.

Signed-off-by: mozarthq <nate@mozarthq.com>
Keep media, Git, and deletion domain behavior behind ObjectStore while relay and standalone process roots select GCS or S3. Add provider-neutral exact-version deletion, fail-closed provider tokens, and an explicit GCS-to-S3 cutover proof.

Signed-off-by: mozarthq <nate@mozarthq.com>
Signed-off-by: mozarthq <nate@mozarthq.com>
@NateJowett
NateJowett force-pushed the mozart/gcs-object-store branch from 465b4ce to c91702d Compare August 31, 2026 17:34
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.

2 participants