feat: DAG compaction with receiver-side reclamation, perf and reliability improvements - #369
Open
samyfodil wants to merge 2 commits into
Open
feat: DAG compaction with receiver-side reclamation, perf and reliability improvements#369samyfodil wants to merge 2 commits into
samyfodil wants to merge 2 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR addresses the long-standing unbounded-growth problem (#22): a
crdt.Datastoreonly 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 (newDelta.snapshotflag), replaces the covered heads, purges the old blocks/set entries, and broadcasts the new head. Key design points:varint(priority) || alias-CID, additive format, no migration);findBestValueand the purge/compaction walks resolve reads through alias-or-path.Element.priorityfield, used only in snapshots): a snapshot reproduces exactly the same conflict-resolution state on every replica.MaxBatchDeltaSizesplits into sibling snapshot nodes that all become heads and collapse on the next write.Concurrency semantics (tested, not just claimed)
TestCompactConcurrentDeleteWins): a delete concurrent with an unseen compaction wins in both exchange orders — no resurrection, because the tombstone's target id survives compaction.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.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.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):putTombsgroups tombstones by key: onefindBestValuescan per key instead of one per tombstone (aDelete()of a key with N versions was O(N²) DAG fetches).findBestValueonly 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()honorsQuery.KeysOnly.Disk footprint
Measured effect of one
Compacton a replica loaded with 1,000 keys × 5 versions × 1KB values, 200 keys then deleted (logical bytes; single replica):/s)/b)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
/sremainder 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
processNodeunreserves queued children on merge failure (previously a failed merge left the CID reserved forever and the branch stalled until the next repair interval).findBestValue, the migration) are bounded byDAGSyncerTimeout— a missing block can no longer hang a localDelete()indefinitely.purgeKeyBlocksskips no-op value rewrites, so Put/Delete hooks only fire on real changes.Rollout notes (not coordination requirements)
Compactis 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.Tests
Unit coverage goes from 75.2% to 90.8%; the full suite and the
-racesuite 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).