From 30211f25c1d715c9f6be69c85b1a307742cab65f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 07:59:52 +0000 Subject: [PATCH 1/2] wip: reaper requeue --- packages/db/src/queries.js | 17 +++++++-- test/rollout-recovery.test.js | 65 +++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/db/src/queries.js b/packages/db/src/queries.js index ea19877..539c35c 100644 --- a/packages/db/src/queries.js +++ b/packages/db/src/queries.js @@ -481,13 +481,26 @@ export async function listRuns(sourceId, { limit = 20 } = {}) { `; } -/** Runs that never finished: a container that died mid-fetch. Marked so the UI is honest. */ +/** + * Runs that never finished: a container that died mid-fetch. Marked so the + * UI is honest, and the source asked to run again now: startRun had pushed + * its next run a whole cadence out, which for a bulk list of ten thousand + * pages read a month apart meant a redeploy in the wrong minute parked the + * list for a month with nothing to show. A killed run is not a run. + */ export async function reapStaleRuns({ minutes = 30 } = {}) { const rows = await sql` update runs set status = 'error', finished_at = now(), error = 'abandoned (process exited)' where status = 'running' and started_at < now() - (${`${minutes} minutes`})::interval - returning id + returning id, source_id `; + const sources = [...new Set(rows.map((r) => r.source_id))]; + if (sources.length > 0) { + await sql` + update sources set next_run_at = now(), updated_at = now() + where id = any(${pgArray(sources)}::int[]) and enabled + `; + } return rows.length; } diff --git a/test/rollout-recovery.test.js b/test/rollout-recovery.test.js index 6e00599..9ee2f7b 100644 --- a/test/rollout-recovery.test.js +++ b/test/rollout-recovery.test.js @@ -164,3 +164,68 @@ describe('the slot a failed lookup costs', () => { expect(Number(retry.mins)).toBeLessThan(3); }); }); + +/** + * The statements `reapStaleRuns` runs, kept identical to the query: a run + * abandoned by a container that died is marked, and its source is asked to + * run again now rather than a whole cadence later. + */ +const REAP = ` + update runs set status = 'error', finished_at = now(), error = 'abandoned (process exited)' + where status = 'running' and started_at < now() - ($1)::interval + returning id, source_id`; +const REQUEUE = ` + update sources set next_run_at = now(), updated_at = now() + where id = any($1::int[]) and enabled`; + +describe('a run killed by a redeploy does not park its source for a cadence', () => { + test('the abandoned run is marked and the source is due again now', async () => { + // A bulk list: read once a month, and its first run began two hours ago. + const id = await source({ adapter: 'opensite', minutesOut: 43_200, cadence: 43_200 }); + await one( + `insert into runs (source_id, started_at) values ($1, now() - interval '2 hours') returning id`, + [id], + ); + const before = await one( + `select next_run_at > now() + interval '29 days' as parked from sources where id = $1`, + [id], + ); + expect(before.parked).toBe(true); + const reaped = await rows(REAP, ['40 minutes']); + expect(reaped.map((r) => r.source_id)).toContain(id); + await rows(REQUEUE, [[...new Set(reaped.map((r) => r.source_id))]]); + const run = await one(`select status, error from runs where source_id = $1`, [id]); + expect(run.status).toBe('error'); + expect(run.error).toBe('abandoned (process exited)'); + const after = await one(`select next_run_at <= now() as due from sources where id = $1`, [id]); + expect(after.due).toBe(true); + }); + + test('a run still inside its window, and a paused source, are left alone', async () => { + const fresh = await source({ adapter: 'opensite', minutesOut: 43_200, cadence: 43_200 }); + await one( + `insert into runs (source_id, started_at) values ($1, now() - interval '5 minutes') returning id`, + [fresh], + ); + const paused = await source({ + adapter: 'opensite', + minutesOut: 43_200, + cadence: 43_200, + enabled: false, + }); + await one( + `insert into runs (source_id, started_at) values ($1, now() - interval '2 hours') returning id`, + [paused], + ); + const reaped = await rows(REAP, ['40 minutes']); + expect(reaped.map((r) => r.source_id)).not.toContain(fresh); + expect(reaped.map((r) => r.source_id)).toContain(paused); + await rows(REQUEUE, [[...new Set(reaped.map((r) => r.source_id))]]); + expect( + (await one(`select next_run_at > now() as later from sources where id = $1`, [fresh])).later, + ).toBe(true); + expect( + (await one(`select next_run_at > now() as later from sources where id = $1`, [paused])).later, + ).toBe(true); + }); +}); From b934bbc845476b653ac2490d330f855085923206 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 07:59:56 +0000 Subject: [PATCH 2/2] 0.21.2: a run killed by a redeploy no longer parks its source for a whole cadence startRun pushes next_run_at a cadence forward before the run begins, and a container that dies mid-run leaves that in place: the tick's reaper marked the run abandoned but left the source parked. For a bulk list of ten thousand pages read once a month, a redeploy in the wrong minute meant a month with nothing to show. The reaper now asks the source to run again at once (paused sources excepted), so the walk resumes from its cursor at the next tick. Two tests against the in-process Postgres. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MwAoNvWzezmBHeT7oDHo3C --- apps/cli/package.json | 2 +- apps/cli/src/index.js | 2 +- apps/web/package.json | 2 +- apps/worker/package.json | 2 +- package.json | 2 +- packages/adapters/package.json | 2 +- packages/auth/package.json | 2 +- packages/config/package.json | 2 +- packages/core/package.json | 2 +- packages/db/package.json | 2 +- packages/enrichers/package.json | 2 +- packages/knowledge/package.json | 2 +- packages/notify/package.json | 2 +- packages/payments/package.json | 2 +- packages/premium/package.json | 2 +- packages/queue/package.json | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 545af81..ec23045 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/nichedb", - "version": "0.21.1", + "version": "0.21.2", "description": "CLI and MCP bridge for NicheDB: browse collections, manage sources and feeds, search items, from any deployment", "type": "module", "main": "src/index.js", diff --git a/apps/cli/src/index.js b/apps/cli/src/index.js index fd171d2..453253c 100644 --- a/apps/cli/src/index.js +++ b/apps/cli/src/index.js @@ -15,7 +15,7 @@ import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { createInterface } from 'node:readline'; -export const VERSION = '0.21.1'; +export const VERSION = '0.21.2'; const DEFAULT_API = process.env.NICHEDB_API ?? 'https://nichedb.dev'; const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'nichedb'); const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); diff --git a/apps/web/package.json b/apps/web/package.json index 22fe258..c352b7e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/web", - "version": "0.21.1", + "version": "0.21.2", "private": true, "type": "module", "scripts": { diff --git a/apps/worker/package.json b/apps/worker/package.json index e9c5d4c..e3ad659 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/worker", - "version": "0.21.1", + "version": "0.21.2", "private": true, "type": "module", "scripts": { diff --git a/package.json b/package.json index 4ebe304..219f0ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "niche-db", - "version": "0.21.1", + "version": "0.21.2", "private": true, "type": "module", "description": "An open platform for large, ever-growing databases of real-time data: sources in, feeds out. Web, API, CLI and MCP on one Postgres.", diff --git a/packages/adapters/package.json b/packages/adapters/package.json index 508600a..c27e387 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/adapters", - "version": "0.21.1", + "version": "0.21.2", "private": true, "type": "module", "exports": { diff --git a/packages/auth/package.json b/packages/auth/package.json index 8a2b328..24f2aab 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/auth", - "version": "0.21.1", + "version": "0.21.2", "private": true, "type": "module", "exports": { diff --git a/packages/config/package.json b/packages/config/package.json index 3ecd936..9a2ddbc 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/config", - "version": "0.21.1", + "version": "0.21.2", "private": true, "type": "module", "exports": { diff --git a/packages/core/package.json b/packages/core/package.json index 2ba8792..e3c5c42 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/core", - "version": "0.21.1", + "version": "0.21.2", "private": true, "type": "module", "exports": { diff --git a/packages/db/package.json b/packages/db/package.json index 154c819..497122a 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/db", - "version": "0.21.1", + "version": "0.21.2", "private": true, "type": "module", "exports": { diff --git a/packages/enrichers/package.json b/packages/enrichers/package.json index c7ed92c..c6fd989 100644 --- a/packages/enrichers/package.json +++ b/packages/enrichers/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/enrichers", - "version": "0.21.1", + "version": "0.21.2", "private": true, "type": "module", "exports": { diff --git a/packages/knowledge/package.json b/packages/knowledge/package.json index 99ba820..29cff50 100644 --- a/packages/knowledge/package.json +++ b/packages/knowledge/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/knowledge", - "version": "0.21.1", + "version": "0.21.2", "private": true, "type": "module", "exports": { diff --git a/packages/notify/package.json b/packages/notify/package.json index 36cc403..d7c471b 100644 --- a/packages/notify/package.json +++ b/packages/notify/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/notify", - "version": "0.21.1", + "version": "0.21.2", "private": true, "type": "module", "exports": { diff --git a/packages/payments/package.json b/packages/payments/package.json index 81c54ad..20c80c1 100644 --- a/packages/payments/package.json +++ b/packages/payments/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/payments", - "version": "0.21.1", + "version": "0.21.2", "private": true, "type": "module", "exports": { diff --git a/packages/premium/package.json b/packages/premium/package.json index 1c22132..ed6353f 100644 --- a/packages/premium/package.json +++ b/packages/premium/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/premium", - "version": "0.21.1", + "version": "0.21.2", "private": true, "type": "module", "exports": { diff --git a/packages/queue/package.json b/packages/queue/package.json index a44ced6..dc41129 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/queue", - "version": "0.21.1", + "version": "0.21.2", "private": true, "type": "module", "exports": {