Configuring compaction#
The logd overview says what compaction is: a logarithmic retention schedule that keeps every delta within a recent cutoff and thins older history down to snapshots. This page is how it is turned on, what each knob does, and — the part that surprises people — what cannot be asked of it.
It is off unless the config asks for it#
Compaction is the one section that does not default on. A config file with no
compaction: section, or no config file at all, means compaction never runs and
the full delta history is retained forever.
That is the opposite of snapshot:, which defaults to maxCommits: 1000 and
maxBytes: 4194304 (4 MiB) even when nothing is configured.
An empty section is not nothing:
Every field defaults when it is absent or zero, so compaction: {} and a section
naming all five defaults are the same config.
It runs when a snapshot runs, and never otherwise#
Compaction is not scheduled. It is the last step of taking a snapshot:
a commit lands
→ thresholds checked (maxCommits, maxBytes)
→ active log switched, so writes keep landing in the new one
→ baseline snapshot written
→ the now-inactive log compacted
Four consequences follow from that, and they are the ones worth knowing before tuning anything:
- A store that stops taking writes never compacts. No commits means no threshold crossing, means no snapshot, means no compaction — however old its history gets. There is no timer and no idle sweep.
- There is no way to run it by hand. No
osubcommand, no request in the session protocol. If it has not run, the only lever is writes. - It can never run more often than snapshots do. Lowering
cutoffbelow the snapshot interval does not make compaction happen sooner; it only changes what the next compaction keeps. - Turning snapshots off turns compaction off, whatever
compaction:says. A deliberatesnapshot: {}disables both thresholds, so nothing switches logs and nothing is ever compacted.
Only the inactive log is compacted. The log currently being written to is never touched, so recent history is bounded by the snapshot policy rather than by anything here.
The knobs#
| field | default | what it does |
|---|---|---|
cutoff |
1h | delta records younger than this are kept in full, so reads and replays in the window are exact to the commit |
baseInterval |
1h | the width of the first snapshot tier past the cutoff |
slotsPerTier |
8 | how many snapshots survive in each tier |
multiplier |
2 | each tier is this many times wider than the one before it |
gracePeriod |
5s | how long a reader holding the pre-compaction file has before it is deleted |
horizon |
none | how far back any history survives: past it a snapshot is dropped whatever tier it would have taken a slot in |
Every field may be left out, and a field left out takes its default. The server logs the policy it resolved, whole, so what a store says it is compacting with is what it is compacting with:
level=INFO msg="configured compaction" cutoff=2h baseInterval=1h slotsPerTier=8 multiplier=2 gracePeriod=5s horizon=0s
The horizon#
Without one, the tiers keep some snapshot from every era indefinitely, ever more
sparsely. That is the right default for history, and the wrong answer for a record
that retention has deleted: the delete removes it from the current
state, but every older snapshot that survives still carries it, so it never leaves the
disk. horizon is where history ends. It must be at least the cutoff, since the
cutoff keeps every record; the newest snapshot in a file is kept whatever its age,
because it is the state.
Durations#
cutoff, baseInterval, gracePeriod and horizon are written the way a duration is
written — 1h, 90m, 30s, 500ms — which is what time.ParseDuration reads, plus
d, w and y for 24h, 7d and 365d, calendar-blind (2y, 1y6w). A bare number is
refused rather than guessed at, so there is no unit to remember and no 3600000000000
to get wrong.
What a compaction removes#
Not state — memory. Compaction does not undo the effect of the records it
removes. Immediately before compacting, SwitchDLog writes a full baseline snapshot
of the state at the switch commit, and that snapshot is moments old, so it always
falls inside the cutoff window and always survives. Everything the removed records
contributed is in it. A record that should leave the state — a job long done, an
event a year old — is not compaction's to remove; that is retention,
which deletes it as a write.
What is removed is the account of how the state got there. The store keeps knowing what it holds; it stops knowing which deltas built it. Concretely:
- reading the current state is exact, always, at any retention setting;
- reading a historical commit below the cutoff is approximate — it lands on the
first surviving snapshot at or after the commit rather than being replayed to it, and
the answer's
commitnames that snapshot's, so the client can tell (see Session); - a client resuming a watch from a commit below the retained delta history is
refused with
replay_compactedrather than served a gap, and one resuming above it from a state that is no longer exact starts at the snapshot that stands for it.
Delta records are kept if the record's timestamp is newer than now - cutoff. A
record whose timestamp will not parse is kept: the doubtful case keeps history rather
than losing it.
Scope records are kept regardless of cutoff, until the scope itself is deleted,
and here the reason is a stronger one — for a scope, the records are the state.
Baseline snapshots are written for the root only; scope snapshots are deliberately
not created, because a materialized scope layer resolves !key away and is unsound to
re-apply onto a changed baseline, so a scoped read replays the raw op-preserving records
in full. There is no coarser thing to fall back to, and dropping one would
lose state rather than history. Bounding this is tracked in 5hmq80f3h12krh1mbsn0;
until then a long-lived scope's record set grows without limit and no setting here
changes that. What it costs a read is bounded separately: a read that names a path
replays only the records bearing on that path, not the scope's whole history.
Snapshots are bucketed by age into tiers past the cutoff. With the defaults, measuring from the end of the cutoff window:
| tier | covers | keeps |
|---|---|---|
| 0 | the first 1h past cutoff | 8 snapshots |
| 1 | the next 2h | 8 |
| 2 | the next 4h | 8 |
| N | baseInterval × multiplierᴺ |
slotsPerTier |
Within a tier the survivors are the most recent ones, not a spread across the tier, so a tier thins from its old end first.
The schema needs no snapshot kept for its sake. A schema change is a commit of its own, which compaction never drops, and every root snapshot records the schema in force at its commit; whichever survive carry it.
What cannot be configured#
- Zero does not mean zero. Every field treats an absent or zero value as "use the
default", so there is no way to say keep no delta history (
cutoff: 0sis one hour) or no grace period at all. - A misspelled field is ignored.
cutof:is not an error; it leavescutoffat its default. Same class as a misspelled request field, filed ask0d4y1m6h12kr7cdgdn0. A value the policy refuses, on the other hand —multiplier: 1, ahorizoninside thecutoff— is refused when the file loads, not inside every compaction attempt. - There is no per-path or per-scope policy for history. One schedule covers the whole store. Per-path ageing of state is a different thing, and is a request rather than a setting: retention.
- It cannot be undone. The delta history a compaction removes is gone; the state it described is not, and never was at risk. What cannot be recovered afterwards is the ability to read or replay a commit below the cutoff exactly.
- A reader can be cut off. After
gracePeriodthe pre-compaction file is deleted; a read still holding it errors.
A worked example#
Keep a day of exact history, then thin fast, on a store that commits often enough to snapshot several times an hour:
snapshot:
maxBytes: 4194304 # 4 MiB of delta since the last snapshot; naming the
# section leaves maxCommits at 0, which turns the
# commit threshold OFF
compaction:
cutoff: 24h # a day of exact history
baseInterval: 24h # then 24h-wide tiers
slotsPerTier: 4
multiplier: 4
gracePeriod: 30s # for slow readers
Point logd at it with -config:
o system compose -config takes the same file.
Writing a section is how a threshold is turned off deliberately: a snapshot: that
names only maxBytes leaves maxCommits at zero, and zero is disabled. Only a
section left out entirely gets the defaults.
Verify what was actually read by the numbers logd prints on the way up: