Skip to content

feat: DAG compaction with receiver-side reclamation, perf and reliability improvements - #369

Open
samyfodil wants to merge 2 commits into
ipfs:masterfrom
samyfodil:feat/dag-compaction
Open

feat: DAG compaction with receiver-side reclamation, perf and reliability improvements#369
samyfodil wants to merge 2 commits into
ipfs:masterfrom
samyfodil:feat/dag-compaction

Conversation

@samyfodil

@samyfodil samyfodil commented Jul 12, 2026

Copy link
Copy Markdown

This PR addresses the long-standing unbounded-growth problem (#22): a crdt.Datastore only ever grows, even when keys are deleted. It adds compaction (fold a DAG's live state into snapshot blocks and purge the covered history), receiver-side reclamation (replicas that witnessed the old history reclaim their local copy too), and a set of performance/reliability fixes found along the way.

Compaction here is coordination-free, like every other operation in this package: any replica may compact at any time, concurrently with puts, deletes, and other compactions, and all replicas converge to exactly the state the uncompacted history would have produced. No writer quiescence, no consensus, no membership knowledge.

Compaction (Datastore.Compact)

Compact(ctx, dagName) walks the DAG locally, computes every key's winning (value, priority) plus the tombstones that must survive, writes them as one or more snapshot blocks (new Delta.snapshot flag), replaces the covered heads, purges the old blocks/set entries, and broadcasts the new head. Key design points:

  • Stable element ids. A snapshot element keeps its original element id — compaction re-homes the element's storage (its delta now lives in the snapshot block) without changing its identity. This is what makes compaction semantically invisible to the OR-set: a tombstone created concurrently with a compaction targets the id it observed, and that id still names the element after any number of compaction generations. Element markers record the hosting block as an alias in their value (varint(priority) || alias-CID, additive format, no migration); findBestValue and the purge/compaction walks resolve reads through alias-or-path.
  • Per-element priorities are preserved (new Element.priority field, used only in snapshots): a snapshot reproduces exactly the same conflict-resolution state on every replica.
  • Snapshot links are covered-heads bookkeeping only — walkers never descend them. A fresh replica syncs the full live state from the snapshot block(s) alone.
  • Two-generation tombstone rule: a tombstone is carried into the snapshot while its target may still be held un-killed by a lagging replica, and dropped one generation later. Carried tombstones are written with a re-affirming alias so the kill survives the purge of its target's block.
  • Sibling-split: state larger than MaxBatchDeltaSize splits into sibling snapshot nodes that all become heads and collapse on the next write.

Concurrency semantics (tested, not just claimed)

  • Compact ∥ Delete (TestCompactConcurrentDeleteWins): a delete concurrent with an unseen compaction wins in both exchange orders — no resurrection, because the tombstone's target id survives compaction.
  • Compact ∥ Compact, same view (TestConcurrentCompactSameView): compaction is deterministic (sorted keys, stable ids, generation id derived from covered heads), so two replicas compacting the same view produce byte-identical snapshot CIDs — content-addressing collapses the race into a single generation.
  • Compact ∥ Compact, divergent views (TestCompactDivergentViewsConverge): replicas compacting different views (including unseen deletes and overwrites) converge to exactly the state of a never-compacting oracle replica pair fed the same operations.
  • Late tombstones (TestSnapshotElementKilledByLateTombstone): a snapshotted element is killed by a tombstone arriving before or after the snapshot, on full-history and snapshot-only replicas alike.

Receiver-side reclamation

Compact only reclaims space on the replica that runs it. Snapshot deltas therefore carry generation metadata (snapshotTotal, snapshotId): once a replica has merged every sibling of a generation, it purges its own local copy of the covered history (Options.ReclaimOnSnapshot, default on). Waiting for all siblings avoids transiently dropping keys whose surviving value lives in a not-yet-merged sibling. The path is soft-failure (logged, never marks the store dirty); Datastore.ReclaimCompacted(ctx, dagName) is the explicit/recovery entry point (crash-missed generations, disabled auto-reclaim, snapshots without metadata).

Performance

Benchmarks (i9-12900HK, in-memory store, -benchtime=20x):

Benchmark before after
Delete of a key with 100 versions 276 ms/op ~1 ms/op
KeysOnly query, 2000×1KB keys 4.0 ms/op ~1.7 ms/op
Put 95 µs/op ~40 µs/op
  • putTombs groups tombstones by key: one findBestValue scan per key instead of one per tombstone (a Delete() of a key with N versions was O(N²) DAG fetches).
  • Element markers store their priority (and, post-compaction, hosting alias) in the marker value, so findBestValue only fetches delta blocks for max-priority candidates. Migration v1→v2 backfills existing markers (batch-written, idempotent, tolerates unfetchable blocks; empty markers keep a permanent read fallback).
  • Elements() honors Query.KeysOnly.

Disk footprint

Measured effect of one Compact on a replica loaded with 1,000 keys × 5 versions × 1KB values, 200 keys then deleted (logical bytes; single replica):

Component before after
Blockstore (DAG blocks) 5,200 blocks — 5.52 MB 1 block — 0.95 MB −83%
Datastore set state (/s) 7,600 keys — 1.32 MB 3,400 keys — 1.06 MB −20%
Datastore processed markers (/b) 348 KB 348 KB kept, by design
Total 7.19 MB 2.36 MB −67%

The blockstore dominates (every version's value is embedded in its delta block); the reduction scales with version churn — at 100 versions/key the history collapses ~100:1. The /s remainder includes the carried tombstones for the deleted keys, which drop at the next generation (two-generation rule). Processed markers (~67 bytes/op) are intentionally kept so stale rebroadcasts of purged history stay no-ops. Receivers get the same reduction via reclamation; fresh replicas only ever download the snapshot. Note these are logical deletions — the underlying store releases the bytes on its own schedule (Pebble background compaction; Badger value-log GC).

Reliability

  • processNode unreserves queued children on merge failure (previously a failed merge left the CID reserved forever and the branch stalled until the next repair interval).
  • DAG fetches issued by the set (findBestValue, the migration) are bounded by DAGSyncerTimeout — a missing block can no longer hang a local Delete() indefinitely.
  • purgeKeyBlocks skips no-op value rewrites, so Put/Delete hooks only fire on real changes.

Rollout notes (not coordination requirements)

  • All replicas should run a compaction-aware version before Compact is called anywhere — an old-code replica would descend snapshot links into purged history and mis-attribute element priorities. This is release ordering, the same as any format addition.
  • Compacting while replicas are reasonably synced folds the most history (efficiency only; correctness holds regardless).

Tests

Unit coverage goes from 75.2% to 90.8%; the full suite and the -race suite pass. New infrastructure includes a per-replica-blockstore harness (a purge on one replica is invisible to others, as in production — the shared-blockstore harness cannot express this), end-to-end compaction/reclaim scenarios (in-sync, lagging, fresh, sibling-split, crash recovery, legacy snapshots), the concurrency-semantics suite above, fault-injection suites, and benchmarks.

The proto changes are backward compatible (new fields only). The storage migration runs automatically on first open (v1→v2).

…lity improvements

Compaction (Datastore.Compact): folds a named DAG's live state into one
or more "snapshot" blocks and purges the covered history. Per-element
original priorities are preserved (new Element.priority field), so a
snapshot changes nothing about conflict resolution; tombstones are
carried under a two-generation rule; oversized snapshots split into
sibling nodes. Snapshot links are covered-heads bookkeeping and are
never descended by processNode, repairDAG or the purge walk, so fresh
replicas sync the live state from the snapshot alone and never fetch
purged history.

Receiver-side reclamation: snapshot deltas carry generation metadata
(snapshotTotal/snapshotId). Once a replica has merged every sibling of
a generation it purges its own local copy of the covered history
(Options.ReclaimOnSnapshot, default on, soft-failure), so disk usage on
long-lived replicas is bounded by live state rather than witnessed
history. Datastore.ReclaimCompacted is the explicit/recovery path
(crash-missed generations, disabled auto-reclaim, legacy snapshots).

Performance:
- putTombs groups tombstones by key and runs one findBestValue per key
  instead of one per tombstone: deleting a key with 100 versions drops
  from 276ms to ~1ms.
- Element markers now store their priority (varint value on the
  /s/<key>/<id> entry), so findBestValue only fetches delta blocks for
  max-priority candidates instead of every version. Migration v1->v2
  backfills existing markers; empty markers keep a read fallback.
- Elements() honors Query.KeysOnly instead of always reading values.

Reliability:
- processNode unreserves queued children on merge failure; a failed
  branch no longer stalls until the next repair interval.
- DAG fetches issued by the set (findBestValue, migration) are bounded
  by DAGSyncerTimeout instead of hanging on missing blocks.
- purgeKeyBlocks skips no-op value rewrites so Put/Delete hooks only
  fire on real changes.

Unit coverage goes from 75.2% to 90.6%. New tests include a
per-replica-blockstore harness (purges on one replica are invisible to
others, as in production), end-to-end compaction/reclaim scenarios
(in-sync, lagging, fresh, sibling-split, crash recovery, legacy),
convergence tests for concurrent same-view compaction (byte-identical
snapshot CIDs), and a pinned test documenting add-wins resurrection
when compaction races an unseen delete, which is why Compact requires a
single-writer or quiesced dagName.
Snapshots previously re-homed every element under the snapshot block's
id. A tombstone created concurrently with a compaction targets the
element id it observed, so it could not cover the re-homed copy and
add-wins resurrected the key. That forced a "single-writer or quiesced
dagName" correctness requirement on Compact -- a coordination
requirement this package must not have.

Snapshot elements now keep their ORIGINAL element ids: an element is
immutable once created, so folding its storage into a snapshot block
must not change its identity. Concurrent tombstones therefore keep
covering snapshotted elements, in every arrival order, and compaction
becomes pure representation GC with no effect on CRDT semantics. The
single-writer requirement is removed from the docs; what remains are
notes (upgrade replicas before compacting; compacting while synced
folds more history) rather than correctness requirements.

Plumbing: the element marker value (varint priority, since v2) gains an
optional trailing alias CID naming the block that now hosts the
element's delta. findBestValue, purgeKeyBlocks and compactSnapshotState
resolve scope and fetches through alias-or-path, which also makes
second and later compaction generations work (the marker's path id
block is long gone; its alias walks the generation chain). The format
is additive: bare-varint and legacy empty markers stay valid, no
migration needed.

Purge semantics follow element hosting: an element marker survives a
purge iff the block hosting its value survives, which deletes stale
superseded losers folded into earlier generations (previously they
could outlive their host and diverge a later findBestValue). Carried
tombstones are written with a re-affirming alias so they survive the
very purge that covers their target -- without it, the compacting
replica would discard its own record of a kill in the same run that
wrote it, and a divergent replica's later element could resurrect the
key locally.

TestCompactConcurrentDeleteResurrection is inverted and renamed
TestCompactConcurrentDeleteWins: the delete now wins through a
concurrent compaction in both exchange orders. New tests cover the
marker codec, alias-aware purging, late-tombstone kills of snapshotted
elements (both orders), second-generation alias chains against a
fresh-replica oracle, and divergent-view concurrent compactions judged
against a never-compacting replica pair.
taubyte0 pushed a commit to taubyte/tau that referenced this pull request Jul 17, 2026
…age kvdb (#483)

* feat(kvdb): vendor go-ds-crdt (dag-compaction fork), merged into package kvdb

kvdb was a thin wrapper over ipfs/go-ds-crdt v0.6.7. Upstream lags and the
DAG-compaction / perf work (ipfs/go-ds-crdt#369) has been slow to land, while
kvdb was flagged as a hotspot in a tau performance audit. Vendor the fork
(samyfodil/go-ds-crdt @ feat/dag-compaction) so we maintain it ourselves.

Merged directly into package kvdb (it was already the wrapper) rather than a
separate package. protobuf types in pkg/kvdb/pb. Collisions with the wrapper
renamed:
  - crdt New (constructs *Datastore) -> NewDatastore (kvdb keeps factory New)
  - fork's built-in PubSubBroadcaster -> BasicPubSubBroadcaster (kvdb's richer
    topic-dedup broadcaster stays the one it uses)
  - fork's batch (ds.Batch) -> crdtBatch (kvdb wrapper already has batch)

External consumers (services/substrate/migration, cli/app) repointed to kvdb.
go mod tidy drops github.com/ipfs/go-ds-crdt. Merged suite green (kvdb 213s).

* test(kvdb): portable kvdb-ops benchmarks incl. 100-version delete + 2000×1KiB keys-only

* test(migration): guard the broadcaster leak vector, not the kvdb import

go-ds-crdt is now vendored into pkg/kvdb, so migration must import pkg/kvdb to
reach the offline datastore primitives (NewDatastore/Datastore/DefaultOptions).
The old TestNoKvdbImport banned the import wholesale as a proxy for "no live
broadcaster"; that's now incompatible with the merge.

Replace it with TestNoLiveBroadcaster, which AST-checks that migration never
calls the actual leak vectors — the kvdb factory New or a PubSub broadcaster
constructor — while allowing the safe offline reads (NewDatastore with a nil
broadcaster) migration already uses.
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.

1 participant