···11+---
22+"@atmo-dev/contrail-appview": minor
33+---
44+55+fix(feeds): make feed_items pruning bounded so it can't reset the D1 DO
66+77+The hourly feed prune ran a single global `ROW_NUMBER() OVER (PARTITION BY actor)`
88+window + `(actor, uri) NOT IN (...)` anti-join across the entire `feed_items`
99+table — O(n) CPU in one statement. Once the table grew large this exceeded D1's
1010+per-query CPU limit and reset the shared Durable Object, taking down any
1111+concurrent read on the same SQLite instance (unrelated user requests 500'd with
1212+`was reset` / `Network connection lost`). Because the statement reset before
1313+completing, caps were never enforced, the table kept growing, and the prune got
1414+more expensive — a death spiral.
1515+1616+Changes:
1717+1818+- **Bounded per-actor prune.** Pruning is now an index-backed cutoff delete per
1919+ `(actor, collection)` using `idx_feed_actor_coll_time`, cost O(cap), never
2020+ O(table). New `pruneActorFeed` / `sweepFeedItems` exports; the ingest loops
2121+ run one bounded `sweepFeedItems` slice per tick (`FEED_PRUNE_SWEEP_ACTORS`
2222+ actors), which also serves as recovery for already-bloated tables.
2323+- **Persisted prune cursor.** A new `feed_prune_cursor` row tracks the rolling
2424+ sweep position, so progress survives the cron isolate recycling that
2525+ previously made the in-memory hourly gate a no-op (it pruned on essentially
2626+ every tick). The time gate is removed from the cron path; the long-lived
2727+ persistent loop keeps a short in-memory throttle.
2828+- **API:** `pruneFeedItems(db, caps)` now accepts only the per-collection
2929+ `Map<collection, cap>` (the legacy global-number form is removed) and is
3030+ reimplemented as a bounded full-table recovery loop — keep it off the hot
3131+ path.
3232+3333+The follow fan-out's `subject` lookup is already covered by `idx_<follow>_subject`,
3434+so no unbounded statement remains in the ingest path.
+189
packages/contrail/tests/feed-prune.test.ts
···11+import { describe, it, expect, beforeEach } from "vitest";
22+import { createSqliteDatabase } from "../src/adapters/sqlite";
33+import type { Database, ResolvedContrailConfig } from "../src/core/types";
44+import { resolveConfig } from "../src/core/types";
55+import { initSchema } from "../src/core/db/schema";
66+import {
77+ pruneActorFeed,
88+ sweepFeedItems,
99+ pruneFeedItems,
1010+ getFeedPruneCursor,
1111+ saveFeedPruneCursor,
1212+} from "../src/core/db/records";
1313+1414+const EVENT = "community.lexicon.calendar.event";
1515+const RSVP = "community.lexicon.calendar.rsvp";
1616+1717+// Feeds config: event capped at 2 per actor, rsvp at 3. resolveConfig
1818+// auto-adds the `follow` collection, so initSchema builds feed_items, the
1919+// idx_feed_actor_coll_time index, and feed_prune_cursor.
2020+const CONFIG: ResolvedContrailConfig = resolveConfig({
2121+ namespace: "com.example",
2222+ collections: {
2323+ event: { collection: EVENT },
2424+ rsvp: { collection: RSVP },
2525+ },
2626+ feeds: {
2727+ main: {
2828+ targets: [
2929+ { collection: "event", maxItems: 2 },
3030+ { collection: "rsvp", maxItems: 3 },
3131+ ],
3232+ },
3333+ },
3434+});
3535+3636+// caps keyed by NSID, matching what buildFeedTargetCaps / the fanout produce.
3737+const CAPS = new Map<string, number>([
3838+ [EVENT, 2],
3939+ [RSVP, 3],
4040+]);
4141+4242+let db: Database;
4343+4444+async function insertItem(
4545+ actor: string,
4646+ collection: string,
4747+ n: number,
4848+ timeUs: number
4949+): Promise<void> {
5050+ await db
5151+ .prepare(
5252+ "INSERT INTO feed_items (actor, uri, collection, time_us) VALUES (?, ?, ?, ?)"
5353+ )
5454+ .bind(actor, `at://${actor}/${collection}/${n}`, collection, timeUs)
5555+ .run();
5656+}
5757+5858+/** Insert `count` items for (actor, collection) with increasing time_us. */
5959+async function seed(
6060+ actor: string,
6161+ collection: string,
6262+ count: number
6363+): Promise<void> {
6464+ for (let i = 0; i < count; i++) {
6565+ await insertItem(actor, collection, i, 1000 + i);
6666+ }
6767+}
6868+6969+async function rows(
7070+ actor: string,
7171+ collection: string
7272+): Promise<number[]> {
7373+ const res = await db
7474+ .prepare(
7575+ "SELECT time_us FROM feed_items WHERE actor = ? AND collection = ? ORDER BY time_us DESC"
7676+ )
7777+ .bind(actor, collection)
7878+ .all<{ time_us: number }>();
7979+ return (res.results ?? []).map((r) => Number(r.time_us));
8080+}
8181+8282+beforeEach(async () => {
8383+ db = createSqliteDatabase(":memory:");
8484+ await initSchema(db, CONFIG);
8585+});
8686+8787+describe("pruneActorFeed", () => {
8888+ it("keeps the newest `cap` rows and deletes the rest", async () => {
8989+ await seed("alice", EVENT, 5); // time_us 1000..1004
9090+ const deleted = await pruneActorFeed(db, "alice", EVENT, 2);
9191+ expect(deleted).toBe(3);
9292+ expect(await rows("alice", EVENT)).toEqual([1004, 1003]);
9393+ });
9494+9595+ it("is a no-op when the actor is at or under cap", async () => {
9696+ await seed("bob", EVENT, 2);
9797+ expect(await pruneActorFeed(db, "bob", EVENT, 2)).toBe(0);
9898+ expect(await pruneActorFeed(db, "bob", EVENT, 5)).toBe(0);
9999+ expect((await rows("bob", EVENT)).length).toBe(2);
100100+ });
101101+102102+ it("only touches the named collection", async () => {
103103+ await seed("alice", EVENT, 4);
104104+ await seed("alice", RSVP, 4);
105105+ await pruneActorFeed(db, "alice", EVENT, 2);
106106+ expect((await rows("alice", EVENT)).length).toBe(2);
107107+ expect((await rows("alice", RSVP)).length).toBe(4); // untouched
108108+ });
109109+});
110110+111111+describe("sweepFeedItems", () => {
112112+ it("prunes every actor to the per-collection caps in one pass", async () => {
113113+ await seed("alice", EVENT, 5);
114114+ await seed("alice", RSVP, 6);
115115+ await seed("bob", EVENT, 1);
116116+ await seed("carol", RSVP, 10);
117117+118118+ const res = await sweepFeedItems(db, CAPS, null, 100);
119119+120120+ expect(res.done).toBe(true);
121121+ expect(res.nextCursor).toBeNull();
122122+ expect(res.pruned).toBe(3 + 3 + 0 + 7); // alice event/rsvp, bob, carol
123123+ expect((await rows("alice", EVENT)).length).toBe(2);
124124+ expect((await rows("alice", RSVP)).length).toBe(3);
125125+ expect((await rows("bob", EVENT)).length).toBe(1);
126126+ expect((await rows("carol", RSVP)).length).toBe(3);
127127+ });
128128+129129+ it("pages by actor and resumes via the cursor", async () => {
130130+ // Three actors, each over the event cap.
131131+ for (const a of ["a-actor", "b-actor", "c-actor"]) await seed(a, EVENT, 5);
132132+133133+ // Budget of 1 actor per slice: first slice handles "a-actor".
134134+ const s1 = await sweepFeedItems(db, CAPS, null, 1);
135135+ expect(s1.done).toBe(false);
136136+ expect(s1.nextCursor).toBe("a-actor");
137137+ expect((await rows("a-actor", EVENT)).length).toBe(2);
138138+ expect((await rows("b-actor", EVENT)).length).toBe(5); // not yet reached
139139+140140+ const s2 = await sweepFeedItems(db, CAPS, s1.nextCursor, 1);
141141+ expect(s2.nextCursor).toBe("b-actor");
142142+ expect((await rows("b-actor", EVENT)).length).toBe(2);
143143+144144+ const s3 = await sweepFeedItems(db, CAPS, s2.nextCursor, 1);
145145+ // Last actor — still a full page, so not yet flagged done.
146146+ expect(s3.nextCursor).toBe("c-actor");
147147+ expect((await rows("c-actor", EVENT)).length).toBe(2);
148148+149149+ // One more slice runs off the end and wraps.
150150+ const s4 = await sweepFeedItems(db, CAPS, s3.nextCursor, 1);
151151+ expect(s4.done).toBe(true);
152152+ expect(s4.nextCursor).toBeNull();
153153+ expect(s4.pruned).toBe(0);
154154+ });
155155+156156+ it("returns done with no work for empty caps", async () => {
157157+ await seed("alice", EVENT, 5);
158158+ const res = await sweepFeedItems(db, new Map(), null, 100);
159159+ expect(res).toEqual({ pruned: 0, nextCursor: null, done: true });
160160+ expect((await rows("alice", EVENT)).length).toBe(5);
161161+ });
162162+});
163163+164164+describe("feed prune cursor", () => {
165165+ it("round-trips and defaults to null", async () => {
166166+ expect(await getFeedPruneCursor(db)).toBeNull();
167167+ await saveFeedPruneCursor(db, "did:plc:xyz");
168168+ expect(await getFeedPruneCursor(db)).toBe("did:plc:xyz");
169169+ await saveFeedPruneCursor(db, null);
170170+ expect(await getFeedPruneCursor(db)).toBeNull();
171171+ });
172172+});
173173+174174+describe("pruneFeedItems (full recovery loop)", () => {
175175+ it("brings an already-bloated table within caps in one call", async () => {
176176+ // Many actors well over cap — the bloated-table recovery scenario.
177177+ for (let i = 0; i < 25; i++) {
178178+ await seed(`actor-${String(i).padStart(2, "0")}`, EVENT, 8);
179179+ await seed(`actor-${String(i).padStart(2, "0")}`, RSVP, 8);
180180+ }
181181+ const total = await pruneFeedItems(db, CAPS);
182182+ expect(total).toBe(25 * (6 + 5)); // event: 8→2, rsvp: 8→3
183183+184184+ const remaining = await db
185185+ .prepare("SELECT COUNT(*) AS c FROM feed_items")
186186+ .first<{ c: number }>();
187187+ expect(Number(remaining?.c)).toBe(25 * (2 + 3));
188188+ });
189189+});
+26-10
packages/contrail-appview/src/core/jetstream.ts
···66 shortNameForNsid,
77 buildFeedTargetCaps,
88} from "./types";
99-import { initSchema, getLastCursor, saveCursor, applyEvents, pruneFeedItems } from "./db";
99+import { initSchema, getLastCursor, saveCursor, applyEvents, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor } from "./db";
1010import { refreshStaleIdentities, applyIdentityEvent } from "./identity";
1111import { backfillFollowersFromConstellation } from "./constellation";
12121313const BATCH_SIZE = 50;
1414-const FEED_PRUNE_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
1414+/** Distinct actors pruned per ingest tick by the rolling feed sweep. Each
1515+ * actor costs a handful of index-backed O(cap) deletes, so this bounds the
1616+ * prune's per-tick CPU regardless of how large feed_items grows. */
1717+export const FEED_PRUNE_SWEEP_ACTORS = 500;
15181619/** Mutable state that persists across ingest cycles within the same process. */
1720export interface IngestState {
1821 cachedKnownDids?: Set<string>;
1922 schemaInitialized: boolean;
2020- lastFeedPruneMs: number;
2323+ /** Wall-clock of the last feed sweep — used only by the long-lived
2424+ * persistent loop to throttle; the recycling cron isolate sweeps every
2525+ * tick and relies on the persisted cursor instead. */
2626+ lastFeedSweepMs: number;
2127}
22282329export function createIngestState(): IngestState {
2424- return { schemaInitialized: false, lastFeedPruneMs: 0 };
3030+ return { schemaInitialized: false, lastFeedSweepMs: 0 };
2531}
26322733function getLogger(config: ContrailConfig): Logger {
···336342 }
337343 }
338344339339- // Prune feed items hourly, per-target so high-volume targets don't
340340- // squeeze out lower-volume ones.
341341- if (config.feeds && Date.now() - s.lastFeedPruneMs > FEED_PRUNE_INTERVAL_MS) {
345345+ // Prune feed_items to per-collection caps with a bounded, cursored sweep.
346346+ // Every statement is index-backed and O(cap) (see sweepFeedItems), so it can
347347+ // never exhaust D1's per-query CPU budget and reset the shared DO — unlike
348348+ // the old global window+anti-join. The cron isolate recycles each tick, so we
349349+ // persist the sweep cursor in the DB rather than gating on in-memory time,
350350+ // and run an unconditional bounded slice every tick.
351351+ if (config.feeds) {
342352 const caps = buildFeedTargetCaps(config);
343353 if (caps.size > 0) {
344344- const pruned = await pruneFeedItems(db, caps);
345345- if (pruned > 0) log.log(`Pruned ${pruned} old feed items`);
354354+ const cursor = await getFeedPruneCursor(db);
355355+ const { pruned, nextCursor } = await sweepFeedItems(
356356+ db,
357357+ caps,
358358+ cursor,
359359+ FEED_PRUNE_SWEEP_ACTORS
360360+ );
361361+ await saveFeedPruneCursor(db, nextCursor);
362362+ if (pruned > 0) log.log(`Pruned ${pruned} feed items (sweep)`);
346363 }
347347- s.lastFeedPruneMs = Date.now();
348364 }
349365350366 log.log(`[ingest] cycle complete. stored=${events.length}`);
+20-7
packages/contrail-appview/src/core/persistent.ts
···77 resolveConfig,
88 shortNameForNsid,
99} from "./types";
1010-import { initSchema, getLastCursor, saveCursor, applyEvents, pruneFeedItems } from "./db";
1010+import { initSchema, getLastCursor, saveCursor, applyEvents, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor } from "./db";
1111import { refreshStaleIdentities, applyIdentityEvent } from "./identity";
1212import { backfillFollowersFromConstellation } from "./constellation";
1313-import { createIngestState } from "./jetstream";
1313+import { createIngestState, FEED_PRUNE_SWEEP_ACTORS } from "./jetstream";
1414import type { IngestState } from "./jetstream";
15151616-const FEED_PRUNE_INTERVAL_MS = 60 * 60 * 1000;
1616+/** How often the long-lived persistent loop runs a bounded feed sweep. The
1717+ * process stays resident, so this in-memory throttle is reliable here (unlike
1818+ * the recycling cron isolate). */
1919+const FEED_SWEEP_INTERVAL_MS = 10_000;
17201821export interface PersistentIngestOptions {
1922 batchSize?: number;
···176179 }
177180 }
178181179179- if (config.feeds && Date.now() - state.lastFeedPruneMs > FEED_PRUNE_INTERVAL_MS) {
182182+ // Bounded, cursored feed prune (see sweepFeedItems). This process is
183183+ // long-lived, so the in-memory interval is a reliable throttle; the
184184+ // cursor is still persisted so progress carries across restarts.
185185+ if (config.feeds && Date.now() - state.lastFeedSweepMs > FEED_SWEEP_INTERVAL_MS) {
180186 const caps = buildFeedTargetCaps(config);
181187 if (caps.size > 0) {
182182- const pruned = await pruneFeedItems(db, caps);
183183- if (pruned > 0) log.log(`Pruned ${pruned} old feed items`);
188188+ const cursor = await getFeedPruneCursor(db);
189189+ const { pruned, nextCursor } = await sweepFeedItems(
190190+ db,
191191+ caps,
192192+ cursor,
193193+ FEED_PRUNE_SWEEP_ACTORS
194194+ );
195195+ await saveFeedPruneCursor(db, nextCursor);
196196+ if (pruned > 0) log.log(`Pruned ${pruned} feed items (sweep)`);
184197 }
185185- state.lastFeedPruneMs = Date.now();
198198+ state.lastFeedSweepMs = Date.now();
186199 }
187200188201 log.log(`Flushed ${batch.length} events. Cursor: ${lastTimeUs}`);
+2-2
packages/contrail-appview/src/core/db/index.ts
···11export { initSchema } from "./schema";
22-export { getLastCursor, saveCursor, applyEvents, lookupExistingRecords, queryRecords, queryAcrossSources, pruneFeedItems } from "./records";
33-export type { QueryOptions, SortOption, ExistingRecordInfo } from "./records";
22+export { getLastCursor, saveCursor, applyEvents, lookupExistingRecords, queryRecords, queryAcrossSources, pruneFeedItems, pruneActorFeed, sweepFeedItems, getFeedPruneCursor, saveFeedPruneCursor } from "./records";
33+export type { QueryOptions, SortOption, ExistingRecordInfo, FeedSweepResult } from "./records";
44export type { RecordSource } from "../types";
+159-33
packages/contrail-appview/src/core/db/records.ts
···295295296296// --- Feed pruning ---
297297298298-/** Prune feed_items per (actor, collection) to the given cap.
298298+/** db.batch chunk size for the sweep — caps statements per transaction. */
299299+const SWEEP_BATCH_SIZE = 50;
300300+/** Actor page size for the full-table {@link pruneFeedItems} recovery loop. */
301301+const FEED_PRUNE_RECOVERY_BATCH = 200;
302302+303303+/**
304304+ * Build the bounded per-actor cutoff DELETE for one (actor, collection).
299305 *
300300- * - If `caps` is a number: legacy behavior — global per-actor cap across all collections.
301301- * - If `caps` is a Map<collection-NSID, cap>: each collection is pruned independently per actor,
302302- * so high-volume collections (e.g. RSVPs) can't squeeze out lower-volume ones (e.g. events).
303303- * Collections not present in the map are left alone.
306306+ * Deletes everything older than the newest `cap` rows, driven directly by
307307+ * idx_feed_actor_coll_time(actor, collection, time_us DESC). Cost is
308308+ * O(cap + deleted) — never O(table). This is the ONLY prune shape contrail
309309+ * issues: an unbounded window/anti-join over the whole table can exhaust D1's
310310+ * per-query CPU budget and reset the shared Durable Object, which kills any
311311+ * concurrent read against the same SQLite instance.
312312+ *
313313+ * The cutoff is the cap-th newest row (`OFFSET cap - 1`); we delete strictly
314314+ * older rows. Actors with `cap` or fewer rows: the OFFSET subquery yields no
315315+ * row, the cutoff is NULL, and `time_us < NULL` matches nothing — a cheap
316316+ * index no-op. On a tie at the cutoff time_us we keep the extra rows rather
317317+ * than risk deleting a row we meant to keep (feed_items is a cache; a few over
318318+ * cap is harmless, dropping a wanted item is not).
319319+ */
320320+function actorCutoffDelete(
321321+ db: Database,
322322+ actor: string,
323323+ collection: string,
324324+ cap: number
325325+): Statement {
326326+ // Plain `?` placeholders (bound repeatedly) rather than numbered params, so
327327+ // the Postgres adapter's positional `?`→`$n` rewrite stays correct.
328328+ return db
329329+ .prepare(
330330+ `DELETE FROM feed_items
331331+ WHERE actor = ? AND collection = ?
332332+ AND time_us < (
333333+ SELECT time_us FROM feed_items
334334+ WHERE actor = ? AND collection = ?
335335+ ORDER BY time_us DESC LIMIT 1 OFFSET ?
336336+ )`
337337+ )
338338+ .bind(actor, collection, actor, collection, Math.max(0, cap - 1));
339339+}
340340+341341+/** Prune a single actor's feed for one collection to `cap`. Bounded O(cap). */
342342+export async function pruneActorFeed(
343343+ db: Database,
344344+ actor: string,
345345+ collection: string,
346346+ cap: number
347347+): Promise<number> {
348348+ const result = await actorCutoffDelete(db, actor, collection, cap).run();
349349+ return (result as any)?.changes ?? 0;
350350+}
351351+352352+export interface FeedSweepResult {
353353+ /** Rows deleted this slice. */
354354+ pruned: number;
355355+ /** Actor to resume after; null once a full pass completed (wrap to start). */
356356+ nextCursor: string | null;
357357+ /** True when this slice reached the end of the actor list. */
358358+ done: boolean;
359359+}
360360+361361+/**
362362+ * One bounded slice of a rolling feed-items prune.
363363+ *
364364+ * Pages at most `actorBudget` distinct actors (resuming after `cursor`, via the
365365+ * feed_items (actor, uri) PK) and applies the per-(actor, collection) cutoff
366366+ * delete for every cap in `caps`. Every issued statement is index-backed and
367367+ * O(cap), so the slice's per-query CPU stays flat no matter how large
368368+ * feed_items grows — the property the old global window query lacked.
369369+ *
370370+ * Drive it across ticks with a persisted cursor (see getFeedPruneCursor):
371371+ * feed back `nextCursor` until `done`, at which point the cursor wraps to null
372372+ * and the next pass starts from the beginning. Because each pass visits every
373373+ * actor, this doubles as the recovery path for an already-bloated table.
374374+ */
375375+export async function sweepFeedItems(
376376+ db: Database,
377377+ caps: Map<string, number>,
378378+ cursor: string | null,
379379+ actorBudget: number
380380+): Promise<FeedSweepResult> {
381381+ if (caps.size === 0 || actorBudget <= 0) {
382382+ return { pruned: 0, nextCursor: null, done: true };
383383+ }
384384+385385+ const actorsRes = cursor
386386+ ? await db
387387+ .prepare(
388388+ "SELECT DISTINCT actor FROM feed_items WHERE actor > ? ORDER BY actor LIMIT ?"
389389+ )
390390+ .bind(cursor, actorBudget)
391391+ .all<{ actor: string }>()
392392+ : await db
393393+ .prepare("SELECT DISTINCT actor FROM feed_items ORDER BY actor LIMIT ?")
394394+ .bind(actorBudget)
395395+ .all<{ actor: string }>();
396396+397397+ const actors = (actorsRes.results ?? []).map((r) => r.actor);
398398+ if (actors.length === 0) {
399399+ // Ran off the end (cursor pointed past the last actor) — wrap next tick.
400400+ return { pruned: 0, nextCursor: null, done: true };
401401+ }
402402+403403+ const stmts: Statement[] = [];
404404+ for (const actor of actors) {
405405+ for (const [collection, cap] of caps) {
406406+ stmts.push(actorCutoffDelete(db, actor, collection, cap));
407407+ }
408408+ }
409409+410410+ let pruned = 0;
411411+ for (let i = 0; i < stmts.length; i += SWEEP_BATCH_SIZE) {
412412+ const results = await db.batch(stmts.slice(i, i + SWEEP_BATCH_SIZE));
413413+ for (const r of results) pruned += (r as any)?.changes ?? 0;
414414+ }
415415+416416+ // A short page means we exhausted the actor list this slice.
417417+ const done = actors.length < actorBudget;
418418+ return { pruned, nextCursor: done ? null : actors[actors.length - 1], done };
419419+}
420420+421421+/**
422422+ * Prune the ENTIRE feed_items table to the per-collection `caps` by looping the
423423+ * bounded {@link sweepFeedItems} until a full pass completes.
424424+ *
425425+ * Every statement is O(cap) and safe against D1's per-query CPU limit, but the
426426+ * statement count is O(distinct actors), so keep this OFF the hot ingest path —
427427+ * the cron/persistent loops issue a single bounded slice per tick instead. Use
428428+ * it for one-shot recovery or admin tooling.
304429 */
305430export async function pruneFeedItems(
306431 db: Database,
307307- caps: number | Map<string, number>
432432+ caps: Map<string, number>
308433): Promise<number> {
309309- if (typeof caps === "number") {
310310- const result = await db
311311- .prepare(
312312- `DELETE FROM feed_items WHERE (actor, uri) NOT IN (
313313- SELECT actor, uri FROM (
314314- SELECT actor, uri, ROW_NUMBER() OVER (PARTITION BY actor ORDER BY time_us DESC) as rn
315315- FROM feed_items
316316- ) sub WHERE rn <= ?
317317- )`
318318- )
319319- .bind(caps)
320320- .run();
321321- return (result as any)?.changes ?? 0;
322322- }
323434 let total = 0;
324324- for (const [collection, cap] of caps) {
325325- const result = await db
326326- .prepare(
327327- `DELETE FROM feed_items WHERE collection = ? AND (actor, uri) NOT IN (
328328- SELECT actor, uri FROM (
329329- SELECT actor, uri, ROW_NUMBER() OVER (PARTITION BY actor ORDER BY time_us DESC) as rn
330330- FROM feed_items WHERE collection = ?
331331- ) sub WHERE rn <= ?
332332- )`
333333- )
334334- .bind(collection, collection, cap)
335335- .run();
336336- total += (result as any)?.changes ?? 0;
435435+ let cursor: string | null = null;
436436+ for (;;) {
437437+ const res = await sweepFeedItems(db, caps, cursor, FEED_PRUNE_RECOVERY_BATCH);
438438+ total += res.pruned;
439439+ if (res.done) break;
440440+ cursor = res.nextCursor;
337441 }
338442 return total;
443443+}
444444+445445+// --- Feed prune cursor ---
446446+447447+/** Last actor swept by the rolling feed prune; null = start of a fresh pass. */
448448+export async function getFeedPruneCursor(db: Database): Promise<string | null> {
449449+ const row = await db
450450+ .prepare("SELECT actor FROM feed_prune_cursor WHERE id = 1")
451451+ .first<{ actor: string | null }>();
452452+ return row?.actor ?? null;
453453+}
454454+455455+export async function saveFeedPruneCursor(
456456+ db: Database,
457457+ actor: string | null
458458+): Promise<void> {
459459+ await db
460460+ .prepare(
461461+ "INSERT INTO feed_prune_cursor (id, actor) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET actor = excluded.actor"
462462+ )
463463+ .bind(actor)
464464+ .run();
339465}
340466341467// --- Cursor ---
+5
packages/contrail-appview/src/core/db/schema.ts
···338338 )`,
339339 `CREATE INDEX IF NOT EXISTS idx_feed_actor_coll_time ON feed_items(actor, collection, time_us DESC)`,
340340 `CREATE INDEX IF NOT EXISTS idx_feed_actor_time ON feed_items(actor, time_us DESC)`,
341341+ // Single-row cursor for the rolling, bounded feed prune (see sweepFeedItems).
342342+ `CREATE TABLE IF NOT EXISTS feed_prune_cursor (
343343+ id INTEGER PRIMARY KEY CHECK (id = 1),
344344+ actor TEXT
345345+ )`,
341346 `CREATE TABLE IF NOT EXISTS feed_backfills (
342347 actor TEXT NOT NULL,
343348 feed TEXT NOT NULL,