From 41b4e24efd8d5a7f41ff4f671bad8368c0e287bf Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Thu, 9 Jul 2026 16:57:46 -0500 Subject: [PATCH] compaction slice 1: tombstone snapshot + upstream study spec tombstone.zig ports upstream internal/tombstone: record tombstones (did/collection/rkey -> max superseding seq, from delete+update rows) and did tombstones (from sync rows and account rows decoding to active=false status=deleted), strict-inequality suppression of materialization rows only, marker rows never dropped, per-key max-seq merge, watermark eviction. keys cloned out of decoded block buffers. docs/upstream-compaction-spec.md condenses the upstream study: pass algorithm (chunked disk-window fold, issue #100 dual), watermark rules, rewrite crash safety (tmp+fsync+rename, block topology preserved), merge-tail vs steady, cache-invalidation design, port notes. Co-Authored-By: Claude Fable 5 --- docs/upstream-compaction-spec.md | 242 +++++++++++++++++++++++++++++++ src/internal/tombstone.zig | 221 ++++++++++++++++++++++++++++ src/tests.zig | 1 + 3 files changed, 464 insertions(+) create mode 100644 docs/upstream-compaction-spec.md create mode 100644 src/internal/tombstone.zig diff --git a/docs/upstream-compaction-spec.md b/docs/upstream-compaction-spec.md new file mode 100644 index 0000000..372e058 --- /dev/null +++ b/docs/upstream-compaction-spec.md @@ -0,0 +1,242 @@ +# upstream compaction + tombstone spec (condensed from agent study 2026-07-09) + +how jetstream physically removes deleted/updated data from sealed segments. +sources: `internal/ingest/orchestrator/compact_deletes.go`, `compaction_watermark.go`, +`internal/tombstone/`, `segment/rewrite.go`, docs/README.md §3.3, the +2026-06-12 cache-invalidation design note. + +## tombstones + +BOTH record-level and account-level: + +- record tombstone: key `(did, collection, rkey)` → max superseding seq. + produced by `KindDelete` and `KindUpdate` rows (update = delete+replace; + only latest record version is kept). +- did tombstone: key `did` → `{seq, reason}` where reason ∈ {"sync", "account"}. + produced by `KindSync` rows (repo diverged, full resync follows) and by + `KindAccount` rows whose payload decodes to `active=false && status=="deleted"`. + +semantics (strict inequality everywhere): +- suppression drops only materialization rows (`KindCreate`, `KindUpdate`, + `KindCreateResync`) with seq STRICTLY LESS than the tombstone seq + (`ShouldDrop`: `ts.Seq > ev.Seq`). the marker row itself is never dropped. +- tombstone rows are retained as event rows FOREVER: delete/account/identity/ + sync rows survive compaction so mid-stream clients and audit tooling see + history. compaction removes only superseded creates/updates. +- there is NO read-time overlay: the server never suppresses at delivery; + clients fold the stream themselves (creates/updates apply, deletes/ + account-deletes/syncs remove). + +merge semantics: `Snapshot.Merge` takes per-key max seq. `Fold`/`FoldRange` +build a snapshot from decoded events within a `(lowExclusive, highInclusive]` +seq window. inserted map keys are cloned so they don't pin the decoded block +buffer (decoded events alias the decompressed block). + +persistence: tombstones are NOT persisted as a separate structure — they ARE +the event rows in segments. the in-memory `tombstone.Set` (mutex-guarded maps ++ approx-bytes gauge, 64-byte per-entry overhead constant) is rebuilt at +startup by folding all rows above the watermark (`rebuildLiveTombstones`; +cost scales with the watermark backlog, not the archive — segments/blocks +with `MaxSeq <= watermark` are skipped). the live set is fed by the writer's +OnAppend hook under the writer mutex, and is used ONLY for the cap trigger +(`Len`) and size gauge — never for the pass's drop decisions. + +## the compaction watermark (`compaction/seq` in pebble) + +the highest seq physical compaction has covered. invariant: below W, +superseded create/update rows are physically gone; the uncompacted tail +`(W, tip]` may still carry rows a later marker kills. versioned uint64-LE +value under key `compaction/seq` (`compactionWatermarkV1 = 0x01`). + +`initCompactionWatermarkFloor(store, nextSeq)`: called once when merge opens +the destination writer. if a watermark already exists, no-op; else it writes +`nextSeq - 1` (or 0 when nextSeq == 0). this floors W at "everything before +this instance's first real seq", so the first pass doesn't think the whole +seq space below is uncompacted. + +boundary-seq rules: +- target watermark of a pass = max `MaxSeq` across SEALED segments with + events. active segments are skipped; W never advances past them (their + tombstones stay in the live set — Evict only removes entries ≤ committed W + — and re-apply after seal). +- pass no-ops when `targetWatermark <= watermark`. +- eviction: after committing a chunk watermark, `Tombstones.Evict(chunkEnd)` + removes live-set entries with seq ≤ chunkEnd. + +## the pass algorithm (`runDeleteCompaction`, one pass, chunked) + +1. cleanup: delete stale `*.jss.tmp` in segments/ (also done at process + boot). must hold the rewrite lock — a live timestamp-import rewrite may + have a tmp open; under the lock any tmp seen is genuinely stale. +2. steady mode only: force-rotate the live writer's active segment, so rows + deleted while their segment was active get compacted this pass. race-free + because tombstone Observe runs as the OnAppend hook under the writer + mutex: by ForceRotate's return every sealed event's tombstone is in the + live set, and later events land above the sealed MaxSeq (above this pass's + target). merge-tail passes hand liveWriter=nil (tree already sealed). +3. sweep segments/: open each file's header (`SkipChecksum: true`), skip + active ones, compute targetWatermark. steady mode then reconciles the + manifest (see below). exit if nothing to do. +4. chunk loop, `current = W` until `targetWatermark`: + a. build the chunk's tombstone snapshot by FOLDING SEALED ROWS FROM DISK + in `(current, targetWatermark]` — deliberately NOT the in-memory set, + which collapses each key to the GLOBAL max seq; a key updated again + above the watermark would vanish from an upper-bounded readout and an + older superseded row would survive forever below the committed W + (issue #100 dual). folding the on-disk window can only see seqs ≤ + target. block-index seq bounds prune blocks (bounds survive rewrites + as historical supersets, so pruning is safe). + b. cap: `CompactionTombstoneCap` (default 32_000_000 entries) bounds + snapshot size; when hit, chunkEnd = that segment's MaxSeq and the loop + does multiple chunks. + c. rewrite chunk under the rewrite lock (scoped per chunk, not per pass, + so a timestamp import may interleave between chunks — safe because + both rewrites are per-segment atomic + idempotent): fan segments to + `min(NumCPU, 8)` workers; each calls `segment.Rewrite(path, decide)` + with decide = keep if `seq > chunkEnd`, else drop if `snap.ShouldDrop`. + a bloom prefilter narrows by candidate DIDs unless the snapshot has + more than `CompactionBloomNarrowMaxDIDs` (default 100_000) distinct + DIDs (merge-tail passes run millions — probing costs more than it + saves), in which case exact-scan every segment. + IMPORTANT: after g.Wait() the ctx error is folded in — a cancel can + let workers return nil for a chunk cut short, and committing that + watermark would evict tombstones for rewrites that never ran. + d. steady mode: for each actually-rewritten segment, fire + `OnSegmentCompacted(idx, path)` (manifest refresh + cache invalidation, + below). refresh failure fails the pass; W does not advance. + e. commit: `saveCompactionWatermark(chunkEnd)`, then `Evict(chunkEnd)`. + crashpoints straddle both sides (`AfterCompactionRewriteBeforeWatermark`, + `AfterCompactionChunkWatermark`). + +failure policy: a failed steady pass logs and retries next tick — it must +NEVER tear down the daemon (would turn a transient IO error into an +ingestion outage). `watermark_lag_seconds` (tip witnessed-at minus watermark +witnessed-at, floored to the span of uncompacted data so an empty watermark +doesn't read as a 50-year spike) is the operator paging signal. + +## segment rewrite crash safety (`segment.Rewrite`) + +- rewrite is read-old → write sibling `path + ".tmp"` (`.jss.tmp`) → fsync + tmp → close → rename over path → fsync parent dir. failure at or before + the rename leaves the original untouched; a crashed tmp is reclaimed at + boot and pass start. any write/fsync/rename error is crash-loud (disk-full + gets an actionable operator message). +- block topology is PRESERVED: a fully-dropped block remains as an + `event_count=0` block, and historical seq/witnessed-at envelopes are kept, + so block numbers, cursor translation, and cold replay stay stable across + generations. rewritten files get a new checksum. +- if the segment-level bloom rules out all candidate DIDs, or no row drops, + the rewrite is a no-op (`Rewritten: false` — "clean" segment). +- the orchestrator-level `rewriteMu` serializes delete-compaction and + timestamp-import rewrites; two concurrent tmp+rename on one file would + silently drop the loser's changes. + +## merge-tail vs steady-state + +merge-tail (`compactionMergeTail`), once, at the tail of the merge phase: +after the dst writer is sealed (`SealActiveAndClose`) and before +`phase=steady_state` is written — so the FIRST served archive view is +delete/update compliant. differences from steady: liveWriter=nil (no +force-rotate), no per-segment manifest refresh (the pass is +manifest-oblivious — nothing is serving yet), typically millions of +tombstones so the bloom narrowing is skipped. immediately after it, +`reconcileCompactionManifestFromDisk` runs one-shot: re-list sealed +segments and re-fire the manifest refresh for every entry whose resident +checksum mismatches the on-disk header — serving must not ungate until the +manifest matches disk. reconcile failure aborts the transition (crash-loud). + +steady (`compactionSteady`), the `runSteadyCompactor` loop: +- timer: every `CompactionInterval` (default 4h; 0 disables compaction + entirely — passes, live-set feeding, and rebuild all skip). +- early trigger: the live consumer sends on a 1-buffered `compactionTrigger` + channel (non-blocking, coalesced) when the in-memory set's Len reaches + `CompactionTombstoneCap`. `minCompactionTriggerSpacing = 30s` floors + back-to-back cap-triggered passes (defense against a failed pass making + the consumer re-fire on every event). an early pass resets the timer. +- each steady pass also runs the cheap manifest reconcile (checksum compare + against resident entries) to heal the rewrite-succeeded/refresh-failed + crash window. + +## cache invalidation (2026-06-12 design) + +why: rename changes durable bytes instantly, but serving keeps two +in-memory structures — the manifest (headers, block indexes, blooms, +collection indexes per segment) and the subscribe cold-path decoded-block +LRU. either serving pre-compaction data after the rename is a correctness +bug (clients would observe compacted-away rows below W). + +mechanism, per rewritten segment, ordering deliberate: +1. `Manifest.OnSegmentCompacted(idx, path)`: reopen the file sealed, verify + header/footer checksum, rebuild all resident metadata, swap the entry + under the manifest lock. failure ⇒ pass fails, W does not advance. +2. only then `ColdReader.InvalidateSegment(idx)`: generation-aware — a + `generationBySegment` counter is part of the block cache key; bump + + purge resident entries. an in-flight decode may satisfy its own read but + cannot re-insert under a stale generation (closes the purge-undone-by- + in-flight-decode race). checksum stays in the key too, covering missed + invalidations across crash windows. + +manifest is NOT in pebble — it's a directory scan + self-describing file +headers, so it can't drift from disk across restarts; the block cache is +process-local and empty after restart. crash matrix: rewrite ok/refresh +fails → next pass reconciles by checksum; crash before refresh → startup +rereads disk; crash after refresh before watermark commit → next pass +observes lag and continues. + +## crashpoints / oracle seams + +- orchestrator: `AfterCompactionRewriteBeforeWatermark` (chunk rewritten, + watermark not committed — recovery re-runs the chunk, idempotent), + `AfterCompactionChunkWatermark` (committed, eviction may or may not have + happened — rebuild-from-disk makes that immaterial). +- segment: `CrashPointRewriteTempWritten` / `TempSynced` / `Renamed` / + `DirSynced`, plus `IOFaultInjector` consulted before every tmp write, + fsync, and rename. +- hooks: `OnBeforeCompactionPass(targetWatermark)` fires after force-rotate + with no rewrites yet (full pre-compaction snapshot point); + `OnCompactionPass{Watermark, Err}` after every pass. the oracle's + `CheckCompacted` model matches the window-fold (`FoldRange`) semantics + exactly. +- metrics worth porting: watermark, watermark-lag seconds, segments + examined/rewritten/clean, rows dropped by reason (record/sync/account), + tombstones collected by reason, manifest reconciles, early passes, + tombstone set bytes/len. + +## port notes + +maps naturally onto stream: +- `src/internal/archive.zig` owns the sealed-segment list — it is the + manifest analog; `OnSegmentCompacted` becomes an archive method that + reopens + re-validates one sealed jss file and swaps its resident entry. +- jss sealed segments already carry per-block indexes and blooms, so the + window fold (block seq-bound pruning) and candidate-DID bloom prefilter + translate directly; keep the 100k-DID narrowing cutoff and the + "block bounds are historical supersets" invariant when rewriting. +- rewrite = tmp + fsync + rename + fsync dir is already house style; keep + block topology (empty blocks stay) so block numbering survives rewrites. +- watermark: no pebble — a versioned uint64 file (write-tmp-rename) beside + the segments dir matches our file-based metadata precedent; the floor + init (`nextSeq - 1`) is trivial. +- in-memory tombstone set is a plain hashmap pair under a mutex; the fold/ + merge/evict/rebuild functions are small and pure — good first unit-test + surface. clone keys out of decoded block buffers, same aliasing hazard. + +needs a design decision: +- cold-path decoded-block cache invalidation: if/when stream grows a + decoded-block LRU (`cold.zig` / `lru.zig`), decide between generation + counters in the cache key vs keying purely on segment checksum; upstream + needs both (generation closes the in-flight race, checksum covers missed + invalidations). single-threaded readers may let us skip generations. +- rewrite lock: upstream needs it because timestamp import shares the + rewrite path; stream has no second rewriter yet — decide whether to + introduce the mutex now (cheap) or document single-compactor as an + invariant. +- who owns force-rotate: upstream threads the live writer into the pass; + stream's ingest/segment_writer split needs an explicit seam so the + compactor can seal the active segment and rely on the "tombstone observed + before rotate returns" ordering. +- account-delete detection decodes the #account CBOR payload per row during + fold; decide whether to precompute a flag at ingest (a row-kind or header + bit) instead of re-decoding CBOR on every rebuild/pass. +- chunking cap and trigger cap defaults (32M entries) assume go map sizes; + re-derive for zig hashmap overhead before copying the constant. diff --git a/src/internal/tombstone.zig b/src/internal/tombstone.zig new file mode 100644 index 0000000..8c37d5c --- /dev/null +++ b/src/internal/tombstone.zig @@ -0,0 +1,221 @@ +//! tombstones — which rows a compaction pass may physically drop +//! +//! upstream internal/tombstone (docs/upstream-compaction-spec.md): two +//! tombstone families, both keyed by seq with STRICT inequality: +//! - record: (did, collection, rkey) -> max superseding seq, produced by +//! KindDelete and KindUpdate rows (update = delete + replace) +//! - did: did -> seq, produced by KindSync rows (repo diverged) and +//! KindAccount rows whose payload says active=false && status=="deleted" +//! +//! suppression drops ONLY materialization rows (create/update/create_resync) +//! with seq strictly below the tombstone seq; marker rows (delete/account/ +//! identity/sync) survive forever — clients fold the stream themselves, +//! there is no read-time overlay. tombstones are never persisted separately: +//! they ARE the event rows, and snapshots are rebuilt by folding decoded +//! rows. keys are cloned on insert — decoded events alias the decompressed +//! block buffer (same hazard as upstream). + +const std = @import("std"); +const zat = @import("zat"); +const segment = @import("segment.zig"); + +const Allocator = std.mem.Allocator; + +pub const DidReason = enum { sync, account }; + +pub const DidTombstone = struct { + seq: u64, + reason: DidReason, +}; + +pub const Counts = struct { + record: u64 = 0, + sync: u64 = 0, + account: u64 = 0, +}; + +/// a fold of tombstone rows over some seq window. also the live in-memory +/// set (fed per append in steady state) — same structure, the live set is +/// used only for the cap trigger, never for drop decisions. +pub const Snapshot = struct { + allocator: Allocator, + /// owns all keys + arena: std.heap.ArenaAllocator, + records: std.StringHashMapUnmanaged(u64) = .empty, + dids: std.StringHashMapUnmanaged(DidTombstone) = .empty, + counts: Counts = .{}, + + pub fn init(allocator: Allocator) Snapshot { + return .{ .allocator = allocator, .arena = std.heap.ArenaAllocator.init(allocator) }; + } + + pub fn deinit(self: *Snapshot) void { + self.records.deinit(self.allocator); + self.dids.deinit(self.allocator); + self.arena.deinit(); + } + + pub fn len(self: *const Snapshot) usize { + return self.records.count() + self.dids.count(); + } + + /// observe one event row; per-key max seq wins. non-marker kinds are + /// ignored, so callers can feed every row unconditionally. + pub fn observe(self: *Snapshot, ev: segment.Event) !void { + switch (ev.kind) { + .delete, .update => { + var key_buf: [1024]u8 = undefined; + const key = recordKey(&key_buf, ev.did, ev.collection, ev.rkey) orelse return; + const gop = try self.records.getOrPut(self.allocator, key); + if (!gop.found_existing) { + gop.key_ptr.* = try self.arena.allocator().dupe(u8, key); + gop.value_ptr.* = ev.seq; + self.counts.record += 1; + } else if (ev.seq > gop.value_ptr.*) { + gop.value_ptr.* = ev.seq; + } + }, + .sync => try self.observeDid(ev.did, ev.seq, .sync), + .account => { + if (accountIsDelete(self.allocator, ev.payload)) + try self.observeDid(ev.did, ev.seq, .account); + }, + .create, .create_resync, .identity => {}, + } + } + + fn observeDid(self: *Snapshot, did: []const u8, seq: u64, reason: DidReason) !void { + const gop = try self.dids.getOrPut(self.allocator, did); + if (!gop.found_existing) { + gop.key_ptr.* = try self.arena.allocator().dupe(u8, did); + gop.value_ptr.* = .{ .seq = seq, .reason = reason }; + switch (reason) { + .sync => self.counts.sync += 1, + .account => self.counts.account += 1, + } + } else if (seq > gop.value_ptr.seq) { + gop.value_ptr.* = .{ .seq = seq, .reason = reason }; + } + } + + /// true iff a compaction pass may physically drop this row: a + /// materialization row strictly below a covering tombstone. marker rows + /// are never dropped. + pub fn shouldDrop(self: *const Snapshot, ev: segment.Event) bool { + switch (ev.kind) { + .create, .update, .create_resync => {}, + else => return false, + } + if (self.dids.get(ev.did)) |t| { + if (t.seq > ev.seq) return true; + } + var key_buf: [1024]u8 = undefined; + const key = recordKey(&key_buf, ev.did, ev.collection, ev.rkey) orelse return false; + if (self.records.get(key)) |t_seq| { + if (t_seq > ev.seq) return true; + } + return false; + } + + /// drop entries with seq <= watermark (they cover only rows already + /// physically compacted). live-set maintenance; snapshots are throwaway. + pub fn evict(self: *Snapshot, watermark: u64) void { + var rit = self.records.iterator(); + while (rit.next()) |e| { + if (e.value_ptr.* <= watermark) self.records.removeByPtr(e.key_ptr); + } + var dit = self.dids.iterator(); + while (dit.next()) |e| { + if (e.value_ptr.seq <= watermark) self.dids.removeByPtr(e.key_ptr); + } + } +}; + +/// (did, collection, rkey) -> "did\x00collection\x00rkey". components never +/// contain NUL (did/nsid/rkey syntax); null on overflow (caller drops the +/// observation — such a row also fails segment validation upstream of here). +fn recordKey(buf: []u8, did: []const u8, collection: []const u8, rkey: []const u8) ?[]const u8 { + return std.fmt.bufPrint(buf, "{s}\x00{s}\x00{s}", .{ did, collection, rkey }) catch null; +} + +/// does an #account payload mark the account deleted? upstream decodes the +/// envelope per fold; matches active == false && status == "deleted". +pub fn accountIsDelete(allocator: Allocator, payload: []const u8) bool { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const env = zat.cbor.decodeAll(arena.allocator(), payload) catch return false; + const active = env.getBool("active") orelse return false; + if (active) return false; + const status = env.getString("status") orelse return false; + return std.mem.eql(u8, status, "deleted"); +} + +// === tests === + +const testing = std.testing; + +fn row(kind: segment.Kind, seq: u64, did: []const u8, coll: []const u8, rkey: []const u8, payload: []const u8) segment.Event { + return .{ + .seq = seq, + .witnessed_at = 1, + .indexed_at = 0, + .kind = kind, + .did = did, + .collection = coll, + .rkey = rkey, + .rev = "3l3qo2vutsw2b", + .payload = payload, + }; +} + +// {"active": false, "status": "deleted"} / {"active": true} as dag-cbor +const account_deleted = "\xa2\x66\x61\x63\x74\x69\x76\x65\xf4\x66\x73\x74\x61\x74\x75\x73\x67\x64\x65\x6c\x65\x74\x65\x64"; +const account_active = "\xa1\x66\x61\x63\x74\x69\x76\x65\xf5"; + +test "record tombstones: strict inequality, max-seq wins, markers survive" { + var snap = Snapshot.init(testing.allocator); + defer snap.deinit(); + + try snap.observe(row(.delete, 10, "did:plc:a", "app.bsky.feed.post", "k1", "")); + try snap.observe(row(.update, 7, "did:plc:a", "app.bsky.feed.post", "k1", "x")); // lower, ignored + try testing.expectEqual(@as(usize, 1), snap.len()); + + // strictly below 10 → drop; at 10 (the marker's own seq) or above → keep + try testing.expect(snap.shouldDrop(row(.create, 9, "did:plc:a", "app.bsky.feed.post", "k1", "x"))); + try testing.expect(snap.shouldDrop(row(.update, 9, "did:plc:a", "app.bsky.feed.post", "k1", "x"))); + try testing.expect(snap.shouldDrop(row(.create_resync, 9, "did:plc:a", "app.bsky.feed.post", "k1", "x"))); + try testing.expect(!snap.shouldDrop(row(.create, 10, "did:plc:a", "app.bsky.feed.post", "k1", "x"))); + try testing.expect(!snap.shouldDrop(row(.create, 11, "did:plc:a", "app.bsky.feed.post", "k1", "x"))); + // different rkey untouched; marker kinds never drop + try testing.expect(!snap.shouldDrop(row(.create, 9, "did:plc:a", "app.bsky.feed.post", "k2", "x"))); + try testing.expect(!snap.shouldDrop(row(.delete, 9, "did:plc:a", "app.bsky.feed.post", "k1", ""))); + try testing.expect(!snap.shouldDrop(row(.identity, 9, "did:plc:a", "", "", "x"))); +} + +test "did tombstones: sync and account-delete cover the whole did" { + var snap = Snapshot.init(testing.allocator); + defer snap.deinit(); + + try snap.observe(row(.sync, 20, "did:plc:b", "", "", "")); + try snap.observe(row(.account, 30, "did:plc:c", "", "", account_deleted)); + try snap.observe(row(.account, 40, "did:plc:d", "", "", account_active)); // not a delete + try testing.expectEqual(@as(u64, 1), snap.counts.sync); + try testing.expectEqual(@as(u64, 1), snap.counts.account); + + try testing.expect(snap.shouldDrop(row(.create, 19, "did:plc:b", "app.bsky.feed.post", "k", "x"))); + try testing.expect(!snap.shouldDrop(row(.create, 20, "did:plc:b", "app.bsky.feed.post", "k", "x"))); + try testing.expect(snap.shouldDrop(row(.create, 29, "did:plc:c", "app.bsky.feed.post", "k", "x"))); + try testing.expect(!snap.shouldDrop(row(.create, 39, "did:plc:d", "app.bsky.feed.post", "k", "x"))); +} + +test "evict removes entries at or below the watermark" { + var snap = Snapshot.init(testing.allocator); + defer snap.deinit(); + try snap.observe(row(.delete, 5, "did:plc:a", "app.bsky.feed.post", "k1", "")); + try snap.observe(row(.delete, 15, "did:plc:a", "app.bsky.feed.post", "k2", "")); + try snap.observe(row(.sync, 10, "did:plc:b", "", "", "")); + snap.evict(10); + try testing.expectEqual(@as(usize, 1), snap.len()); + try testing.expect(snap.shouldDrop(row(.create, 14, "did:plc:a", "app.bsky.feed.post", "k2", "x"))); + try testing.expect(!snap.shouldDrop(row(.create, 4, "did:plc:a", "app.bsky.feed.post", "k1", "x"))); +} diff --git a/src/tests.zig b/src/tests.zig index 85adb46..5c5c70c 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -15,6 +15,7 @@ comptime { _ = @import("internal/bootstrap/merge.zig"); _ = @import("internal/bootstrap/lifecycle.zig"); _ = @import("internal/archive.zig"); + _ = @import("internal/tombstone.zig"); _ = @import("internal/segment_writer.zig"); _ = @import("internal/segment_fixture_test.zig"); _ = @import("internal/verify.zig"); -- knotmirror 0.0.1