feat(server): unified scheduled-job registry and scheduler; module timers moved onto it
- modules/schedules: registerScheduledJob({key, defaults, run}) registry; defaults are
synced into scheduled_job without overriding user-changed enabled/schedule
- scheduler runs only on the io worker, one instance via pg_try_advisory_lock held on a
dedicated connection; due = enabled and next_run_at reached, or run requested;
jobs run in parallel, never re-entrant; interval next = finish + period, cron next match;
run-now does not move next_run_at and merges duplicate requests; skip advances one period;
history trimmed to the last 50 runs; interrupted runs marked failed on takeover
- agent_check jobs: trigger condition checked first, goal template rendered, runAgent
enqueued via startRun(trigger=schedule) with allowlist and budget, one run per time slot;
allowRecover uses the autoInvestigate grant check; results harvested back into the run
history and optionally reported as an info decision; 3 built-in checks, off by default
- sources-identity (feeds.poll, sites.snapshot, identity.review), acquisition-organize
(downloads.sync, cache.threshold, downloads.orchestrate, slots.reevaluate,
subscriptions.search, subscriptions.metadata, library.reconcile) and file-manager
(trash.purge) register jobs instead of running their own setInterval; default
intervals unchanged; backup registered as an unconfigured placeholder
- endpoints under /schedules (admin, console session only)
TODO: pi refreshPiTokens and auth-settings session cleanup still own their timers.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f8f70d8e09
commit
a68fcea13b
16 files changed
+1732
-127
No files matched your search
@@ -0,0 +1,99 @@
|
||||
import type { ScheduleGroup, ScheduleKind, ScheduleSpec, ScheduleTrigger } from "@umt/contracts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
|
||||
// 定时任务注册表(进程内):模块在 start() 里登记 {key, defaults, run},schedules 模块的调度器
|
||||
// 把定义同步进 scheduled_job 表(不覆盖用户改过的开关/调度),按 next_run_at 调用 run。
|
||||
// 各模块只 import 本文件(不依赖调度器实现);没装 schedules 模块时登记只是一张内存表,不会执行。
|
||||
|
||||
export interface ScheduledJobDefaults {
|
||||
name: string;
|
||||
description?: string;
|
||||
group: ScheduleGroup;
|
||||
schedule: ScheduleSpec;
|
||||
/** 缺省 true */
|
||||
enabled?: boolean;
|
||||
/** 任务参数(看板可改;task 浅合并,agent_check 整体替换) */
|
||||
config?: Record<string, unknown>;
|
||||
/** false = 占位(如备份未配置),不执行、不可开启 */
|
||||
available?: boolean;
|
||||
/** 首次登记时何时第一次运行:now(缺省,尽快)/ after_interval(一个周期后) */
|
||||
firstRun?: "now" | "after_interval";
|
||||
}
|
||||
|
||||
export interface JobRunContext {
|
||||
jobId: string;
|
||||
/** scheduled_job_run.id */
|
||||
runId: string;
|
||||
trigger: Exclude<ScheduleTrigger, "skip">;
|
||||
now: Date;
|
||||
/** 当前任务参数(表里的 config) */
|
||||
config: Record<string, unknown>;
|
||||
/**
|
||||
* 时间槽:按调度周期取整的稳定串(interval:floor(now/everyMs);cron:本次计划时间)。
|
||||
* 入队幂等键用它拼,多实例 / 重复触发同槽只入队一次;立即执行用 manual:<runId>。
|
||||
*/
|
||||
slot: string;
|
||||
}
|
||||
|
||||
export interface JobRunResult {
|
||||
/** 缺省 ok;业务侧主动放弃(如只读对账模式)返回 skipped */
|
||||
status?: "ok" | "skipped";
|
||||
summary?: string | null;
|
||||
agentRunId?: string | null;
|
||||
}
|
||||
|
||||
export type JobRunFn = (c: JobRunContext) => Promise<JobRunResult | undefined | void>;
|
||||
|
||||
export interface ScheduledJobDef {
|
||||
/** 稳定 key(表主键),如 feeds.poll */
|
||||
key: string;
|
||||
kind?: ScheduleKind;
|
||||
defaults: ScheduledJobDefaults;
|
||||
run: JobRunFn;
|
||||
}
|
||||
|
||||
export interface ScheduleRegistry {
|
||||
/** 每次登记 +1,调度器据此判断是否需要重新同步到表 */
|
||||
readonly version: number;
|
||||
get(key: string): ScheduledJobDef | undefined;
|
||||
list(): ScheduledJobDef[];
|
||||
register(def: ScheduledJobDef): void;
|
||||
unregister(key: string): void;
|
||||
}
|
||||
|
||||
class MemoryRegistry implements ScheduleRegistry {
|
||||
version = 0;
|
||||
private defs = new Map<string, ScheduledJobDef>();
|
||||
get(key: string) {
|
||||
return this.defs.get(key);
|
||||
}
|
||||
list() {
|
||||
return [...this.defs.values()];
|
||||
}
|
||||
register(def: ScheduledJobDef) {
|
||||
if (!/^[a-z][a-z0-9_.:-]{1,120}$/.test(def.key)) throw new Error(`定时任务 key 不合法:${def.key}`);
|
||||
this.defs.set(def.key, def);
|
||||
this.version += 1;
|
||||
}
|
||||
unregister(key: string) {
|
||||
if (this.defs.delete(key)) this.version += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const registries = new WeakMap<object, ScheduleRegistry>();
|
||||
|
||||
/** 取 ctx 对应的注册表(同一 ServerContext 共用一张) */
|
||||
export function scheduleRegistry(ctx: Pick<ServerContext, "db">): ScheduleRegistry {
|
||||
const k = ctx as object;
|
||||
let r = registries.get(k);
|
||||
if (!r) {
|
||||
r = new MemoryRegistry();
|
||||
registries.set(k, r);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 模块 start() 里调用:登记一个周期任务(同 key 重复登记以最后一次为准) */
|
||||
export function registerScheduledJob(ctx: Pick<ServerContext, "db">, def: ScheduledJobDef): void {
|
||||
scheduleRegistry(ctx).register(def);
|
||||
}
|
||||
Reference in new issue
Block a user