BoltDecorator

class BoltDecorator<Id : Any, V, Op : Any>(bolt: Bolt<Op>, format: BoltArchiveFormat<Id, V, Op>, removalWindow: Int = DEFAULT_REMOVAL_WINDOW, frontierWindow: Int = DEFAULT_FRONTIER_WINDOW)

Feeds a Bolt the operations a CRDT owner applied, and suppresses the ones it has already archived.

This is the wiring, and it is deliberately the only wiring. The owner of the replica — a log exporter, a document, anything holding an Rga or a Fugue — publishes the operations it applied and knows nothing about archiving; this class knows nothing about what the operations mean. So one decorator serves every op-log owner, and neither side has to learn about the other.

Two paths reach here, and the second one is the whole point

A replica's local edits already come with their operations in hand, so teeing those is straightforward. A merge does not. Absorbing a peer's replica is a state join: it produces no operation stream at all. And gossip is exactly how a phone's records reach a server — so an archive fed only by the local path holds a server's own history and none of the history that arrived from the devices, which is the one capability the module exists to open. The owner must therefore publish the remote replica's operations on the merge path too, by enumerating them through OpLogCrdt.operations().

Which is why deduplication is not optional

Anti-entropy re-offers the same remote log every round. Without suppression, each round writes one full copy of the peer's log, so an archive that is supposed to grow with history would grow with time spent gossiping. An append-only log gives no dedup for free.

Inserts and removes are recognised by different things, and cost different amounts

An Insert mints exactly one causal Dot. A Remove mints none — it reuses its target insert's id — so the two need different handles, and the difference is the whole memory story:

  • Inserts are suppressed from a DotFrontier, which holds contiguous runs of archived seq per author. One peer's entire live log is one entry, however many operations it holds. There is no working set to exceed and no cliff to fall off: an insert this decorator has archived stays suppressed however long gossip runs, and the only thing that can take that back is frontierWindow evicting the whole author's run.

  • Removes are suppressed from a bounded LRU set of LogOp identities, because there is no dot to key them on. This is the residual, and it is bounded by removalWindow.

The split pays because a live log is mostly inserts. Every record the source still retains is an insert; a tombstone lives only from the removal that minted it until the source's own compaction collects it. Over all time the two counts converge — every record is eventually removed — but what a peer offers each round is the live log, and that is the quantity a window has to hold.

The residual, stated with its bound. Suppression of removes is total only while the aggregate offered working set of removes — every peer's live tombstones, plus this replica's own export stream, since one window serves them all — fits in removalWindow. Past that it is a step, not a ramp: the window thrashes, suppression collapses, and the archive grows by roughly the whole Σ(offered removes) per round — not by Σ − removalWindow. Each peer's identities are evicted by the next peer's before the round comes round again, so nothing is suppressed at all; measured at exactly zero suppressions against an offered set 5,000× the window. The two formulas nearly agree once Σ is far past the window and disagree precisely at the sizing boundary, which is where this sentence gets read. Live tombstones are the minority of an offered log, per the split above, so this is a materially smaller residual than one sized by every operation — but it is a residual, and raising removalWindow moves that cliff rather than removing it.

The frontier has a bound too, in frontierWindow, but it counts runs rather than operations: one per author, plus one for each hole that has not filled. Past it the shortest run is evicted and the inserts it covered are archived a second time.

All of it is sound because a suppression miss costs bytes, never correctness: a duplicate operation in the archive replays as a duplicate operation, and folding an op-log CRDT's operation twice is idempotent. The frontier is careful to keep the error on that side — see DotFrontier.

Nothing is rebuilt on open, and the frontier does not survive a restart either

The design sketch this class grew from proposed an unbounded set of archived identities, rebuilt from the archive's tail whenever a process opens one. Both halves are unaffordable for the same reason — a bolt's archive is unbounded by construction, so nothing sized against it may be held in memory or read at startup.

So a new process starts with an empty frontier and an empty removal window, and that is a deliberate choice rather than an omission. It costs at most one extra copy of each peer's live log per process start — bounded, one-off, and cheaper than reading an unbounded archive to avoid it. Persisting the frontier is possible (it is small and serialisable) and is not done here: it would put a second durable thing beside the archive that has to stay consistent with it across crashes, to save a cost that is already bounded. If that trade ever changes, it changes as its own issue.

A restart is also why the frontier holds runs rather than a high-water mark: the second process meets each peer part-way through that peer's sequence, so there is a hole below first contact that will never fill. DotFrontier leaves it open at no cost.

Ordering: this runs BEFORE the owner's durable write, on purpose

The owner publishes as soon as it has applied the operations, which is earlier than its own durable write returns. So a failed write leaves the archive holding a record the owner's store does not. That asymmetry is the right way round — an archive that is a superset of the live replica is the product; one that is a subset is a silent hole — but it is a property, not an accident, and it is pinned by a test rather than left to be rediscovered.

Never throws (except cancellation), and never fails its caller

publish reports through its return value and health; a full archive disk must not take down the application whose telemetry it is archiving. A caller that wants to act on a refusal reads the returned AppendResult — which carries the lost frame's identities, never a tally.

That holds even against a misbehaving backend. Bolt.append promises not to throw for an I/O failure, which is narrower than not throwing at all, and Bolt is a public, pluggable interface — a backend wrapping a network or a database can throw anything. publish converts such a throw into AppendResult.Failed carrying the operations' dots, so identities that would otherwise vanish reach health like any other refusal.

CancellationException is the one thing that still propagates, and it must: swallowing it would turn a structured-concurrency cancel into a silent no-op. The claim is released first, so a cancelled publish leaves nothing reserved behind.

Parameters

bolt

the archive to feed.

format

how operations are classified and encoded — the same value the bolt was built with. Classification is what separates content from records of forgetting, supplies the causal dot an insert is recognised by, and supplies the identity a repeated removal is recognised by.

removalWindow

how many removal identities to remember, evicted least-recently-offered first. Inserts do not consume it — they are suppressed from the frontier — so size it to the aggregate live tombstones you expect to be offered rather than to whole logs. 0 disables removal suppression, which is only ever right for an owner that never merges.

frontierWindow

how many contiguous runs of archived insert dots to remember: one per author while that author's dots arrive densely, one more for each hole that has not filled. It is not sized by operations — a peer's whole log is one run — so the default covers a fleet far larger than any removalWindow could.

Constructors

Link copied to clipboard
constructor(bolt: Bolt<Op>, format: BoltArchiveFormat<Id, V, Op>, removalWindow: Int = DEFAULT_REMOVAL_WINDOW, frontierWindow: Int = DEFAULT_FRONTIER_WINDOW)

Types

Link copied to clipboard
object Companion

Properties

Link copied to clipboard
val health: StateFlow<ArchiveHealth>

What this decorator has archived, and what it could not — see ArchiveHealth.

Functions

Link copied to clipboard
suspend fun publish(ops: List<Op>): AppendResult

Archive whatever of ops has not been archived already.