rfc: 0013 title: Object-storage backend (S3-compatible) for the Parquet store status: green author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-06-15 supersedes: — superseded-by: —
RFC 0013 — Object-storage backend (S3-compatible) for the Parquet store
Status note.
green(2026-06-17;red2026-06-15). The first shipping-milestone spine: the writer/reader/compactor/audit-sink addressed a single local filesystembucket_root: &Path, butCLAUDE.md§3.6 declares object storage the source of truth. This RFC abstracts the storage seam behind the Apacheobject_storecrate (already in our tree via DataFusion) so the RFC 0005 data + audit Parquet and the RFC 0009 manifest live on an S3-compatible bucket in production and on local disk in dev/test — without changing the on-disk layout or a single stored row.All eight §5 scenarios pass. The S3-backed scenarios (RFC0013.1/.3/.4/.7) run in the
s3-integrationCI job againstLocalStack(testcontainers); the local-backend and tenant scenarios (.2/.5) and the reader forward-compat scenario (.8, via the colocated RFC 0005 reader tests) run in the defaultcargo test; and RFC0013.6 (WAL stays local) is greened end to end through the served binary inourios-server(tests/rfc0013_6_wal_stays_local.rs), which wires theStoreinto the RFC 0014 data write path and asserts only Parquet/manifest objects reach the store while the WAL*.walsegments stay on local disk. The crate-shape open question resolved to a module, not a new crate (§3.7).Landed across
green: the S3 backend (object_storeawsfeature) + conditional-PUT atomic publish (Manifest::publish_cas, RFC0013.3/.4); the writer/reader/compaction/audit consumers migrated frombucket_root: &PathontoStore; and the §7 questions (conditional-PUT portability, credentials via theobject_storechain, endpoint override) decided. Deferred to their own follow-ups (not RFC0013 acceptance): a single-writer lease, the multipart-upload threshold, and a read cache.
1. Summary
Ourios’s storage layer addresses a single local-filesystem
bucket_root: &Path, threaded through ourios-parquet (writer, reader,
compaction,
manifest, audit sink) and ourios-server. CLAUDE.md §3.6 makes object
storage — “Parquet on S3” — the source of truth, with local disk only a
cache and the WAL horizon. This RFC introduces an object-storage backend
behind that seam by adopting the Apache Arrow object_store crate (one
trait over LocalFileSystem and AmazonS3/S3-compatible stores), so the
same code path targets local disk for dev/test/CI and an S3-compatible
bucket in production. It pins how the RFC 0009 atomic-publish (manifest
generation swap) maps onto object stores that lack POSIX rename
(discharging RFC 0009 §7’s deferred S3 atomic-swap + single-writer lease).
It changes where bytes live, never the RFC 0005 schema or the Parquet
bytes themselves.
2. Motivation
2.1 §3.6 is currently unmet — and it gates deployment
CLAUDE.md §3.6: “Local disk is cache and WAL. Parquet on S3 is the truth.
Never design a feature that requires local disk to be durable beyond the WAL
horizon.” Today the store is local-filesystem only — no first-party
object_store/S3 usage in our Rust source (it is present only transitively,
via DataFusion; §2.2); every consumer takes a &Path bucket root. That is
fine for the thesis-proving MVP (which ran on single hosts), but it makes
Ourios undeployable to a cluster: pods are ephemeral, and acknowledged data
must outlive any one node. Durable shared object storage is the spine of the
first shipping milestone; nothing else (container image, Helm chart) matters
if the data evaporates with the pod.
2.2 Why at this layer, and why object_store
The seam is narrow and already uniform: a bucket_root: &Path (plus the
per-partition Hive key layout from RFC 0005 §3.4) threaded through the
writer, reader, compact_partition, the manifest, and the audit sink.
Abstracting it once behind a backend handle leaves every consumer’s
logic unchanged. The natural abstraction is the Apache Arrow
object_store crate — and it is already in our dependency tree
(v0.13.2) transitively via DataFusion, our query engine (pillar #3).
DataFusion’s own table providers read through object_store, so adopting it
also aligns the read path: the querier can register the same
ObjectStore with DataFusion instead of handing it local file paths. One
abstraction, used end to end, that we already ship.
2.3 Why now
The thesis is proven (B1/B2/C1/C2 pass on baseline; benchmarks.md §9.4/§9.6) and
the RFC ladder is green-or-beyond. The next milestone is deployability,
and this is its load-bearing, architectural-pillar-level prerequisite —
hence an RFC (CLAUDE.md §5.1) rather than a PR.
3. Proposed design
3.1 Scope
In scope: the read and write of the RFC 0005 data + audit Parquet series
and the RFC 0009 manifest.json through an object-storage backend, with
two concrete backends — LocalFileSystem (dev/test/CI; preserves today’s
behaviour) and AmazonS3 (production; covers S3-compatible stores — MinIO,
Cloudflare R2, etc. — via an endpoint override). Out of scope: the WAL
(stays local — it is the §3.4 durability horizon, §3.5 below); any change
to the RFC 0005 on-disk schema or Parquet encoding; a table format
(Iceberg/Delta — rejected in RFC 0005 §4.1); a local read cache (a future
perf concern, §7).
3.2 The object_store abstraction
Adopt object_store::ObjectStore (async put/get/list/delete over an
object_store::path::Path — a UTF-8, /-delimited key). The RFC 0005 Hive
layout (data/tenant_id=…/year=…/…/<uuid>.parquet,
audit/tenant_id=…/…) maps directly onto object keys under a configured
prefix — no layout change. A thin Store handle wraps an
Arc<dyn ObjectStore> + a key prefix and is threaded where
bucket_root: &Path is
today.
flowchart LR
subgraph consumers [ourios-parquet / ourios-server consumers]
W[Writer] & R[Reader] & C[compact_partition] & A[ParquetAuditSink]
end
consumers --> S[Store handle\nan object_store handle + key prefix]
S --> L[LocalFileSystem\ndev / test / CI]
S --> O[AmazonS3 / S3-compatible\nproduction]
Q[ourios-querier] -. registers same store .-> DF[DataFusion] --> S
3.3 The seam migration
Replace bucket_root: &Path with the Store handle across the writer,
reader, compact_partition, manifest, and audit sink. Writes go to a
temporary key and become live via the §3.4 publish; reads are list +
get by key. The change is mechanical and consumer-logic-preserving — the
existing RFC 0005/0009 tests re-run against the LocalFileSystem backend to
prove no behavioural regression (RFC0013.2).
3.4 Atomic publish without POSIX rename
The hard part. RFC 0009’s atomic publish and the writer’s
.parquet.tmp→final both rely on POSIX rename, which object stores do not
provide. Map the manifest generation swap onto object stores via
conditional PUT: S3 now supports If-None-Match (create-if-absent) and
If-Match (compare-and-swap on ETag), surfaced by object_store as
PutMode::Create / PutMode::Update{ETag}. A new manifest generation is
written with a precondition on the current generation, giving
single-writer-wins semantics — exactly the property compact_partition
needs so a query never double-counts or misses a row. Data/audit objects are
written to a _tmp/ key and made live solely by the manifest swap (no
rename). The RFC 0009 §7 single-writer lease (so two compactors don’t
race a partition) is realised by the same conditional-PUT contention or a
dedicated lease object (§7 open question).
3.5 What stays local
The WAL stays on local disk: it is the §3.4 WAL-before-ack durability horizon, and §3.6 explicitly permits local disk up to that horizon. Recovery (RFC 0008) replays the local WAL into the object store on startup. No feature introduced here requires local disk to be durable beyond the WAL horizon, so the §3.6 invariant holds.
3.6 Schema / compatibility
No change to the RFC 0005 schema, the Parquet bytes, the partition layout, or the reader’s §3.9 forward-compat contract. This RFC is purely about where the bytes are stored; an operator’s existing data semantics are untouched.
3.7 Crate shape — resolved: a module in ourios-parquet
The backend is a store module in ourios-parquet (not a new crate),
exposing a Store type. Resolved at red against the less-committing
option (CLAUDE.md §7: a new crate is an architectural commitment): the
dependency graph confirms it — ourios-querier, -ingester, and -server
already depend on ourios-parquet, so the type is visible to every storage
consumer without a new crate. If a future consumer needs the store without
the Parquet writer/reader, extracting an ourios-store crate is a
mechanical follow-up. Configuration (endpoint, region, bucket, prefix,
credentials) flows through RFC 0004.
4. Alternatives considered
- Hand-rolled
aws-sdk-s3client. Direct control, but reimplements whatobject_storealready gives — multi-backend, retries, multipart, and the local/test backend — and diverges from DataFusion’s own storage layer. Rejected: more code, less reuse, two storage abstractions in one tree. - Network filesystem (EFS/NFS) mounted into pods, keep
&Path. Avoids the S3 work, but violates the §3.6 “S3 is the truth” pillar, inherits NFS’s diceyrename/close-to-open consistency, and is operationally worse and costlier than object storage at log volumes. Rejected. - An object-store-as-filesystem shim (s3fs/goofys). Keeps the
&Pathcode, but inherits non-atomicrenameand read-after-write pitfalls — the exact correctness hazards §3.4 must avoid. Rejected. - A table format (Iceberg/Delta) on object storage. Already rejected in RFC 0005 §4.1 (the manifest in RFC 0009 is the minimal piece we actually need). Out of scope; not reopened here.
5. Acceptance criteria
Normative scenarios in the docs/rfcs/README.md Given/When/Then/And format;
each id is referenced from the test code. Refined at specified once the
backend trait shape (§7) is fixed, but the scenarios below are the binding
contract.
RFC0013.1 — Round-trip through the S3 backend
- Given a
MinedRecordbatch covering every RFC 0005 §3.2 column- When it is written and then read back through the
AmazonS3backend (a MinIO / localstack container via thetestcontainerscrate)- Then the recovered rows and Parquet bytes equal those from the
LocalFileSystembackend, byte for byte.
RFC0013.2 — Local backend regresses nothing
- Given the existing RFC 0005 and RFC 0009 acceptance suites
- When they run against the
LocalFileSystembackend after the seam refactor- Then every one passes unchanged (the abstraction is behaviour- preserving for the local case).
RFC0013.3 — Atomic publish under contention
- Given two
compact_partitionruns racing on one partition’s manifest- When both attempt to publish a new generation
- Then exactly one wins; no query observes a torn, doubled, or missing row; and the loser either retries against the new generation or no-ops.
RFC0013.4 — Manifest swap needs no
rename
- Given an object store with no POSIX
rename- When a generation is published
- Then it uses conditional PUT — create-if-absent (
If-None-Match) and compare-and-swap (If-Match) — with norenamedependency anywhere on the publish path.
RFC0013.5 — Tenant isolation across the key prefix
- Given data + audit objects for tenants X and Y under the configured prefix
- When an operation runs in tenant X’s context
- Then it addresses only X’s key sub-prefix; no read or write touches Y’s keys (
CLAUDE.md§3.7).
RFC0013.6 — WAL stays local
- Given a server configured with an object-storage backend
- When it ingests and acknowledges a batch
- Then only the RFC 0005 data/audit Parquet and the RFC 0009 manifest reach the object store; the WAL remains on local disk (the §3.4 durability horizon is unchanged).
RFC0013.7 — S3-compatible endpoints via override
- Given an S3-compatible store (e.g. MinIO) configured through RFC 0004 with an endpoint override
- When the backend reads and writes
- Then it works against that endpoint exactly as against AWS S3.
RFC0013.8 — Reader forward-compat over the object store
- Given objects written by an older/newer schema (absent / unknown columns, per RFC 0005 §3.9)
- When the current reader reads them through the object-storage backend
- Then the §3.9 contract holds (absent columns default, unknown columns ignored) — no error.
6. Testing strategy
Mapped to CLAUDE.md §6.2. The LocalFileSystem backend keeps the current
fast, corpus-free unit/proptest path; the AmazonS3 backend is exercised
against a MinIO/localstack container (testcontainers, which supports any
OCI runtime — Docker on the CI runner, nerdctl/containerd or Podman
locally). Per scenario:
- RFC0013.1 / .7 / .8 (S3 round-trip / S3-compatible endpoint / reader forward-compat over the store) — integration tests against the S3 container, reusing the RFC 0005 round-trip and §3.9 fixtures behind the backend trait.
- RFC0013.2 (local backend regresses nothing) — the existing RFC 0005 +
RFC 0009 suites re-run against
LocalFileSystemvia a parametrised harness (one suite, two backends). - RFC0013.3 (atomic publish under contention) — a
proptest/ concurrency test driving N racing publishers at the S3 container, asserting exactly-one-wins and no torn / doubled / missing rows. - RFC0013.4 (manifest swap, no
rename) — integration test asserting the publish path uses conditional PUT (PutMode::Create/PutMode::Update{ETag}) and never arename. - RFC0013.5 (tenant isolation) — integration test interleaving two tenants’ objects under the prefix; asserts no cross-prefix access.
- RFC0013.6 (WAL stays local) — after ingest + ack, assert WAL frames are on local disk and only data/audit/manifest objects reached the store.
The S3 integration lane is #[ignore] / feature-gated, so the default
cargo test stays container-free; CI runs it explicitly.
7. Open questions
- Crate shape — resolved at
red: astoremodule inourios-parquet(no new crate; dep-graph-confirmed, §3.7). - Single-writer lease — is conditional-PUT contention on the manifest sufficient, or is a separate lease object needed (RFC 0009 §7)?
- Conditional-PUT portability — do
If-None-Match/If-Matchcover the compare-and-swap on every targeted store (S3, R2, MinIO, GCS viaobject_store)? What is the fallback where a store lacks it? - Multipart threshold — RFC 0009 outputs are 256 MiB–2 GiB; pick the multipart-upload threshold.
- Credentials — IAM role (IRSA on k8s) vs static keys via RFC 0004 / k8s Secrets.
- Local read cache for hot Parquet — defer (perf, not correctness)?
- Migration — is a local-FS-store → object-store copy tool needed, or is it N/A pre-release (no production data yet)?
8. References
CLAUDE.md§3.6 (object storage is the source of truth), §3.4 (WAL-before-ack / durability horizon), §3.7 (multi-tenancy partitioning), §5.1 (RFC-required for a pillar), §7 (new-crate commitment).- RFC 0005 — the on-disk Parquet contract being relocated (unchanged).
- RFC 0009 — the atomic-publish manifest; §7 deferred the S3 atomic-swap + single-writer lease, discharged here.
- RFC 0004 — configuration policy (endpoint, region, bucket, prefix, creds).
object_store— the Apache Arrow object-storage abstraction, already a transitive dependency via DataFusion.- S3 conditional writes —
If-None-Match/If-Matchprecondition PUTs.