Skip to content

Commit 0f7e31a

Browse files
committed
Test and document the retention reaper
Thirteen lifecycle tests cover expiry and DELETE reaps, manifest-last ordering, crash-resume across restore-from-R2, tombstone-lite bootstrap (zero segment head checks), recreate on deleted and expired names, recreate racing an in-flight reap, verify-pass stragglers, failure backoff, the upload filter for deleted streams, the no-local-row scan path, and internal-stream safety. MockR2 gains delete fault injection and counters. The assumptions suite's delete test now asserts the new contract: tombstone until the reap completes, then fully gone. Docs: spec DELETE/TTL semantics, recovery-runbook deletion commit points, architecture retention section, operational env knobs, metrics series, sqlite-schema lifecycle note, CHANGELOG.
1 parent e17d8e2 commit 0f7e31a

9 files changed

Lines changed: 669 additions & 7 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
## Upcoming
44

5+
- Reclaim object storage on stream expiry and deletion: a crash-safe retention
6+
reaper deletes a doomed stream's remote objects (data first, manifest last)
7+
and hard-deletes local rows once the prefix is verifiably empty; a periodic
8+
object-store scan reaps doomed manifests whose creating node is gone;
9+
restore-from-R2 recovers tombstoned/expired manifests as row-only tombstones;
10+
recreating a deleted or expired stream waits for the old incarnation's
11+
cleanup; the uploader skips segments of deleted streams. New env knobs:
12+
`DS_RETENTION_REAP_LIMIT`, `DS_RETENTION_DELETE_CONCURRENCY`,
13+
`DS_RETENTION_SCAN_MS`.
514
- Add an `otel-traces` profile with OTLP JSON/protobuf/gzip ingest, canonical
615
trace-span normalization, search aliases, privacy controls, and trace rollups.
716
- Add request observability pairing descriptors and `POST /v1/observe/request`

docs/architecture.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,20 +193,44 @@ and if it is missing a background reconciliation pass can rebuild it from
193193
published segments plus retained WAL. Profiles and schemas only shape how a
194194
stream is interpreted.
195195

196-
## Stream Deletion Enforcement
196+
## Stream Deletion Enforcement and Retention
197197

198198
`DELETE /v1/stream/{name}` is enforced as a tombstone plus local acceleration
199199
scrub:
200200

201-
- the stream row stays in SQLite with the deleted flag set
201+
- the stream row stays in SQLite with the deleted flag set until the reap below
202+
completes
202203
- the same local delete transaction removes all stream-owned acceleration state:
203204
- routing index state and runs
204205
- exact secondary index state and runs
205206
- routing-key lexicon state and runs
206207
- bundled search companion plans and per-segment companion rows
208+
- before acking, the manifest is republished carrying the deleted flag, so the
209+
stream is a tombstone in remote object storage
207210
- the request path does not synchronously delete already-published remote
208211
segment, manifest, schema, or index objects
209212

213+
Remote cleanup is owned by the retention sweeper and the `StreamReaper`
214+
(`src/retention.ts`, `src/retention_sweeper.ts`), which run as background loops
215+
alongside the uploader and reconciler:
216+
217+
- the expiry phase soft-deletes streams whose `expires_at` has passed
218+
(`Stream-TTL` / `Stream-Expires-At`), converging expiry and DELETE on one
219+
reapable state: the deleted-flagged local row
220+
- the reap deletes the stream's remote objects data-first with `manifest.json`
221+
strictly last, then hard-deletes local rows once the prefix is verifiably
222+
empty; every step is idempotent and resumes after a crash
223+
- restore-from-R2 recovers tombstoned or expired manifests as row-only
224+
tombstones (no segment head checks), so a half-reaped prefix re-arms the reap
225+
instead of aborting bootstrap
226+
- a low-frequency object-store scan restores row-only tombstones for doomed
227+
manifests that have no local row, which keeps retention converging on
228+
deployments whose local SQLite is ephemeral across restarts
229+
- recreating a deleted or expired stream name reaps the old incarnation inline
230+
before the create, so two incarnations never interleave under one prefix
231+
- the uploader skips segments of deleted streams, so an in-flight reap cannot
232+
be re-populated by late uploads
233+
210234
Startup re-enforces the same invariant before background loops start. On boot,
211235
the server scans tombstoned streams and re-runs the acceleration scrub so older
212236
builds, crashes, or manual SQLite edits cannot leave orphaned async-index state

docs/durable-streams-spec.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ System streams (reserved names):
116116
Rules:
117117
- At most one of `Stream-TTL` and `Stream-Expires-At` may be provided.
118118
- If provided, the stream becomes unavailable for reads/appends after expiry.
119+
- After expiry, the stream's local and remote storage is reclaimed
120+
asynchronously (same ordering as DELETE: data objects first, manifest last).
119121

120122
### 3.3 Read request headers
121123

@@ -1112,8 +1114,12 @@ Headers:
11121114
- exact secondary index state and runs
11131115
- routing-key lexicon state and runs
11141116
- bundled search companion plans and per-segment companion catalog rows
1115-
- Does not synchronously delete already-published segment, manifest, schema, or
1116-
index objects from remote object storage.
1117+
- Before acking, republishes the manifest carrying the deleted flag, so the
1118+
stream is a tombstone in remote object storage.
1119+
- Remote objects are then deleted asynchronously and idempotently: data objects
1120+
(segments, indexes, companions, schema) first, `manifest.json` strictly last,
1121+
and local state is fully removed only once the stream's remote prefix is
1122+
verifiably empty. Recreating the same name completes only after that cleanup.
11171123
- Must be idempotent.
11181124

11191125
---

docs/metrics.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,17 @@ This implementation emits interval summaries for:
271271
- `tieredstore.backpressure.limit.bytes`
272272
- `tieredstore.backpressure.pressure`
273273

274+
### Retention
275+
276+
- `tieredstore.retention.streams_reaped` (tag `trigger`)
277+
- `tieredstore.retention.objects_deleted`
278+
- `tieredstore.retention.list_passes`
279+
- `tieredstore.retention.reap.latency` (tag `outcome`)
280+
- `tieredstore.retention.reap.failures` (tag `kind`)
281+
- `tieredstore.retention.scan.latency`
282+
- `tieredstore.retention.scan.manifests_checked`
283+
- `tieredstore.retention.scan.tombstones_restored`
284+
274285
### Process memory
275286

276287
- `process.rss.bytes`

docs/operational-notes.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ Companion-cache note:
121121
- `DS_OBJECTSTORE_RETRY_MAX_MS`: max backoff for retries (default 2s)
122122
- `DS_EXPIRY_SWEEP_MS`: expired stream sweep interval (default 60s; 0 disables)
123123
- `DS_EXPIRY_SWEEP_LIMIT`: expired streams per sweep tick (default 100)
124+
- `DS_RETENTION_REAP_LIMIT`: soft-deleted streams reaped per sweep tick (default 20)
125+
- `DS_RETENTION_DELETE_CONCURRENCY`: parallel object deletes within one stream's reap (default 4)
126+
- `DS_RETENTION_SCAN_MS`: object-store scan interval for doomed manifests without a local row (default 6h; 0 disables)
124127
- `DS_METRICS_FLUSH_MS`: metrics flush interval (default 10s; 0 disables)
125128
- `DS_STATS_INTERVAL_MS`: stats log interval when using `--stats` (default 60s)
126129
- `DS_BACKPRESSURE_BUDGET_MS`: per-request queue-wait budget used by stats (default `DS_INGEST_FLUSH_MS + 1`)

docs/recovery-integrity-runbook.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,24 @@ Correctness depends on explicit commit points in the write path:
4545
- applies chunked WAL GC up to that bound
4646
- This is the remote durability/visibility commit point.
4747

48+
5. Deletion commit points (expiry and DELETE):
49+
- Local soft delete (`STREAM_FLAG_DELETED` on the stream row) records the
50+
delete intent; the row is the durable resume token for cleanup.
51+
- Tombstone manifest publish (deleted flag, or a past `expires_at` already in
52+
the manifest) is the remote delete-intent commit: from here, restore from
53+
object storage recovers the stream only as a row-only tombstone (no segment
54+
head checks), so a half-cleaned prefix can never abort bootstrap.
55+
- Remote data objects are deleted before `manifest.json`; the manifest going
56+
away is the remote invisibility point.
57+
- Local hard delete runs only after the stream's remote prefix is verifiably
58+
empty; this is the final commit. A crash anywhere earlier re-runs the reap
59+
idempotently on the next sweep tick.
60+
- An un-acked DELETE (5xx on the tombstone publish) still converges: the local
61+
soft-deleted row drives the reaper, which republishes the tombstone first.
62+
- A periodic scan restores row-only tombstones for doomed manifests that have
63+
no local row (local SQLite is ephemeral across redeploys), so retention holds
64+
even when the node that created a stream is gone.
65+
4866
### 1.2 Core safety invariants
4967

5068
For every stream, these invariants must hold:

docs/sqlite-schema.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ Additional columns present in the current implementation:
7878
- Retention/flags:
7979
- `expires_at_ms`
8080
- `stream_flags`
81+
- Lifecycle: expiry or DELETE sets the deleted flag (soft delete); the row
82+
survives as the durable resume token while the reaper clears the stream's
83+
object-store prefix, and is hard-deleted (all per-stream rows removed) only
84+
once the prefix is verifiably empty.
8185
- WAL accounting:
8286
- `logical_size_bytes`
8387
- `wal_rows`

test/assumptions.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,7 @@ describe("assumptions", () => {
477477
rmSync(root, { recursive: true, force: true });
478478
});
479479

480-
test("delete is tombstone, listing excludes deleted, and acceleration state is scrubbed", async () => {
480+
test("delete tombstones, hides the stream, scrubs acceleration state, and reaps", async () => {
481481
const root = mkdtempSync(join(tmpdir(), "ds-assume-"));
482482
const cfg = makeConfig(root);
483483
const app = createApp(cfg, new MockR2Store());
@@ -498,10 +498,16 @@ describe("assumptions", () => {
498498
const list = await fetch(`${baseUrl}/v1/streams`);
499499
const arr = await list.json();
500500
expect(arr.find((x: any) => x.name === "del")).toBeUndefined();
501+
// The row survives only as a tombstone until the nudged reap clears the
502+
// object-store prefix and hard-deletes it.
501503
const deletedRow = app.deps.db.getStream("del");
502-
expect(deletedRow).not.toBeNull();
503-
expect(deletedRow && app.deps.db.isDeleted(deletedRow)).toBe(true);
504+
if (deletedRow) expect(app.deps.db.isDeleted(deletedRow)).toBe(true);
504505
expectAccelerationStateCleared(app.deps.db, "del");
506+
const deadline = Date.now() + 10_000;
507+
while (Date.now() < deadline && app.deps.db.getStream("del") !== null) {
508+
await sleep(25);
509+
}
510+
expect(app.deps.db.getStream("del")).toBeNull();
505511

506512
server.stop();
507513
await app.close();

0 commit comments

Comments
 (0)