[worker] schedule preview cleanup with native cron #162

Merged
eleith merged 1 commits from feat/native-preview-maintenance into main 2026-10-27 17:37:49 +00:00
11 changed files with 501 additions and 274 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
---
"@luzzle/web.worker": patch
---
Schedule preview-data cleanup with OpenWorkflow cron and durable jitter near
02:00 UTC instead of a startup run and process-local timer. Remove expired preview
files before their application progress/log rows, retain active jobs and engine
history, and remove the unused duplicate cleanup step.
+7
View File
@@ -67,6 +67,13 @@ To update, back up your archive and deployment data, change `LUZZLE_VERSION` if
pinned, then run `docker compose pull` and `docker compose up -d`.
[Republish affected pieces](templates.md#apply-your-choices) after OG changes.
## Preview cleanup
The worker cleans expired preview files and app logs daily, near **02:00 UTC
±12 minutes**. It waits through OpenWorkflow, so other jobs can run during the
random delay. There is no extra cleanup run at startup; downtime or a busy worker
can delay it. Finished-job data is kept for seven days. Workflow history stays.
## Optional remote sync
Put your rclone configuration in `rclone/rclone.conf`. Set `sync.archive` for
+8
View File
@@ -17,6 +17,14 @@ export const jobProgressPurgeSpec = defineWorkflowSpec<
JobProgressPurgeResult
>({
name: 'JobProgressPurge',
schedules: [
{
id: 'luzzle:job-progress-purge',
cron: '48 1 * * *',
timezone: 'UTC',
input: { retentionDays: 7 },
},
],
})
export const previewSpec = defineWorkflowSpec<PreviewPayload, PreviewResult>({
@@ -1,119 +1,113 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { setupDatabase, teardownDatabase } from '../../test/db.js'
import { createAppDb, runWebMigrations } from '@luzzle/web.db'
import { sql } from 'kysely'
import { JobProgress } from './job-progress.js'
import type { Kysely } from 'kysely'
import type { WebDatabase } from '../services/db.js'
describe('JobProgress', () => {
let testDb: Kysely<WebDatabase>
let testDb: ReturnType<typeof createAppDb>
let jobProgress: JobProgress
beforeEach(async () => {
testDb = await setupDatabase()
jobProgress = new JobProgress(testDb, 2)
testDb = createAppDb(':memory:')
expect((await runWebMigrations(testDb)).error).toBeUndefined()
jobProgress = new JobProgress(testDb)
})
afterEach(async () => {
await teardownDatabase(testDb)
await testDb.destroy()
})
it('should start a phase', async () => {
it('starts and restarts a phase', async () => {
await jobProgress.start('42', 'archive.sync')
const rows = await testDb.selectFrom('job_progress' as any).selectAll().execute()
await jobProgress.complete('42', 'archive.sync', 'done')
await jobProgress.start('42', 'archive.sync')
const rows = await testDb.selectFrom('job_progress').selectAll().execute()
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
job_id: '42',
phase: 'archive.sync',
status: 'running',
message: null,
finished_at: null
finished_at: null,
})
expect(rows[0].started_at).toBeGreaterThan(0)
})
it('should complete a phase', async () => {
it('completes a phase', async () => {
await jobProgress.start('42', 'archive.sync')
await jobProgress.complete('42', 'archive.sync', 'completed successfully')
const rows = await testDb.selectFrom('job_progress' as any).selectAll().execute()
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
job_id: '42',
phase: 'archive.sync',
status: 'completed',
message: 'completed successfully'
})
expect(rows[0].finished_at).toBeGreaterThan(0)
const row = await testDb.selectFrom('job_progress').selectAll().executeTakeFirstOrThrow()
expect(row).toMatchObject({ status: 'completed', message: 'completed successfully' })
expect(row.finished_at).toBeGreaterThan(0)
})
it('should skip a phase', async () => {
it('skips a phase', async () => {
await jobProgress.start('42', 'archive.sync')
await jobProgress.skip('42', 'archive.sync', 'no remote configured')
const rows = await testDb.selectFrom('job_progress' as any).selectAll().execute()
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
job_id: '42',
phase: 'archive.sync',
status: 'skipped',
message: 'no remote configured'
})
expect(rows[0].finished_at).toBeGreaterThan(0)
const row = await testDb.selectFrom('job_progress').selectAll().executeTakeFirstOrThrow()
expect(row).toMatchObject({ status: 'skipped', message: 'no remote configured' })
expect(row.finished_at).toBeGreaterThan(0)
})
it('should fail a phase with an Error', async () => {
it.each([new Error('network error'), 'network error'])('fails a phase: %s', async (error) => {
await jobProgress.start('42', 'archive.sync')
await jobProgress.fail('42', 'archive.sync', new Error('network error'))
const rows = await testDb.selectFrom('job_progress' as any).selectAll().execute()
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
job_id: '42',
phase: 'archive.sync',
status: 'failed',
message: 'network error'
})
expect(rows[0].finished_at).toBeGreaterThan(0)
await jobProgress.fail('42', 'archive.sync', error)
const row = await testDb.selectFrom('job_progress').selectAll().executeTakeFirstOrThrow()
expect(row).toMatchObject({ status: 'failed', message: 'network error' })
expect(row.finished_at).toBeGreaterThan(0)
})
it('should fail a phase with a string', async () => {
await jobProgress.start('42', 'archive.sync')
await jobProgress.fail('42', 'archive.sync', 'string error')
async function log(jobId: string) {
await testDb
.insertInto('job_progress_logs')
.values({
job_id: jobId,
phase: 'render',
line_number: 1,
ts: Date.now(),
level: 'info',
message: 'log',
})
.execute()
}
const rows = await testDb.selectFrom('job_progress' as any).selectAll().execute()
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
job_id: '42',
phase: 'archive.sync',
status: 'failed',
message: 'string error'
})
expect(rows[0].finished_at).toBeGreaterThan(0)
it('lists the distinct union of progress and log IDs, including logs without progress', async () => {
await jobProgress.start('both', 'one')
await jobProgress.start('both', 'two')
await jobProgress.start('progress-only', 'one')
await log('both')
await log('logs-only')
expect((await jobProgress.listJobIds()).sort()).toEqual(['both', 'logs-only', 'progress-only'])
})
it('should purge old records based on started_at', async () => {
// Insert an old record
await testDb.insertInto('job_progress' as any).values({
job_id: '1',
phase: 'old.phase',
status: 'completed',
started_at: Date.now() - 3 * 24 * 60 * 60 * 1000, // 3 days old
}).execute()
// Insert a recent record
await testDb.insertInto('job_progress' as any).values({
job_id: '2',
phase: 'new.phase',
status: 'completed',
started_at: Date.now() - 1 * 24 * 60 * 60 * 1000, // 1 day old
}).execute()
await jobProgress.purgeExpired()
const rows = await testDb.selectFrom('job_progress' as any).selectAll().execute()
expect(rows).toHaveLength(1)
expect(rows[0].job_id).toBe('2')
it('purges only selected IDs in bounded chunks, idempotently', async () => {
const ids = Array.from({ length: 205 }, (_, index) => `job-${index}`)
for (const id of [...ids, 'keep']) {
await jobProgress.start(id, 'render')
await log(id)
}
await jobProgress.purgeJobs(ids)
await jobProgress.purgeJobs(ids)
await jobProgress.purgeJobs([])
expect(await jobProgress.listJobIds()).toEqual(['keep'])
expect(await testDb.selectFrom('job_progress').select('job_id').execute()).toEqual([
{ job_id: 'keep' },
])
expect(await testDb.selectFrom('job_progress_logs').select('job_id').execute()).toEqual([
{ job_id: 'keep' },
])
})
})
it('rolls back logs and earlier chunks and propagates database failures', async () => {
const ids = Array.from({ length: 101 }, (_, index) => `job-${index}`)
for (const id of ids) {
await jobProgress.start(id, 'render')
await log(id)
}
await sql`CREATE TRIGGER prevent_purge BEFORE DELETE ON job_progress
WHEN OLD.job_id = 'job-100' BEGIN SELECT RAISE(FAIL, 'delete failed'); END`.execute(testDb)
await expect(jobProgress.purgeJobs(ids)).rejects.toThrow('delete failed')
expect(await testDb.selectFrom('job_progress').selectAll().execute()).toHaveLength(101)
expect(await testDb.selectFrom('job_progress_logs').selectAll().execute()).toHaveLength(101)
})
})
+17 -35
View File
@@ -3,47 +3,29 @@ import type { AppDatabase } from '../services/db.js'
export class JobProgress {
private readonly db: Kysely<AppDatabase>
private readonly retentionDays: number
constructor(db: Kysely<AppDatabase>, retentionDays: number = 7) {
constructor(db: Kysely<AppDatabase>) {
this.db = db
this.retentionDays = retentionDays
}
async purgeExpiredBatch(limit: number): Promise<string[]> {
try {
const cutoffMs = Date.now() - this.retentionDays * 24 * 60 * 60 * 1000
async listJobIds(): Promise<string[]> {
const rows = await this.db
.selectFrom('job_progress')
.select('job_id')
.union(this.db.selectFrom('job_progress_logs').select('job_id'))
.execute()
return rows.map((row) => row.job_id)
}
const oldJobs = await this.db
.selectFrom('job_progress')
.select('job_id')
.distinct()
.where('started_at', '<', cutoffMs)
.limit(limit)
.execute()
const oldJobIds = oldJobs.map((row) => row.job_id)
if (oldJobIds.length > 0) {
await this.db.deleteFrom('job_progress_logs').where('job_id', 'in', oldJobIds).execute()
await this.db.deleteFrom('job_progress').where('job_id', 'in', oldJobIds).execute()
async purgeJobs(jobIds: string[]): Promise<void> {
if (jobIds.length === 0) return
await this.db.transaction().execute(async (trx) => {
for (let offset = 0; offset < jobIds.length; offset += 100) {
const batch = jobIds.slice(offset, offset + 100)
await trx.deleteFrom('job_progress_logs').where('job_id', 'in', batch).execute()
await trx.deleteFrom('job_progress').where('job_id', 'in', batch).execute()
}
return oldJobIds
} catch (err) {
console.error('JobProgress purgeExpiredBatch failed:', err)
return []
}
}
async purgeExpired(): Promise<string[]> {
const allPurged: string[] = []
let batch: string[] = []
do {
batch = await this.purgeExpiredBatch(100)
allPurged.push(...batch)
} while (batch.length === 100)
return allPurged
})
}
async start(jobId: string, phase: string): Promise<void> {
+10 -7
View File
@@ -14,12 +14,15 @@ import type * as Workflows from './workflows/index.js'
const probe = defineWorkflowSpec<void, string>({ name: 'ShutdownProbe' })
afterEach(() => {
vi.useRealTimers()
vi.doUnmock('./workflows/index.js')
vi.resetModules()
})
test('starts the native worker and health server, then lets active work finish before closing databases', async () => {
vi.resetModules()
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-06-02T12:00:00.000Z'))
const directory = await fs.mkdtemp(path.join(tmpdir(), 'luzzle-worker-'))
const queuePath = path.join(directory, 'queue.sqlite')
const config = validateConfig(
@@ -64,12 +67,12 @@ test('starts the native worker and health server, then lets active work finish b
const { port } = running.server.address() as AddressInfo
expect((await fetch(`http://127.0.0.1:${port}/health`)).status).toBe(200)
const backend = jobs.getOpenWorkflowBackend()
await expect
.poll(
async () =>
(await backend.listWorkflowRuns({ workflowName: 'JobProgressPurge' })).data[0]?.status
)
.toBe('completed')
expect(await jobs.getOpenWorkflow().schedules.get('luzzle:job-progress-purge')).toMatchObject({
cron: '0 48 1 * * *',
timezone: 'UTC',
nextRunAt: new Date('2026-06-03T01:48:00.000Z'),
})
expect((await backend.listWorkflowRuns({ workflowName: 'JobProgressPurge' })).data).toEqual([])
const handle = await jobs.getOpenWorkflow().runWorkflow(probe)
await expect.poll(() => entered).toBe(true)
@@ -104,7 +107,7 @@ test('starts the native worker and health server, then lets active work finish b
)
expect(
(await reopened.listWorkflowRuns({ workflowName: 'JobProgressPurge' })).data
).toHaveLength(1)
).toHaveLength(0)
} finally {
await reopened.stop()
}
-17
View File
@@ -6,14 +6,12 @@ import { createHealthServer } from './services/health.js'
import { createLogger, createOpenWorkflowLogger, log } from './services/logger.js'
import { createWorkerContext, setWorkerContext } from './services/context.js'
import { closeOpenWorkflow, initOpenWorkflow } from '@luzzle/web.jobs'
import { jobProgressPurgeSpec } from '@luzzle/web.jobs/specs'
import { registerWorkflows } from './workflows/index.js'
import { once } from 'node:events'
import type { Server } from 'node:http'
import type { Worker } from 'openworkflow'
const DEFAULT_PORT = 9000
const PURGE_RETENTION_DAYS = 7
async function closeHealthServer(server: Server): Promise<void> {
if (!server.listening) return
@@ -34,11 +32,9 @@ export async function main(
const db = createAppDb(resolveDbPath(config))
const server = createHealthServer()
let worker: Worker | undefined
let purgeInterval: ReturnType<typeof setInterval> | undefined
let stopPromise: Promise<void> | undefined
async function stopResources(): Promise<void> {
clearInterval(purgeInterval)
await closeHealthServer(server)
await worker?.stop()
const errors: unknown[] = []
@@ -82,19 +78,6 @@ export async function main(
await worker.start()
logger.info('openworkflow worker started')
async function enqueuePurge(): Promise<void> {
try {
const run = await openWorkflow.runWorkflow(jobProgressPurgeSpec, {
retentionDays: PURGE_RETENTION_DAYS,
})
logger.info('openworkflow job progress purge enqueued', { jobId: run.workflowRun.id })
} catch {
logger.error('failed to enqueue openworkflow job progress purge')
}
}
void enqueuePurge()
purgeInterval = setInterval(enqueuePurge, 24 * 60 * 60 * 1000)
const listening = once(server, 'listening')
server.listen(port)
await listening
@@ -1,57 +0,0 @@
import { describe, test, expect, vi } from 'vitest'
import { jobProgressPurgeStep } from './job-progress-purge.js'
import { JobProgress } from '../core/job-progress.js'
import type { WorkerContext } from '../services/context.js'
import { promises as fs } from 'node:fs'
const mockPurgeExpired = vi.fn().mockResolvedValue([])
vi.mock('../core/job-progress.js', () => ({
JobProgress: vi.fn().mockImplementation(() => ({
purgeExpired: mockPurgeExpired,
})),
}))
function makeCtx(): WorkerContext {
return {
config: { paths: { assets: '/assets/pieces' } } as unknown as WorkerContext['config'],
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
stdout: vi.fn(),
stderr: vi.fn(),
},
rclone: {} as WorkerContext['rclone'],
db: {} as WorkerContext['db'],
}
}
describe('jobProgressPurgeStep', () => {
test('uses default retentionDays=7', async () => {
mockPurgeExpired.mockResolvedValueOnce([])
const result = await jobProgressPurgeStep.run({}, makeCtx())
expect(result.status).toBe('completed')
expect(JobProgress).toHaveBeenCalledWith(expect.anything(), 7)
})
test('honors explicit retentionDays', async () => {
mockPurgeExpired.mockResolvedValueOnce([])
await jobProgressPurgeStep.run({ retentionDays: 14 }, makeCtx())
expect(JobProgress).toHaveBeenCalledWith(expect.anything(), 14)
})
test('deletes cached preview folders for purged job IDs', async () => {
mockPurgeExpired.mockResolvedValueOnce([101, 102])
const rmSpy = vi.spyOn(fs, 'rm').mockResolvedValue(undefined)
await jobProgressPurgeStep.run({}, makeCtx())
expect(rmSpy).toHaveBeenCalledTimes(2)
expect(rmSpy).toHaveBeenNthCalledWith(1, '/assets/previews/101', { recursive: true, force: true })
expect(rmSpy).toHaveBeenNthCalledWith(2, '/assets/previews/102', { recursive: true, force: true })
rmSpy.mockRestore()
})
})
@@ -1,30 +0,0 @@
import { completed, type Step, type StepResult } from '../core/step.js'
import { JobProgress } from '../core/job-progress.js'
import { promises as fs } from 'node:fs'
import path from 'node:path'
export interface JobProgressPurgeInput {
retentionDays?: number
}
export const jobProgressPurgeStep: Step<JobProgressPurgeInput, void> = {
name: 'job_progress.purge',
async run(input, ctx): Promise<StepResult<void>> {
const { db, logger, config } = ctx
const retentionDays = input.retentionDays ?? 7
const purgedJobIds = await new JobProgress(db, retentionDays).purgeExpired()
for (const jobId of purgedJobIds) {
const previewDir = path.join(path.dirname(config.paths.assets), 'previews', jobId.toString())
try {
await fs.rm(previewDir, { recursive: true, force: true })
logger.info('purged preview assets directory', { jobId, previewDir })
} catch (err) {
logger.warn('failed to purge preview assets directory', { jobId, error: String(err) })
}
}
logger.info('job_progress purge complete', { retentionDays, purgedCount: purgedJobIds.length })
return completed(undefined)
},
}
@@ -1,27 +1,335 @@
import { describe, test, expect, beforeEach } from 'vitest'
import { registerJobProgressPurgeWorkflow } from './job-progress-purge.js'
import { initOpenWorkflow, getOpenWorkflow } from '@luzzle/web.jobs'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { OpenWorkflow, defineWorkflowSpec, type Worker } from 'openworkflow'
import { BackendSqlite } from 'openworkflow/sqlite'
import { createAppDb, runWebMigrations } from '@luzzle/web.db'
import { getOpenWorkflow, getOpenWorkflowBackend } from '@luzzle/web.jobs'
import { jobProgressPurgeSpec } from '@luzzle/web.jobs/specs'
import { promises as fs } from 'node:fs'
import type * as Crypto from 'node:crypto'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { validateConfig } from '@luzzle/web.config'
import { getWorkerContext, type WorkerContext } from '../services/context.js'
import { JobProgress } from '../core/job-progress.js'
import { makeLogger } from '../../test/logger.js'
import { registerJobProgressPurgeWorkflow } from './job-progress-purge.js'
describe('workflows/job-progress-purge', () => {
beforeEach(() => {
initOpenWorkflow({ dbPath: ':memory:' })
const { jitter } = vi.hoisted(() => ({ jitter: vi.fn(() => 0) }))
vi.mock('node:crypto', async (original) => ({
...(await original<typeof Crypto>()),
randomInt: jitter,
}))
vi.mock('@luzzle/web.jobs', () => ({ getOpenWorkflow: vi.fn(), getOpenWorkflowBackend: vi.fn() }))
vi.mock('../services/context.js', () => ({ getWorkerContext: vi.fn() }))
const day = 86_400_000
const now = new Date('2026-06-02T12:00:00Z')
const candidateSpec = defineWorkflowSpec({ name: 'PreviewFixture' })
describe('native preview maintenance', () => {
let directory: string, previews: string
let backend: BackendSqlite, client: OpenWorkflow, worker: Worker
let ctx: WorkerContext, progress: JobProgress
const getRun = (workflowRunId: string) => backend.getWorkflowRun({ workflowRunId })
const steps = async (workflowRunId: string) =>
(await backend.listStepAttempts({ workflowRunId })).data
const preview = (id: string) => path.join(previews, id)
function connect() {
backend = BackendSqlite.connect(path.join(directory, 'workflows.sqlite'))
client = new OpenWorkflow({ backend, logger: makeLogger() })
vi.mocked(getOpenWorkflow).mockReturnValue(client)
vi.mocked(getOpenWorkflowBackend).mockReturnValue(backend)
registerJobProgressPurgeWorkflow()
worker = client.newWorker({ concurrency: 1 })
}
beforeEach(async () => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(now)
jitter.mockReset().mockReturnValue(0)
directory = await fs.mkdtemp(path.join(tmpdir(), 'luzzle-maintenance-'))
previews = path.join(directory, 'previews')
const db = createAppDb(path.join(directory, 'app.sqlite'))
expect((await runWebMigrations(db)).error).toBeUndefined()
const config = validateConfig(
{
storage: { root: directory },
paths: { assets: path.join(directory, 'pieces') },
},
{}
)
ctx = { db, config, logger: makeLogger(), rclone: {} as WorkerContext['rclone'] }
vi.mocked(getWorkerContext).mockReturnValue(ctx)
progress = new JobProgress(db)
connect()
})
afterEach(async () => {
await worker.stop()
vi.restoreAllMocks()
await backend.stop()
await ctx.db.destroy()
await fs.rm(directory, { recursive: true, force: true })
vi.useRealTimers()
})
async function tick() {
expect(await worker.tick()).toBe(1)
await worker.stop()
}
async function seedPreviewData(id: string, rows: 'both' | 'logs' | 'none' = 'both') {
if (rows === 'both') await progress.start(id, 'render')
if (rows !== 'none')
await ctx.db
.insertInto('job_progress_logs')
.values({
job_id: id,
phase: 'render',
line_number: 1,
ts: Date.now(),
level: 'info',
message: 'rendered',
})
.execute()
await fs.mkdir(preview(id), { recursive: true })
await fs.writeFile(path.join(preview(id), 'preview.bin'), 'preview output')
}
async function createCandidateRun(
status = 'completed',
finishedAgeMs = 8 * day,
rows: 'both' | 'logs' | 'none' = 'both'
) {
vi.setSystemTime(now.getTime() - 20 * day)
const options = status === 'pending' ? { availableAt: new Date(now.getTime() + day) } : {}
const { workflowRun: run } = await client.runWorkflow(candidateSpec, {}, options)
const owned = { workflowRunId: run.id, workerId: 'fixture' }
if (status !== 'pending') {
const claim = await backend.claimWorkflowRun({
workerId: 'fixture',
leaseDurationMs: 40 * day,
})
expect(claim?.id).toBe(run.id)
const step = await backend.createStepAttempt({
...owned,
stepName: 'render',
kind: 'function',
config: {},
context: null,
})
await backend.completeStepAttempt({ ...owned, stepAttemptId: step.id, output: 'rendered' })
vi.setSystemTime(now.getTime() - finishedAgeMs)
if (status === 'completed') await backend.completeWorkflowRun({ ...owned, output: 'ok' })
if (status === 'failed')
await backend.failWorkflowRun({
...owned,
error: { message: 'failed' },
retryPolicy: {
maximumAttempts: 1,
initialInterval: '1s',
maximumInterval: '1s',
backoffCoefficient: 1,
},
})
if (status === 'canceled') await backend.cancelWorkflowRun({ workflowRunId: run.id })
}
vi.setSystemTime(now)
await seedPreviewData(run.id, rows)
return run.id
}
async function purge() {
const handle = await client.runWorkflow(jobProgressPurgeSpec, {})
await tick()
return handle.workflowRun.id
}
test('removes aged terminal app data and directories without deleting SDK history', async () => {
const expired = [
await createCandidateRun(),
await createCandidateRun('failed', 8 * day, 'logs'),
await createCandidateRun('canceled', 8 * day, 'none'),
]
const kept = [
await createCandidateRun('completed', day),
await createCandidateRun('completed', 7 * day),
await createCandidateRun('running'),
await createCandidateRun('pending'),
'unknown',
]
await seedPreviewData('unknown')
await progress.start('../outside', 'render')
await fs.writeFile(path.join(directory, 'outside'), 'keep')
const history = await Promise.all(
expired.map(async (id) => [await getRun(id), await steps(id)])
)
await purge()
for (let index = 0; index < expired.length; index++) {
const id = expired[index]
await expect(fs.stat(preview(id))).rejects.toMatchObject({ code: 'ENOENT' })
expect(await progress.listJobIds()).not.toContain(id)
expect([await getRun(id), await steps(id)]).toEqual(history[index])
}
for (const id of kept) {
expect(await fs.readFile(path.join(preview(id), 'preview.bin'), 'utf8')).toBe(
'preview output'
)
expect(await progress.listJobIds()).toContain(id)
}
expect(await fs.readFile(path.join(directory, 'outside'), 'utf8')).toBe('keep')
})
test('registers workflow implementation successfully', () => {
registerJobProgressPurgeWorkflow()
const ow = getOpenWorkflow()
test('honors an explicit retention window and rejects a non-positive window without deleting data', async () => {
const id = await createCandidateRun()
const extended = await client.runWorkflow(jobProgressPurgeSpec, { retentionDays: 14 })
await tick()
expect((await getRun(extended.workflowRun.id))?.status).toBe('completed')
expect(await fs.stat(preview(id))).toBeDefined()
expect(await progress.listJobIds()).toContain(id)
const invalid = await client.runWorkflow(jobProgressPurgeSpec, { retentionDays: 0 })
await tick()
expect((await getRun(invalid.workflowRun.id))?.status).toBe('failed')
expect(await fs.stat(preview(id))).toBeDefined()
expect(await progress.listJobIds()).toContain(id)
})
// Verify the workflow is correctly registered under its name
const registered = (
ow as unknown as {
registry: {
get: (name: string) => { spec: { name: string }; fn: (...args: unknown[]) => unknown }
}
}
).registry.get(jobProgressPurgeSpec.name)
expect(registered).toBeDefined()
expect(registered.spec.name).toBe(jobProgressPurgeSpec.name)
expect(typeof registered.fn).toBe('function')
test('file failure preserves rows and retries saved paths and IDs, even after deleting files', async () => {
const id = await createCandidateRun()
const recent = await createCandidateRun('completed', day)
const list = vi.spyOn(JobProgress.prototype, 'listJobIds')
const rm = fs.rm
vi.spyOn(fs, 'rm').mockImplementationOnce(async (target, options) => {
await rm(target, options)
throw new Error('file deletion interrupted')
})
const purgeId = await purge()
await expect(fs.stat(preview(id))).rejects.toMatchObject({ code: 'ENOENT' })
expect(
await ctx.db
.selectFrom('job_progress_logs')
.select('job_id')
.where('job_id', '=', id)
.execute()
).toHaveLength(1)
expect(
await ctx.db.selectFrom('job_progress').select('job_id').where('job_id', '=', id).execute()
).toHaveLength(1)
const attempts = await steps(purgeId)
expect(attempts.find((step) => step.stepName === 'plan-cleanup')?.output).toEqual({
cutoffMs: now.getTime() - 7 * day,
jobIds: [id],
previewDirs: [preview(id)],
})
expect(attempts.find((step) => step.stepName === 'purge-preview-directories')?.status).toBe(
'failed'
)
ctx.config.paths.assets = path.join(directory, 'different-root', 'pieces')
vi.setSystemTime(now.getTime() + 10 * day)
await tick()
expect(list).toHaveBeenCalledTimes(1)
expect(jitter).toHaveBeenCalledTimes(1)
expect(await progress.listJobIds()).toEqual([recent])
expect(await fs.stat(preview(recent))).toBeDefined()
expect((await getRun(purgeId))?.status).toBe('completed')
})
test.each(['before', 'after'])(
'retries app deletion %s commit after files are gone',
async (boundary) => {
const id = await createCandidateRun()
const original = JobProgress.prototype.purgeJobs
const remove = vi
.spyOn(JobProgress.prototype, 'purgeJobs')
.mockImplementationOnce(async function (this: JobProgress, ids) {
if (boundary === 'after') await original.call(this, ids)
throw new Error('app deletion interrupted')
})
const files = vi.spyOn(fs, 'rm')
const list = vi.spyOn(JobProgress.prototype, 'listJobIds')
const purgeId = await purge()
await expect(fs.stat(preview(id))).rejects.toMatchObject({ code: 'ENOENT' })
expect(await progress.listJobIds()).toEqual(boundary === 'before' ? [id] : [])
list.mockClear()
vi.setSystemTime(now.getTime() + day)
await tick()
expect(list).not.toHaveBeenCalled()
expect(remove.mock.calls).toEqual([[[id]], [[id]]])
expect(files).toHaveBeenCalledTimes(1)
expect(await progress.listJobIds()).toEqual([])
expect((await getRun(purgeId))?.status).toBe('completed')
}
)
test('propagates discovery errors but treats a missing previews root as empty', async () => {
const id = await createCandidateRun()
await fs.rm(previews, { recursive: true })
const list = vi.spyOn(fs, 'readdir').mockRejectedValueOnce(new Error('directory unavailable'))
const purgeId = await purge()
expect(await progress.listJobIds()).toEqual([id])
vi.setSystemTime(now.getTime() + day)
await tick()
expect(list).toHaveBeenCalledTimes(2)
expect(await progress.listJobIds()).toEqual([])
expect((await getRun(purgeId))?.status).toBe('completed')
})
test('persists jitter, releases the only slot during native sleep, and resumes the saved choice', async () => {
jitter.mockReturnValueOnce(1440).mockReturnValueOnce(0)
const first = await purge()
expect(await getRun(first)).toMatchObject({
status: 'running',
workerId: null,
availableAt: new Date(now.getTime() + 1440_000),
})
const attempts = await steps(first)
expect(attempts.find((step) => step.stepName === 'choose-cleanup-jitter')).toMatchObject({
status: 'completed',
output: 1440,
})
expect(attempts.find((step) => step.stepName === 'cleanup-jitter')).toMatchObject({
kind: 'sleep',
})
await backend.stop()
connect()
const second = await purge()
expect((await getRun(second))?.status).toBe('completed')
vi.setSystemTime(now.getTime() + 1440_000)
await tick()
expect((await getRun(first))?.status).toBe('completed')
expect(jitter.mock.calls).toEqual([
[0, 1441],
[0, 1441],
])
})
test('persists cron and pause across restart and coalesces missed occurrences', async () => {
const scheduleId = 'luzzle:job-progress-purge'
await worker.start()
await worker.stop()
const saved = await client.schedules.get(scheduleId)
expect(saved).toMatchObject({
cron: '0 48 1 * * *',
timezone: 'UTC',
input: { retentionDays: 7 },
nextRunAt: new Date('2026-06-03T01:48:00Z'),
})
await backend.stop()
connect()
await worker.start()
await worker.stop()
expect(await client.schedules.get(scheduleId)).toEqual(saved)
await client.schedules.pause(scheduleId)
await backend.stop()
connect()
await worker.start()
await worker.stop()
expect((await client.schedules.get(scheduleId))?.nextRunAt).toBeNull()
await client.schedules.resume(scheduleId)
vi.setSystemTime(new Date('2026-06-06T12:00:00Z'))
expect(await backend.enqueueScheduledRuns({ before: new Date(), limit: 100 })).toBe(1)
expect(await backend.enqueueScheduledRuns({ before: new Date(), limit: 100 })).toBe(0)
const runs = (await backend.listWorkflowRuns({ workflowName: jobProgressPurgeSpec.name })).data
expect(runs).toHaveLength(1)
expect(runs[0].workflowScheduleId).toBe(scheduleId)
await tick()
expect((await getRun(runs[0].id))?.status).toBe('completed')
expect((await client.schedules.get(scheduleId))?.nextRunAt).toEqual(
new Date('2026-06-07T01:48:00Z')
)
})
})
@@ -1,46 +1,67 @@
import { getOpenWorkflow } from '@luzzle/web.jobs'
import { getOpenWorkflow, getOpenWorkflowBackend } from '@luzzle/web.jobs'
import { jobProgressPurgeSpec } from '@luzzle/web.jobs/specs'
import { getWorkerContext } from '../services/context.js'
import { JobProgress } from '../core/job-progress.js'
import { randomInt } from 'node:crypto'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { getWorkerContext } from '../services/context.js'
import { JobProgress } from '../core/job-progress.js'
export function registerJobProgressPurgeWorkflow(): void {
const openWorkflow = getOpenWorkflow()
openWorkflow.implementWorkflow(jobProgressPurgeSpec, async ({ input, step }) => {
const ctx = getWorkerContext()
const { db, logger, config } = ctx
const retentionDays = input.retentionDays ?? 7
if (!Number.isFinite(retentionDays) || retentionDays <= 0) {
throw new Error('Cleanup retentionDays must be positive and finite')
}
const delaySeconds = await step.run({ name: 'choose-cleanup-jitter' }, () => randomInt(0, 1441))
if (delaySeconds > 0) await step.sleep('cleanup-jitter', `${delaySeconds}s`)
const purgedJobIds = await step.run({ name: 'purge-db-entries' }, async () => {
return await new JobProgress(db, retentionDays).purgeExpired()
const { db, logger, config } = getWorkerContext()
const progress = new JobProgress(db)
const plan = await step.run({ name: 'plan-cleanup' }, async () => {
const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000
const previewsRoot = path.resolve(path.dirname(config.paths.assets), 'previews')
let previewNames: string[]
try {
previewNames = (await fs.readdir(previewsRoot, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
previewNames = []
}
const directories = new Set(previewNames)
const candidates = new Set([...(await progress.listJobIds()), ...previewNames])
const jobIds: string[] = []
const previewDirs: string[] = []
const backend = getOpenWorkflowBackend()
for (const jobId of candidates) {
if (!/^[a-zA-Z0-9_-]+$/.test(jobId)) continue
const previewDir = path.resolve(previewsRoot, jobId)
if (path.dirname(previewDir) !== previewsRoot) continue
const run = await backend.getWorkflowRun({ workflowRunId: jobId })
if (!run) continue
if (!['completed', 'failed', 'canceled', 'succeeded'].includes(run.status)) continue
if (!run.finishedAt) continue
const isExpired = run.finishedAt.getTime() < cutoffMs
if (!isExpired) continue
jobIds.push(jobId)
if (directories.has(jobId)) previewDirs.push(previewDir)
}
return { cutoffMs, jobIds, previewDirs }
})
await step.run({ name: 'purge-disk-directories' }, async () => {
for (const jobId of purgedJobIds) {
const previewDir = path.join(
path.dirname(config.paths.assets),
'previews',
jobId.toString()
)
try {
await fs.rm(previewDir, { recursive: true, force: true })
logger.info('purged preview assets directory', { jobId, previewDir })
} catch (err) {
logger.warn('failed to purge preview assets directory', {
jobId,
error: String(err),
})
}
await step.run({ name: 'purge-preview-directories' }, async () => {
for (const previewDir of plan.previewDirs) {
await fs.rm(previewDir, { recursive: true, force: true })
}
})
logger.info('openworkflow purge complete', {
retentionDays,
purgedCount: purgedJobIds.length,
await step.run({ name: 'purge-app-rows' }, async () => {
await progress.purgeJobs(plan.jobIds)
})
logger.info('openworkflow purge complete', { purgedCount: plan.jobIds.length })
return 'ok'
})
}