rfc: 0009 title: Background compaction — small-file consolidation status: validated author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-06-02 supersedes: — superseded-by: —
RFC 0009 — Background compaction: small-file consolidation
Status note.
validated(2026-06-15) — the RFC0009.7 D2/D3/B2-post benches were measured authoritatively onbaseline-8vcpu-32gib(benchmarks.md§9.7, git4d52288): D3 PASS (a band-scale compaction output lands at 456.7 MiB, IN the 256 MiB–2 GiB H4 band, 0% under 128 MiB); D2 compaction throughput 166.8 MiB/s single-partition (≫ any per-partition seal rate → backlog drains); B2-post query latency 12.78 ms → 2.10 ms (≈6.1×) as 32 files / row groups collapse to 1. The sustained-ingest soak (D2’s literal one-hour window at D1’s rate) and D1 itself remain unrun — the throughput is the RFC0009.7 D2 measure, not that soak. The priorgreenstatus (flipped earlier the same day) rested on every RFC0009 §5 acceptance criterion having a live, passing test: .1 small-file count collapses (rfc0009_1_*), .2 row conservation (compaction_conserves_every_rowproptest), .3 atomic publish / no torn read (atomic_publish_…+ theourios-querierrfc0009_3_*manifest tests), .4 crash safety (rfc0009_4_*—gc_orphansreclaims orphans, reads stay at a clean generation), .5 tenant/partition isolation (rfc0009_5_*— a mis-partitioned input aborts on the §3.9 row-vs-path check), .6 union-schema merge across an amendment (rfc0009_6_*). The atomic-publish protocol (§3.4) is the per-partition manifest + atomic generation swap; the querier resolves live files reader-first (glob-fallback when absent); the runner lives inourios-ingester(run_sweep/Compactor) with the §3.6 OTel metrics + audit event.RFC0009.7 — measured. The D2/D3
criterionbenches (compaction throughput, small-file size band) + the post-compaction B2 re-run live inourios-bench’scompactionbench — CI-indicative viacompaction-bench.yml, authoritative onbaseline-8vcpu-32gibin §9.7. Its structural half (file count falls under compaction, every row conserved) is also pinned deterministically by RFC0009.1 /compaction_conserves_every_row.Open follow-ups (§7, post-
validated): the full D2 sustained-ingest soak (backlog-returns-to-zero in a one-hour window at D1’s rate) + a measured D1; late-arriving data re-flagging an already-compacted partition (the manifest is authoritative, so a new write into such a partition must be picked up by the candidate scan / folded into the manifest — confirmplan_candidatescovers it); the S3 atomic-swap primitive + single-writer lease for object storage (local FS usesrename); and the RFC 0004 cadence defaults.
1. Summary
Ourios’s writers land many small Parquet files per tenant per hour
(one per writer flush / time-rotated partition; docs/hazards.md
H4). This RFC introduces a background, per-tenant, per-partition
compaction pass that consolidates the small *.parquet files of a
sealed partition into one (or few) files inside the RFC 0005 §3.5
size targets, without changing a single stored row and without
any query ever seeing a row twice or missing one. Compaction reuses
the existing ourios-parquet Reader/Writer and the atomic-publish
convention (write to *.parquet.tmp, commit by rename); the live set
of a partition is named by a small per-partition manifest so the
commit is a single atomic object swap. It is the mitigation for
hazard H4 and the lever the RFC 0007 §6 / PR #92 B2 bench identified:
query latency there is dominated by per-file footer reads, not data
scanning, so fewer/larger files is the next query-latency win.
2. Motivation
2.1 The small-file problem is now measured, not theoretical
docs/hazards.md H4 predicted it; the B2 latency bench
(crates/ourios-bench/benches/b2.rs, RFC 0007 §6, landed in PR #92)
measured it. With result size held constant while the corpus grows
1×/10×/50×, query latency grew sub-linearly but not flat (~0.95 ms
→ ~1.55 ms → ~4.36 ms). The structural B2 test
(rfc0007_2_template_exact_work_scales_with_result_not_corpus) proves
the scanned row groups and bytes stay flat; the residual wall-clock
growth is per-file footer/metadata reads, because file count scales
with corpus. Compaction is the direct lever on that residual: collapse
N small files’ N footer reads into one.
2.2 RFC 0005 explicitly deferred it here
RFC 0005 §3.5 says the writer’s job is to “land at the bottom of [the file-size] range or below on its own … compaction is deferred,” and §4.5 parks background compaction as “a post-MVP RFC.” Two writer behaviours guarantee small files even at steady state and so require a sweeper: (a) time-rotated partitions — an hour partition that receives a trickle of late or low-volume traffic produces a sub-128 MiB file; (b) end-of-day audit files (RFC 0005 §3.4) are inherently small. H4’s detection threshold (“fewer than 5 % of files below 128 MiB at steady state”) cannot be met by writer sizing alone.
2.3 Why at this layer
Compaction is a write-path / storage concern, not a query-path
one: the querier (RFC 0007, hazard §4.6) must stay a pure reader, and
the WAL→Parquet flush sizing (RFC 0005/0008) is a separate mechanism
(it sizes files as they are first written; this RFC re-consolidates
files already published). Doing it as a background pass keeps it off
the ack-latency hot path (WAL-before-ack, CLAUDE.md §3.4 is
untouched).
3. Proposed design
3.1 Scope
In scope. Background consolidation of the published, committed
*.parquet data files of a single sealed partition
(data/tenant_id=<enc>/year=YYYY/month=MM/day=DD/hour=HH/, the
RFC 0005 §3.4 Hive layout) into one or a few files meeting RFC 0005
§3.5 size targets, preserving every stored row exactly. The same mechanism applies to the audit-event series
(audit/…, day-granular).
Out of scope. WAL→Parquet flush sizing (RFC 0005/0008); retention/expiry/TTL (no compaction-driven deletion of data — only of inputs it has just rewritten); cross-partition or cross-tenant merges; re-mining or re-templating (compaction copies rows, it does not touch the miner); query-side caching.
3.2 Where it runs
A background task hosted in the ingester role (it already owns the
write path, the bucket credentials, and per-tenant context), with its
own bounded concurrency knob so it never starves ingest. The
compaction logic itself is a new compaction module in
ourios-parquet (it is Parquet-file manipulation — read many via
Reader, write one via Writer); no new crate. Driving it from a
dedicated compactor role is a deployment-scaling evolution captured
in §4, not an MVP requirement.
3.3 What is eligible: sealed partitions
Compaction only ever touches a sealed partition — one no longer
receiving writes — so it never races an active writer for the same
input set. A data partition (…/hour=HH/) is sealed once wall-clock
time passes the end of its hour plus a compaction_grace margin
(default 15 min, tunable per RFC 0004) that absorbs late-arriving
records. A sealed partition is a candidate when it has more than
compaction_min_files files (default 4) or holds files below
128 MiB. This is a partition-local trigger heuristic — distinct
from H4’s tenant-level detection metric (the per-tenant file-size
histogram / “fewer than 5 % of files below 128 MiB at steady
state”, §3.6), which is the cluster signal compaction’s job is to
keep satisfied. Late data that
arrives after a partition is compacted lands as a new small file and
re-flags the partition as a candidate — compaction is idempotent and
re-runnable (§3.5).
3.4 The atomic-publish protocol — per-partition manifest
A query (RFC 0007) plans over a partition by enumerating its committed
*.parquet files. If compaction publishes the consolidated file
before deleting its inputs, a concurrent query double-counts; if it
deletes inputs first, a query misses rows. Object storage (the source
of truth, CLAUDE.md §3.6) offers no atomic multi-object operation,
so a glob-the-directory reader cannot be made correct under
compaction.
The commit mechanism is a per-partition manifest. Each partition
carries a small manifest.json naming the live set of data files
(UUIDv7 names) plus a monotonically increasing generation number. The
read path (RFC 0007) resolves a partition’s files through the
manifest, not a raw glob; absence of a manifest means “glob all
*.parquet” — so pre-compaction partitions and the current querier
keep working, and the reader gains manifest support before any
compactor writes one (the reader-first sequencing in §7). Compaction:
- reads the live set, writes the consolidated
*.parquet.tmp; renames it to its committed*.parquetname (still not referenced by any manifest, so invisible to queries);- writes
manifest.json.tmpnaming only the new file atgeneration + 1, and atomically swaps it into place (single- object rename / conditional put) — this is the commit point; - lazily deletes the now-orphaned input files (a crash here leaves harmless orphans that a GC sweep reclaims; correctness already committed at step 3).
A query reads a consistent generation: either the pre-compaction set or the post-compaction set, never a mix (RFC0009.3). This is the Iceberg/Delta “atomic metadata swap” idea reduced to one flat file per partition — deliberately not a full table format; the generation- subdirectory and glob-the-directory-reader alternatives were weighed and rejected in §4.
sequenceDiagram
participant C as Compactor (ingester)
participant FS as Object store (partition)
participant Q as Querier
Note over FS: manifest@gen=N → {a,b,c}.parquet
C->>FS: read live set {a,b,c}
C->>FS: write compacted.parquet.tmp → rename compacted.parquet
Q-->>FS: plan @gen=N (sees {a,b,c}) ✓ no torn read
C->>FS: atomic swap manifest@gen=N+1 → {compacted}
Q-->>FS: plan @gen=N+1 (sees {compacted}) ✓
C->>FS: GC orphaned {a,b,c} (lazy, post-commit)
This protocol is an interaction with RFC 0007 (the querier must read through the manifest) and a small extension to RFC 0005 (the manifest is a new per-partition artifact — additive, optional, back-compatible). Both are recorded as resolved decisions in §7.
3.5 Correctness, idempotency, crash safety
- Row conservation. Compaction preserves every row value
exactly — including the raw
bodybytes — but does not promise byte-identical Parquet files: the physical encoding may differ (row groups re-packed to the §3.5 sizes, compression re-applied, rows possibly reordered within the partition). The logical guarantees hold: same RFC 0005 schema, same partition ⇒ row-vs- path validation §3.9 still holds; bit-identical body reconstruction §3.3 is preserved because rows are copied, never re-mined. Total row count and per-template_idcounts are invariant across a compaction (RFC0009.2). - Idempotency. Re-running compaction on a partition with a single already-large file is a no-op (not a candidate per §3.3).
- Crash safety. The only commit point is the atomic manifest swap
(step 3). A crash before it leaves the prior generation
authoritative — no acknowledged data lost (mirrors the WAL
crash-recovery discipline,
CLAUDE.md§3.4). Temp files and post-commit orphans are reclaimed by an idempotent GC sweep. - Heterogeneous input schemas. Inputs spanning a schema amendment (some files with an added OPTIONAL column) merge to the union schema and stay readable per RFC 0005 §3.9 (the same forward-compatible read RFC0007.4 already tests).
3.6 Audit + observability
Audit event
Every committed compaction emits an audit event to the RFC 0005
§3.7 audit stream — the “nothing happens silently to stored data”
stance applied to file lifecycle (CLAUDE.md §3.1), the same way a
template merge is audited. The event records the partition, the input
file set, the consolidated output file, the row count (which must be
conserved, RFC0009.2), and the committed manifest generation.
Open question (§7): the existing audit schema (RFC 0005 §3.7) is shaped for template events —
event_kindis a bounded ordinal mapping with no compaction member, and the template-specific columns (old_template,positions_widened, …) are non-nullable. A compaction event can reuse the common envelope (tenant_id,timestamp,event_kind/event_type,reason) but (a) needs a newcompactionmember in theevent_kindmapping and (b) has no applicable value for the non-nullable template columns, nor a place for the file set / generation. This is an implementation detail to settle when the compaction audit-emit code lands — not a design blocker, so it does not gatered(tracked in §7). The two routes:
- structured
reason— carry the file set / generation as a structuredreasonpayload. Avoids new columns, but still needs theevent_kindmember and forces placeholder values into the non-nullable template columns (or making them nullable, which is itself a schema change), so “no schema change” is not quite free.- additive OPTIONAL columns — add OPTIONAL compaction columns and relax the template columns to OPTIONAL (an RFC 0005 §3.8 additive, back-compatible amendment); old readers ignore unknown columns per RFC 0005 §3.9.
The non-nullability tilts this toward the additive route; settle it against RFC 0005 §3.7 when that code lands, not here.
Metrics (OpenTelemetry semantic conventions)
Instrumented as OpenTelemetry meters and exported via the OTel
SDK’s OTLP metric exporter (push over OTLP to a collector /
endpoint) — the OTel SDK pipeline end-to-end. No prometheus client
crate and no Prometheus scrape endpoint (maintainer direction,
2026-06-03, superseding the earlier opentelemetry-prometheus
exporter note in roadmap §5; any Prometheus compatibility is a
downstream collector concern, not Ourios’s). The names below follow
the OTel metric-naming guidelines — dotted/namespaced, no
_total/unit suffixes, UCUM units (including UCUM curly-brace
annotations such as {sweep} / {file} for dimensionless counts,
which annotate the unit 1), dimensions as attributes — and are
exported verbatim over OTLP (no exporter-side name mangling).
| Metric | Instrument | Unit | Attributes | Source |
|---|---|---|---|---|
ourios.compaction.sweeps | Counter | {sweep} | ourios.compaction.result | RFC 0009 §3.2 |
ourios.compaction.partitions | Counter | {partition} | — | partitions consolidated |
ourios.compaction.files | Counter | {file} | — | input files merged away (H4) |
ourios.compaction.rows | Counter | {row} | — | rows rewritten (RFC0009.2) |
ourios.compaction.io | Counter | By | ourios.io.direction | bytes read / written |
ourios.compaction.duration | Histogram | s | ourios.compaction.result | sweep wall-clock |
ourios.compaction.orphan.files | Counter | {file} | — | inputs left un-GC’d (gc_failures) |
ourios.compaction.backlog | UpDownCounter | {partition} | ourios.tenant | sealed-but-uncompacted (lag) |
ourios.storage.parquet.file.size | Histogram | By | ourios.tenant | H4 detector — alert when > 5 % of files < 128 MiB |
Attributes (namespaced per the conventions):
ourios.tenant(string) — tenant id. Cardinality is bounded by the tenant count; on the per-file-size histogram it is the dimension H4 detection needs (“per-tenant file-size histogram”).ourios.io.direction(string,read|write) — mirrorsdisk.io.direction; oneiocounter with a direction attribute rather than two_in/_outmetrics.ourios.compaction.result(string,committed|noop|error) — sweep / partition outcome (noop= candidate that consolidated nothing;error= a partition skipped per the resilient sweep).
The H4 “file-count grows sub-linearly with bytes” signal is a derived
alert over ourios.storage.parquet.file.size (count) and ingested
bytes, not a base metric.
Validation gate. This set is the OpenTelemetry semantic- conventions registry at
semconv/registry/, validated byweaver registry checkin CI (thesemconvjob, a required check) — so the names/units/attributes stay spec-adherent and can’t drift. Compaction is the first place these conventions are pinned; RFC 0001 §6.8’s Prometheus-style names get the same OTel-source treatment in its own amendment (roadmap §5).Code generation. Instrumentation does not hand-type these names:
weaver registry generaterenders the registry into a dependency- free leaf crateourios-semconv(const &strper metric / attribute, mirroring upstreamopentelemetry-semantic-conventions), which every instrumented crate depends on. The generator template lives attemplates/registry/rust/; regenerate with the same command CI runs (--futurematchesweaver registry check --future):weaver registry generate rust crates/ourios-semconv/src \ -t templates -r semconv/registry --future cargo fmt -p ourios-semconvThe same
semconvCI job regenerates and fails on any diff (it also catches new untracked files), so the constants cannot drift from the registry. This new leaf crate extends theCLAUDE.md§7 layout; the commitment is blessed here, the same wayourios-telemetrywas blessed in RFC 0001 §6.8.
4. Alternatives considered
- No compaction (rely on writer flush sizing). Rejected: §2.2 — time-rotated low-volume partitions and end-of-day audit files are small by construction, so H4’s <5 % threshold is unmeetable without a sweeper, and PR #92 measured the latency cost.
- Glob-the-directory reader, delete-after-publish (no manifest). Rejected: object storage has no atomic multi-object op, so there is always a window where a query double-counts (publish-then-delete) or misses rows (delete-then-publish). §3.4.
- Full table format (Apache Iceberg / Delta Lake). Rejected for now: Pillar 1 commits Ourios to plain Parquet end-to-end (RFC 0005 §4.6 rejects even a second file format); a full manifest-of- manifests, snapshot log, and schema-registry is far more machinery than one flat per-partition manifest needs. The atomic-swap idea is borrowed from them (§3.4); the bookkeeping is not.
- Compaction in the querier. Rejected: the querier is a pure reader (hazard §4.6); a read path that mutates storage breaks that contract and the multi-reader model.
- Dedicated
compactorrole/binary. A viable evolution for isolating compaction CPU/IO from ingest at scale; deferred — the MVP hosts it as a bounded background task in the ingester (§3.2), and the role split is a later, non-breaking change. - Generation subdirectories instead of a manifest (
…/gen=K/, querier reads the highest). Rejected: it leaks generation into the partition path (a second pruning axis the querier must learn) and complicates partition discovery; the flat manifest (§3.4) keeps the path stable and confines the change to one optional per-partition file. (This was the leading alternative; the manifest won on read-path simplicity.)
5. Acceptance criteria
Given / When / Then / And; ids greppable from tests. These realise hazard H4 and the affected invariants.
-
RFC0009.1 — small-file count falls below the H4 threshold
[H4 detection]- Given a sealed partition with many sub-128 MiB files
- When compaction runs to completion
- Then the partition holds files inside the RFC 0005 §3.5 size range, and at steady state fewer than 5 % of a tenant’s files are below 128 MiB.
-
RFC0009.2 — row conservation
[§3.3 / data integrity]- Given any set of input files in a partition
- When they are compacted
- Then the multiset of stored rows is identical
(total row count and per-
template_idcounts unchanged), and each row still reconstructs bit-identically (RFC 0005 §3.3).
-
RFC0009.3 — query atomicity (no double-count, no miss)
[H4 / RFC0007]- Given a query planned concurrently with a compaction of the same partition
- When it executes
- Then it observes exactly one generation’s file set — every row exactly once — never a torn mix of pre- and post-compaction files.
-
RFC0009.4 — crash safety
[§3.4 discipline]- Given a compactor killed at any point
- When the system recovers
- Then no acknowledged row is lost: the partition reads as either the pre- or post-compaction generation, and orphaned temp/input files are reclaimable.
-
RFC0009.5 — tenant + partition isolation
[§3.7]- Given multi-tenant data
- When compaction runs
- Then it never merges files across tenants or across partition
keys; a compacted file’s rows all share the partition’s
tenant_idand time bucket (RFC 0005 §3.9 row-vs-path holds).
-
RFC0009.6 — forward-compatible merge
[§3.5 / RFC0007.4]- Given inputs spanning a schema amendment (some with an added OPTIONAL column)
- When compacted
- Then the output carries the union schema and reads without error per RFC 0005 §3.9.
-
RFC0009.7 — file count sub-linear in bytes
[H4 / benchmarks D3]- Given sustained ingest with compaction running
- When bytes ingested grow
- Then file count grows sub-linearly, and template-exact query latency (RFC 0007 §6 B2 bench) does not grow proportionally to the pre-compaction file count.
6. Testing strategy
Mapped to CLAUDE.md §6.2:
- Property (
proptest) — RFC0009.2: over arbitrary input file sets (varied templates, row counts, schemas), compaction preserves the row multiset and per-template counts. The reconstruction property test (RFC 0005 §3.3) runs on compacted output too. - Integration — RFC0009.1/.5/.6: build a multi-file partition via
the
ourios-parquetwriter, compact, assert file-size/count and that aQuerierreturns identical results before and after. - Concurrency — RFC0009.3: interleave a query with a compaction commit (drive the manifest swap mid-plan) and assert the row count is exactly correct for one generation.
- Crash recovery — RFC0009.4:
SIGKILLthe compactor before and after the manifest swap; assert recovery loses no rows and GC reclaims orphans (the WAL crash-recovery test is the template). - Corpus — RFC0009.1: file-size histogram on the otel-demo corpora before/after compaction.
- Bench (
criterion) — RFC0009.7 / benchmarks D2 (compaction throughput) + D3 (file count under load); re-run the RFC 0007 §6 B2 latency bench post-compaction to show the per-file footer-read residual (§2.1) shrinks.
7. Open questions
Resolved at specified (the design forks; recorded here so the
history is legible):
- Manifest vs. generation-subdirectories vs. glob-the-directory
reader. Decided: a per-partition
manifest.jsonwith an atomic generation swap (§3.4); the generation-subdirectory and glob-the-directory-reader alternatives are rejected in §4. - RFC 0007 read-path change. Decided: the querier resolves a partition’s files through the manifest, glob-fallback when absent. Sequenced reader-first — the RFC 0007 amendment + querier PR (reader tolerates a manifest) lands before any compactor writes one, so no flag day.
- RFC 0005 artifact ownership. Decided: the manifest is specified here in RFC 0009 and is additive, optional, and back-compatible to the RFC 0005 layout (absent ⇒ glob), so it needs no breaking RFC 0005 amendment; RFC 0005 §3.4 is cross- referenced, not rewritten.
Open (implementation details; none block red):
- Manifest serialization + atomic-swap primitive. Local FS:
renameis atomic. S3: needs conditional-put / versioned-put or a single-writer lease. Which object-store abstraction (and doesobject_storegive us the primitive portably)? - Single-writer-per-partition. Lease, or rely on the ingester being the sole writer by construction? (Interacts with the eventual horizontally-scaled ingester.)
- Late-arriving data into a compacted partition. Direction
decided: a new small file re-flags the partition as a candidate
(§3.3), not re-opening the compacted file; confirm the
compaction_gracedefault. - Cadence + concurrency defaults (RFC 0004): scan interval,
compaction_min_files,compaction_grace, max concurrent partitions. - Audit partition compaction (day-granular) — same protocol, or simpler given lower volume?
- Retention/expiry interplay — explicitly deferred; note the seam so a later TTL RFC composes with the manifest.
- Audit-event shape (§3.6). Carry the compaction file set /
generation in a structured
reasonpayload vs. OPTIONAL audit columns (RFC 0005 §3.8 additive amendment). Per §3.6 the template columns are non-nullable andevent_kindhas no compaction member, so the structured-reasonroute is not schema-free either; leaning the additive OPTIONAL route. - Metric semconv validation (§3.6). Run the §3.6 metric
names/units/attributes through the OpenTelemetry semantic-conventions
check (OTel assistant /
weaver/ rego policy packages) and fix any divergence before instrumentation lands.
8. References
docs/hazards.mdH4 (small-file problem) — the hazard this mitigates;CLAUDE.md§4 hazard 4.- RFC 0005 §3.4 (partitioning + atomic publish), §3.5 (size targets), §3.9 (reader contract / forward-compat), §4.5 (compaction deferral), §3.7 (audit stream).
- RFC 0007 §6 +
crates/ourios-bench/benches/b2.rs(PR #92) — the B2 latency finding that quantifies the small-file cost; RFC 0007 §4.6 (querier stays a pure reader); RFC0007.4 (forward-compatible reads). - RFC 0008 (WAL) — crash-recovery discipline (
CLAUDE.md§3.4) the compactor’s commit protocol mirrors. - RFC 0004 (configuration policy) — where the cadence/grace/concurrency knobs live.
docs/benchmarks.mdD2 (WAL→Parquet compaction keeps up), D3 (small-file count under sustained load).- Apache Iceberg / Delta Lake atomic metadata-swap commit — design inspiration for §3.4 (the idea, not the machinery).