From ee39633ad71605aef814af106b244c6b3cc75e53 Mon Sep 17 00:00:00 2001 From: Rawman Grey Date: Sun, 25 Oct 2026 14:26:49 +0300 Subject: [PATCH] feat: in-process nightly scheduler + pause at window end Hook registers a 15-minute schedule that starts/resumes the backfill only inside the maintenance window (no external cron or token). The backfill pauses when the window closes and resumes next night. UI shows paused state and last-run errors. --- AGENTS.md | 8 ++++++++ README.md | 10 +++++----- src/hook/index.ts | 34 +++++++++++++++++++++++++++++++++- src/lib/backfill.ts | 8 +++++++- src/lib/types.ts | 1 + src/module/module.vue | 7 ++++++- 6 files changed, 60 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bf44928..abded86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,14 @@ pacing). - `state.jsonl` = append-only completed `{file_id,preset_key,format}` → resume. - All state is **file-backed** (PM2 cluster, 8 workers — never in-memory). +## Nightly scheduling + +- The hook registers `schedule('*/15 * * * *')` — **in-process**, no external cron/token. Every tick it + checks enabled + maintenance window + no active run, then starts/resumes the backfill. +- The backfill **pauses** when the window closes (`run.paused = true`) and resumes next night. +- `scheduleSynchronizedJob` makes the cron cluster-safe; the `run.lock` file is a second guard. +- `Run now` in the admin module ignores the window (`ignoreWindow: true`). + ## Gotchas learned the hard way - **Directus coerces env values**: `"true"` arrives as boolean `true`, `"1"` as number. Env parsing must diff --git a/README.md b/README.md index c9708d1..0ab3c68 100644 --- a/README.md +++ b/README.md @@ -110,12 +110,12 @@ The admin module includes a full API reference with curl examples. ## Scheduling -Scheduled runs are window-bound; an external cron can trigger them: +The extension schedules itself **inside Directus** — no external cron, no token: -```bash -0 * * * * curl -fsS -X POST http://localhost:8055/image-previews/run \ - -H 'Authorization: Bearer ' -H 'Content-Type: application/json' -d '{}' -``` +- every 15 minutes it checks whether it is inside the maintenance window; +- if enabled, inside the window, and no run is active, it starts/resumes the backfill; +- the backfill **pauses when the window closes** and resumes the next night (it is resumable); +- once all tasks are done, it stays idle until the next day. `Run now` in the admin module ignores the window. diff --git a/src/hook/index.ts b/src/hook/index.ts index 6807dc4..021ac3c 100644 --- a/src/hook/index.ts +++ b/src/hook/index.ts @@ -1,8 +1,11 @@ import { defineHook } from '@directus/extensions-sdk'; +import { runBackfill } from '../lib/backfill'; import { recordOp } from '../lib/diagnostics'; import { buildRegistry, isImageType, loadCustomPresets } from '../lib/presets'; import { readSettings } from '../lib/settings'; +import { readRun } from '../lib/state'; import { warmFile } from '../lib/warmer'; +import { isInWindow } from '../lib/window'; interface UploadMeta { key?: string; @@ -10,13 +13,19 @@ interface UploadMeta { payload?: { type?: string | null; collection?: string }; } -export default defineHook(({ action }, { services, getSchema, env, logger }) => { +export default defineHook(({ action, schedule }, { services, database, getSchema, env, logger }) => { action('files.upload', (meta) => { void warmUpload(meta as UploadMeta).catch((error) => logger.error(error, 'image-previews: upload warm failed'), ); }); + schedule('*/15 * * * *', () => { + void scheduledBackfill().catch((error) => + logger.error(error, 'image-previews: scheduled run failed'), + ); + }); + async function warmUpload(meta: UploadMeta): Promise { const settings = await readSettings(env); @@ -53,4 +62,27 @@ export default defineHook(({ action }, { services, getSchema, env, logger }) => if (settings.debug) logger.info({ fileId, summary }, 'image-previews: warmed upload'); } + + async function scheduledBackfill(): Promise { + const settings = await readSettings(env); + + if (!settings.enabled) return; + if (!isInWindow(settings.windowStart, settings.windowEnd)) return; + + const run = await readRun(env); + + if (run?.running) return; + + const finishedToday = run?.finishedAt + ? new Date(run.finishedAt).toDateString() === new Date().toDateString() + : false; + + if (finishedToday && run && run.done >= run.total) return; + + logger.info('image-previews: scheduled backfill starting'); + + void runBackfill({ services, database, getSchema, env, logger }, {}).catch((error) => + logger.error(error, 'image-previews: scheduled backfill failed'), + ); + } }); diff --git a/src/lib/backfill.ts b/src/lib/backfill.ts index c688d1c..cd32525 100644 --- a/src/lib/backfill.ts +++ b/src/lib/backfill.ts @@ -89,6 +89,7 @@ export async function runBackfill(deps: BackfillDeps, options: BackfillOptions = rate: 0, eta: null, cancelled: false, + paused: false, }; await writeRun(run, deps.env); @@ -119,6 +120,11 @@ export async function runBackfill(deps: BackfillDeps, options: BackfillOptions = if (!Array.isArray(files) || files.length === 0) break; for (const file of files) { + if (!options.ignoreWindow && !isInWindow(settings.windowStart, settings.windowEnd)) { + run.paused = true; + break; + } + if (await isCancelled(deps.env)) { run.cancelled = true; break; @@ -209,7 +215,7 @@ export async function runBackfill(deps: BackfillDeps, options: BackfillOptions = await writeRun(run, deps.env); } - if (run.cancelled || files.length < PAGE_SIZE) break; + if (run.cancelled || run.paused || files.length < PAGE_SIZE) break; page += 1; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 77290f2..c5c232d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -47,5 +47,6 @@ export interface RunState { rate: number; eta: number | null; cancelled: boolean; + paused?: boolean; error?: string; } diff --git a/src/module/module.vue b/src/module/module.vue index 7e35517..f400a6e 100644 --- a/src/module/module.vue +++ b/src/module/module.vue @@ -4,6 +4,9 @@ Preview generation is disabled. Enable it in Configuration below. + + Last run error: {{ status.run.error }} +
@@ -35,7 +38,7 @@

{{ status?.enabled ? 'enabled' : 'disabled' }} · - {{ status?.inWindow ? 'inside maintenance window' : 'outside maintenance window' }} · + {{ status?.run?.paused ? 'paused (window closed)' : status?.inWindow ? 'inside maintenance window' : 'outside maintenance window' }} · next window {{ status?.nextWindowHuman ?? '—' }}

@@ -228,6 +231,8 @@ interface RunState { etaHuman: string; startedAtHuman: string | null; finishedAtHuman: string | null; + paused?: boolean; + error?: string; } interface Status {