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/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/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": { 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); + }); +});