Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/arch/cron.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Generated from literal `defineScheduledTask` declarations. Run `pnpm cron:docs` after schedule changes.

Tasks: 24. Unique Cloudflare triggers: 15.
Tasks: 25. Unique Cloudflare triggers: 16.

| Task | Cron | Source |
| --- | --- | --- |
Expand All @@ -16,6 +16,7 @@ Tasks: 24. Unique Cloudflare triggers: 15.
| `detect-star-surges` | `30 4 * * *` | `layers/registry/server/tasks/detect-star-surges.ts` |
| `drain-skill-dirty` | `*/5 * * * *` | `layers/registry/server/tasks/drain-skill-dirty.ts` |
| `embedding-parity-audit` | `0 21 * * *` | `layers/registry/server/tasks/embedding-parity-audit.ts` |
| `prune-finished-jobs` | `40 * * * *` | `layers/registry/server/tasks/prune-finished-jobs.ts` |
| `purge-personal-data` | `30 4 * * *` | `layers/identity/server/tasks/purge-personal-data.ts` |
| `recompute-skill-scores` | `0 3 * * *` | `layers/registry/server/tasks/recompute-skill-scores.ts` |
| `reconcile-rendered` | `20 */6 * * *` | `layers/registry/server/tasks/reconcile-rendered.ts` |
Expand Down
55 changes: 55 additions & 0 deletions layers/registry/server/tasks/prune-finished-jobs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/// <reference types="@cloudflare/workers-types" />

import { resolveCloudflareBindings } from '@harlan-zw/nuxt-cloudflare/bindings'
import { runObservedScheduledTask } from '~~/server/utils/scheduled-run'
import { reportJobRun } from '~~/server/utils/sync-job-reporter'
import { observedSchedulePolicy } from '#shared/schedule-policy'
import { pruneFinishedJobs } from '../utils/finished-job-retention'

const CRON = '40 * * * *'

/**
* Scheduled task: delete finished durable job rows and old embedding attempts.
*
* nuxt-cf-jobs soft-completes a job, so every hourly sync left up to 250
* `jobs` rows behind, with their payloads and retry reasons, forever. D1 is a
* 10 GB store and reached that limit on 2026-11-04. Append-only history needs
* a retention window, and this task is the window for the job tables.
*
* The minute avoids the top of the hour, when the sync creates its batch.
*/
export default defineScheduledTask({
name: 'prune-finished-jobs',
cron: '40 * * * *',
description: 'Delete finished durable job rows and old embedding attempts',
async run({ context }) {
const env = resolveCloudflareBindings<Cloudflare.Env>(context)
const db = env?.DB as D1Database | undefined
if (!env || !db) {
emitOperationalEvent(createWideEvent({ operation: 'prune-finished-jobs', outcome: 'binding-missing' }))
return { result: { error: 'no-db' } }
}

return await runObservedScheduledTask({
db,
env,
context,
policy: observedSchedulePolicy('prune-finished-jobs'),
}, async () => {
const startedAt = Date.now()
const report = await pruneFinishedJobs(db, { now: Math.floor(startedAt / 1000) })
emitOperationalEvent(createWideEvent({
'operation': 'prune-finished-jobs',
'outcome': 'completed',
'success.count': report.completedJobs + report.failedJobs + report.finishedBatches + report.embeddingAttempts,
'truncated': report.truncated,
}))
await reportJobRun(db, 'prune-finished-jobs', {
cron: CRON,
status: 'ok',
durationMs: Date.now() - startedAt,
})
return { result: report }
})
},
})
127 changes: 127 additions & 0 deletions layers/registry/server/utils/finished-job-retention.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import Database from 'better-sqlite3'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
COMPLETED_JOB_RETENTION_SECONDS,
EMBEDDING_ATTEMPT_RETENTION_SECONDS,
FAILED_JOB_RETENTION_SECONDS,
pruneFinishedJobs,
} from './finished-job-retention'

const DAY = 24 * 60 * 60
const NOW = 1_800_000_000

describe('pruneFinishedJobs', () => {
let sqlite: Database.Database

beforeEach(() => {
sqlite = new Database(':memory:')
sqlite.exec(`
CREATE TABLE job_batches (
id TEXT PRIMARY KEY,
parent_batch_id TEXT,
finished_at INTEGER
);
CREATE TABLE jobs (
id TEXT PRIMARY KEY,
batch_id TEXT REFERENCES job_batches(id),
payload TEXT NOT NULL DEFAULT '{}',
completed_at INTEGER,
failed_at INTEGER
);
CREATE TABLE failed_jobs (
id TEXT PRIMARY KEY,
batch_id TEXT,
failed_at INTEGER NOT NULL
);
CREATE TABLE embedding_attempts (
attempt_id TEXT PRIMARY KEY,
started_at INTEGER NOT NULL
);
CREATE INDEX idx_jobs_completed_at ON jobs (completed_at) WHERE completed_at IS NOT NULL;
CREATE INDEX idx_failed_jobs_failed_at ON failed_jobs (failed_at);
CREATE INDEX idx_job_batches_finished_at ON job_batches (finished_at);
`)
})

afterEach(() => sqlite.close())

it('deletes old finished rows and keeps active and recent ones', async () => {
const old = NOW - COMPLETED_JOB_RETENTION_SECONDS - DAY
sqlite.exec(`
INSERT INTO job_batches (id, finished_at) VALUES
('batch-old', ${old}), ('batch-open', NULL), ('batch-recent', ${NOW - DAY});
INSERT INTO jobs (id, batch_id, completed_at) VALUES
('done-old', 'batch-old', ${old}),
('done-open-batch', 'batch-open', ${old}),
('done-recent', NULL, ${NOW - DAY}),
('active', NULL, NULL);
INSERT INTO failed_jobs (id, batch_id, failed_at) VALUES
('failed-old', NULL, ${NOW - FAILED_JOB_RETENTION_SECONDS - DAY}),
('failed-recent', NULL, ${NOW - DAY});
INSERT INTO embedding_attempts (attempt_id, started_at) VALUES
('attempt-old', ${NOW - EMBEDDING_ATTEMPT_RETENTION_SECONDS - DAY}),
('attempt-recent', ${NOW - DAY});
`)

const report = await pruneFinishedJobs(wrapSqlite(sqlite), { now: NOW })

expect(report).toEqual({
completedJobs: 1,
failedJobs: 1,
finishedBatches: 1,
embeddingAttempts: 1,
truncated: false,
})
expect(ids('jobs')).toEqual(['active', 'done-open-batch', 'done-recent'])
expect(ids('failed_jobs')).toEqual(['failed-recent'])
expect(ids('job_batches')).toEqual(['batch-open', 'batch-recent'])
expect(ids('embedding_attempts', 'attempt_id')).toEqual(['attempt-recent'])
})

it('keeps a finished batch while a job still names it', async () => {
const old = NOW - COMPLETED_JOB_RETENTION_SECONDS - DAY
sqlite.exec(`
INSERT INTO job_batches (id, finished_at) VALUES ('batch', ${old});
INSERT INTO jobs (id, batch_id, completed_at) VALUES ('member', 'batch', ${NOW - DAY});
`)

const report = await pruneFinishedJobs(wrapSqlite(sqlite), { now: NOW })

expect(report.finishedBatches).toBe(0)
expect(ids('job_batches')).toEqual(['batch'])
})

it('stops at the batch cap and reports the backlog', async () => {
const old = NOW - COMPLETED_JOB_RETENTION_SECONDS - DAY
const insert = sqlite.prepare(`INSERT INTO jobs (id, completed_at) VALUES (?, ?)`)
for (let i = 0; i < 7; i += 1)
insert.run(`done-${i}`, old)

const report = await pruneFinishedJobs(wrapSqlite(sqlite), { now: NOW, limit: 2, maxBatches: 2 })

expect(report.completedJobs).toBe(4)
expect(report.truncated).toBe(true)
expect(ids('jobs')).toHaveLength(3)
})

function ids(table: string, column = 'id'): string[] {
return sqlite.prepare(`SELECT ${column} AS id FROM ${table} ORDER BY id`).all().map(row => (row as { id: string }).id)
}
})

function wrapSqlite(sqlite: Database.Database): D1Database {
return {
prepare(sql: string) {
return {
bind(...params: unknown[]) {
return {
async run() {
const info = sqlite.prepare(sql).run(...params)
return { meta: { changes: info.changes } }
},
}
},
}
},
} as unknown as D1Database
}
171 changes: 171 additions & 0 deletions layers/registry/server/utils/finished-job-retention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/// <reference types="@cloudflare/workers-types" />

/**
* How long a finished durable job row is kept.
*
* `completeJob` in nuxt-cf-jobs is a soft complete: the `jobs` row stays with
* its payload, `retry_reasons`, and `last_error`. The daily check-in reads a
* day or a week back, so seven days keeps every row it can ask for.
*
* Measured 2026-11-12: `jobs` held 146,816 rows, `failed_jobs` 2,672, and
* `job_batches` 3,332, from an hourly sync that writes up to 250 job rows
* and one batch row. Nothing deleted any of them.
*/
export const COMPLETED_JOB_RETENTION_SECONDS = 7 * 24 * 60 * 60

/**
* Failed jobs are the only evidence of a permanent repository failure once
* `repo_sync_progress` is cleared, so they stay longer than completed ones.
*/
export const FAILED_JOB_RETENTION_SECONDS = 30 * 24 * 60 * 60

/**
* Embedding attempts are provider evidence for one Vectorize upsert. The
* parity audit reads only the current `skill_generated` marker, so the
* attempt row matters for triage of a recent failure and nothing else.
*/
export const EMBEDDING_ATTEMPT_RETENTION_SECONDS = 30 * 24 * 60 * 60

/**
* Rows one DELETE may remove. The subquery seeks an index, and 500 rows keep
* each statement well under D1's row budget.
*/
export const FINISHED_JOB_PRUNE_BATCH = 500

/**
* DELETE statements one run may issue per table. Steady state needs one or
* two; the cap only binds while a backlog drains, and it keeps a run inside
* its scheduled runtime.
*/
export const FINISHED_JOB_PRUNE_MAX_BATCHES = 40

export interface FinishedJobPruneReport {
completedJobs: number
failedJobs: number
finishedBatches: number
embeddingAttempts: number
/** True when a table still had a full batch to delete when the cap bound. */
truncated: boolean
}

/**
* Finished job rows nothing needs any more.
*
* The predicates match nuxt-cf-jobs's own prune methods: a member of a batch
* stays while its batch is unfinished, and a batch stays while any job still
* names it. Member rows go before their batches, so the batch delete never
* violates the `jobs.batch_id` foreign key.
*/
const DELETE_COMPLETED_JOBS = `
DELETE FROM jobs WHERE id IN (
SELECT id FROM jobs INDEXED BY idx_jobs_completed_at
WHERE completed_at IS NOT NULL
AND completed_at <= ?
AND (
batch_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM job_batches
WHERE job_batches.id = jobs.batch_id AND job_batches.finished_at IS NULL
)
)
LIMIT ?
)
`

const DELETE_FAILED_JOBS = `
DELETE FROM failed_jobs WHERE id IN (
SELECT id FROM failed_jobs INDEXED BY idx_failed_jobs_failed_at
WHERE failed_at <= ?
AND (
batch_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM job_batches
WHERE job_batches.id = failed_jobs.batch_id AND job_batches.finished_at IS NULL
)
)
LIMIT ?
)
`

const DELETE_FINISHED_BATCHES = `
DELETE FROM job_batches WHERE id IN (
SELECT id FROM job_batches INDEXED BY idx_job_batches_finished_at
WHERE finished_at IS NOT NULL
AND finished_at <= ?
AND NOT EXISTS (SELECT 1 FROM jobs WHERE jobs.batch_id = job_batches.id)
AND NOT EXISTS (SELECT 1 FROM failed_jobs WHERE failed_jobs.batch_id = job_batches.id)
AND (
parent_batch_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM job_batches parent
WHERE parent.id = job_batches.parent_batch_id AND parent.finished_at IS NULL
)
)
LIMIT ?
)
`

/**
* A started attempt older than the window is one the Worker never finished,
* so it is as settled as a terminal one.
*/
const DELETE_EMBEDDING_ATTEMPTS = `
DELETE FROM embedding_attempts WHERE attempt_id IN (
SELECT attempt_id FROM embedding_attempts
WHERE started_at <= ?
LIMIT ?
)
`

async function deleteInBatches(
db: D1Database,
sql: string,
cutoff: number,
limit: number,
maxBatches: number,
): Promise<{ deleted: number, truncated: boolean }> {
let deleted = 0
for (let batch = 0; batch < maxBatches; batch += 1) {
const result = await db.prepare(sql).bind(cutoff, limit).run()
const changes = result.meta.changes ?? 0
deleted += changes
if (changes < limit)
return { deleted, truncated: false }
}
return { deleted, truncated: true }
}

/**
* Delete finished durable job rows and old embedding attempts, in bounded
* batches. Returns how many rows each table lost.
*/
export async function pruneFinishedJobs(
db: D1Database,
input: {
now: number
limit?: number
maxBatches?: number
completedRetentionSeconds?: number
failedRetentionSeconds?: number
embeddingAttemptRetentionSeconds?: number
},
): Promise<FinishedJobPruneReport> {
const limit = input.limit ?? FINISHED_JOB_PRUNE_BATCH
const maxBatches = input.maxBatches ?? FINISHED_JOB_PRUNE_MAX_BATCHES
const completedCutoff = input.now - (input.completedRetentionSeconds ?? COMPLETED_JOB_RETENTION_SECONDS)
const failedCutoff = input.now - (input.failedRetentionSeconds ?? FAILED_JOB_RETENTION_SECONDS)
const attemptCutoff = input.now - (input.embeddingAttemptRetentionSeconds ?? EMBEDDING_ATTEMPT_RETENTION_SECONDS)

const completed = await deleteInBatches(db, DELETE_COMPLETED_JOBS, completedCutoff, limit, maxBatches)
const failed = await deleteInBatches(db, DELETE_FAILED_JOBS, failedCutoff, limit, maxBatches)
const batches = await deleteInBatches(db, DELETE_FINISHED_BATCHES, completedCutoff, limit, maxBatches)
const attempts = await deleteInBatches(db, DELETE_EMBEDDING_ATTEMPTS, attemptCutoff, limit, maxBatches)

return {
completedJobs: completed.deleted,
failedJobs: failed.deleted,
finishedBatches: batches.deleted,
embeddingAttempts: attempts.deleted,
truncated: completed.truncated || failed.truncated || batches.truncated || attempts.truncated,
}
}
1 change: 1 addition & 0 deletions shared/schedule-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const SCHEDULE_POLICY = [
{ _tag: 'observed', taskName: 'embedding-parity-audit', cron: '0 21 * * *', maxSilenceSeconds: 36 * 60 * 60, maxRuntimeSeconds: 30 * 60 },
// First cron fire, not the authoring date: a grace anchored to 2026-09-17
// expired before the deployed trigger ever fired and read as missing_run.
{ _tag: 'observed', taskName: 'prune-finished-jobs', cron: '40 * * * *', maxSilenceSeconds: 3 * 60 * 60, maxRuntimeSeconds: 4 * 60 },
{ _tag: 'observed', taskName: 'purge-personal-data', cron: '30 4 * * *', maxSilenceSeconds: 36 * 60 * 60, maxRuntimeSeconds: 10 * 60, activeFromSeconds: 1790051400 },
{ _tag: 'observed', taskName: 'recompute-skill-scores', cron: '0 3 * * *', maxSilenceSeconds: 36 * 60 * 60, maxRuntimeSeconds: 60 * 60 },
{ _tag: 'observed', taskName: 'refresh-x-engagement', cron: '10 * * * *', maxSilenceSeconds: 3 * 60 * 60, maxRuntimeSeconds: 30 * 60 },
Expand Down
Loading