@softure-ai/jobs 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +75 -10
- package/dist/index.d.ts +54 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/options.d.ts +57 -4
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +40 -10
- package/dist/options.js.map +1 -1
- package/dist/schema.d.ts +92 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +8 -1
- package/dist/schema.js.map +1 -1
- package/dist/server/claim.d.ts +4 -1
- package/dist/server/claim.d.ts.map +1 -1
- package/dist/server/claim.js +13 -3
- package/dist/server/claim.js.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/maintenance.d.ts +5 -2
- package/dist/server/maintenance.d.ts.map +1 -1
- package/dist/server/maintenance.js +5 -3
- package/dist/server/maintenance.js.map +1 -1
- package/dist/server/run-due.d.ts +1 -1
- package/dist/server/run-due.d.ts.map +1 -1
- package/dist/server/run-due.js +3 -1
- package/dist/server/run-due.js.map +1 -1
- package/dist/server/schedules.d.ts +45 -0
- package/dist/server/schedules.d.ts.map +1 -0
- package/dist/server/schedules.js +97 -0
- package/dist/server/schedules.js.map +1 -0
- package/dist/worker/run-job-worker.d.ts.map +1 -1
- package/dist/worker/run-job-worker.js +21 -3
- package/dist/worker/run-job-worker.js.map +1 -1
- package/migrations/0002_create_schedules.sql +12 -0
- package/module.json +2 -2
- package/package.json +1 -1
- package/src/index.ts +11 -4
- package/src/options.ts +61 -25
- package/src/schema.ts +9 -1
- package/src/server/claim.ts +11 -3
- package/src/server/index.ts +7 -0
- package/src/server/maintenance.ts +7 -3
- package/src/server/run-due.ts +3 -1
- package/src/server/schedules.ts +121 -0
- package/src/worker/run-job-worker.ts +18 -3
package/src/options.ts
CHANGED
|
@@ -7,14 +7,34 @@ const DAY_SECONDS = 24 * 60 * 60;
|
|
|
7
7
|
|
|
8
8
|
const backoffSchema = z
|
|
9
9
|
.strictObject({
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
/** Delay before the second attempt; each later one doubles it. */
|
|
11
|
+
baseSeconds: z.number().int().min(1).max(DAY_SECONDS).default(10),
|
|
12
|
+
/** The longest delay between two attempts. */
|
|
13
|
+
maxSeconds: z.number().int().min(1).max(7 * DAY_SECONDS).default(3600),
|
|
14
|
+
})
|
|
15
|
+
.refine((backoff) => backoff.maxSeconds >= backoff.baseSeconds, {
|
|
16
|
+
message: "maxSeconds must be at least baseSeconds",
|
|
17
|
+
path: ["maxSeconds"],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const EVERY = /^([1-9][0-9]{0,4})(m|h|d)$/;
|
|
21
|
+
const UNIT_SECONDS = { m: 60, h: 60 * 60, d: DAY_SECONDS } as const;
|
|
22
|
+
const MAX_EVERY_SECONDS = 7 * DAY_SECONDS;
|
|
23
|
+
const EVERY_HINT = "must be a whole number of minutes, hours or days (e.g. 15m, 1h, 1d), from 1m to 7d";
|
|
24
|
+
|
|
25
|
+
/** Seconds of an `every` text such as `15m`, `1h` or `1d`; null when it is not one or is outside 1m to 7d. */
|
|
26
|
+
export function parseEverySeconds(every: string): number | null {
|
|
27
|
+
const match = EVERY.exec(every);
|
|
28
|
+
if (match === null) return null;
|
|
29
|
+
const seconds = Number(match[1]) * UNIT_SECONDS[match[2] as keyof typeof UNIT_SECONDS];
|
|
30
|
+
return seconds <= MAX_EVERY_SECONDS ? seconds : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const scheduleSchema = z.strictObject({
|
|
34
|
+
/** How often the schedule runs: `15m`, `1h`, `1d`. Runs start on whole periods counted from 1970-01-01 00:00 UTC. */
|
|
35
|
+
every: z.string().refine((every) => parseEverySeconds(every) !== null, { message: EVERY_HINT }),
|
|
36
|
+
/** The payload of every run; default `{}`. */
|
|
37
|
+
payload: z.record(z.string(), z.unknown()).default({}),
|
|
18
38
|
});
|
|
19
39
|
|
|
20
40
|
const queueSchema = z.strictObject({
|
|
@@ -40,25 +60,41 @@ const retentionSchema = z.strictObject({
|
|
|
40
60
|
cancelledDays: z.number().int().min(0).max(3650).default(7),
|
|
41
61
|
});
|
|
42
62
|
|
|
43
|
-
export const jobsOptionsSchema = z
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
63
|
+
export const jobsOptionsSchema = z
|
|
64
|
+
.strictObject({
|
|
65
|
+
/** The queues, by name. A job is enqueued to one of them and runs with its settings. */
|
|
66
|
+
queues: z.record(z.string(), queueSchema).default({}).superRefine(checkNames),
|
|
67
|
+
/**
|
|
68
|
+
* Recurring runs, by queue name: the worker enqueues one job per period (`every`) however many workers run. A
|
|
69
|
+
* schedule's queue takes the default queue settings unless `queues` names it.
|
|
70
|
+
*/
|
|
71
|
+
schedules: z.record(z.string(), scheduleSchema).default({}).superRefine(checkNames),
|
|
72
|
+
/** How long an idle worker waits before it looks for due jobs again. */
|
|
73
|
+
pollIntervalMs: z.number().int().min(50).max(60_000).default(1000),
|
|
74
|
+
/** How long a stopping worker waits for running handlers before it releases their jobs. */
|
|
75
|
+
shutdownGraceMs: z.number().int().min(0).max(10 * 60_000).default(20_000),
|
|
76
|
+
/** How long finished jobs are kept; the worker prunes once an hour. */
|
|
77
|
+
retention: retentionSchema.default({ succeededDays: 7, failedDays: 30, cancelledDays: 7 }),
|
|
78
|
+
})
|
|
79
|
+
.superRefine((options, context) => {
|
|
80
|
+
if (Object.keys(options.queues).length === 0 && Object.keys(options.schedules).length === 0) {
|
|
81
|
+
context.addIssue({ code: "custom", path: ["queues"], message: "must define at least one queue or schedule" });
|
|
52
82
|
}
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
83
|
+
})
|
|
84
|
+
.transform((options) => {
|
|
85
|
+
const scheduledOnly = Object.keys(options.schedules).filter((name) => !Object.hasOwn(options.queues, name));
|
|
86
|
+
return { ...options, queues: { ...options.queues, ...Object.fromEntries(scheduledOnly.map((name) => [name, queueSchema.parse({})])) } };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function checkNames(record: Record<string, unknown>, context: z.RefinementCtx): void {
|
|
90
|
+
for (const name of Object.keys(record).filter((candidate) => !QUEUE_NAME.test(candidate))) {
|
|
91
|
+
context.addIssue({ code: "custom", path: [name], message: QUEUE_NAME_HINT });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
61
94
|
|
|
62
95
|
export type JobsOptionsInput = z.input<typeof jobsOptionsSchema>;
|
|
63
96
|
export type JobsOptions = z.output<typeof jobsOptionsSchema>;
|
|
64
97
|
export type JobQueueOptions = JobsOptions["queues"][string];
|
|
98
|
+
export type JobScheduleOptions = JobsOptions["schedules"][string];
|
|
99
|
+
/** Schedules as an app or a package writes them, e.g. a package's ready-made prune schedules. */
|
|
100
|
+
export type JobSchedulesInput = NonNullable<JobsOptionsInput["schedules"]>;
|
package/src/schema.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Drizzle view of the module's
|
|
1
|
+
// Drizzle view of the module's tables (migrations/). The migrations are the source of truth; this
|
|
2
2
|
// file only types the queries.
|
|
3
3
|
import { bigint, integer, jsonb, pgSchema, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
4
4
|
|
|
@@ -23,3 +23,11 @@ export const jobs = jobsSchema.table("jobs", {
|
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
export type JobRow = typeof jobs.$inferSelect;
|
|
26
|
+
|
|
27
|
+
export const schedules = jobsSchema.table("schedules", {
|
|
28
|
+
name: text("name").primaryKey(),
|
|
29
|
+
lastSlotAt: timestamp("last_slot_at", { withTimezone: true }).notNull(),
|
|
30
|
+
lastSucceededAt: timestamp("last_succeeded_at", { withTimezone: true }),
|
|
31
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
|
32
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
|
|
33
|
+
});
|
package/src/server/claim.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { jobs } from "../schema.js";
|
|
|
6
6
|
import type { JobsContext } from "./context.js";
|
|
7
7
|
import { toJob } from "./job-row.js";
|
|
8
8
|
import { getJobsOptions, getQueue } from "./options.js";
|
|
9
|
+
import { recordScheduleSuccess } from "./schedules.js";
|
|
9
10
|
|
|
10
11
|
/** A job this worker holds: the job and the token its writes are fenced with. */
|
|
11
12
|
export interface ClaimedJob {
|
|
@@ -87,7 +88,10 @@ export async function extendJobLease(ctx: JobsContext, claimed: ClaimedJob): Pro
|
|
|
87
88
|
return rows.length > 0;
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Records the end of an attempt; a success of a scheduled queue also updates its schedule, in the same transaction.
|
|
93
|
+
* False when the job is no longer this claim's: nothing was written.
|
|
94
|
+
*/
|
|
91
95
|
export async function completeJob(ctx: JobsContext, claimed: ClaimedJob, completion: Completion): Promise<boolean> {
|
|
92
96
|
const now = ctx.clock.now();
|
|
93
97
|
const unlocked = { lockedUntil: null, lockedBy: null, claimToken: null, updatedAt: now };
|
|
@@ -97,8 +101,12 @@ export async function completeJob(ctx: JobsContext, claimed: ClaimedJob, complet
|
|
|
97
101
|
: completion.kind === "retry"
|
|
98
102
|
? { ...unlocked, status: "pending" as const, runAt: completion.runAt, lastError: completion.error }
|
|
99
103
|
: { ...unlocked, status: "failed" as const, finishedAt: now, lastError: completion.error };
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
return ctx.db.transaction(async (tx) => {
|
|
105
|
+
const rows = await tx.update(jobs).set(set).where(isHeld(claimed)).returning();
|
|
106
|
+
if (rows.length === 0) return false;
|
|
107
|
+
if (completion.kind === "succeeded") await recordScheduleSuccess({ ...ctx, db: tx }, claimed.job.queue);
|
|
108
|
+
return true;
|
|
109
|
+
});
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
/**
|
package/src/server/index.ts
CHANGED
|
@@ -16,4 +16,11 @@ export { consoleJobsLogger, type JobsLogFields, type JobsLogger } from "./logger
|
|
|
16
16
|
export { getQueueStats, pruneJobs, type QueueStats } from "./maintenance.js";
|
|
17
17
|
export { getJobsOptions } from "./options.js";
|
|
18
18
|
export { runClaimedJob, type RunClaimedJobOptions } from "./process.js";
|
|
19
|
+
export {
|
|
20
|
+
enqueueDueSchedules,
|
|
21
|
+
getScheduleSlot,
|
|
22
|
+
SCHEDULE_DEDUP_KEY,
|
|
23
|
+
type EnqueueDueSchedulesInput,
|
|
24
|
+
type ScheduleState,
|
|
25
|
+
} from "./schedules.js";
|
|
19
26
|
export { runDueJobs, type RunDueJobsOptions, type RunDueJobsReport } from "./run-due.js";
|
|
@@ -4,6 +4,7 @@ import { JOB_STATUSES, type JobStatus } from "../contract.js";
|
|
|
4
4
|
import { jobs } from "../schema.js";
|
|
5
5
|
import type { JobsContext } from "./context.js";
|
|
6
6
|
import { getJobsOptions } from "./options.js";
|
|
7
|
+
import { getScheduleStates, type ScheduleState } from "./schedules.js";
|
|
7
8
|
|
|
8
9
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
9
10
|
/** Rows deleted per statement, so pruning a large backlog never holds one long lock. */
|
|
@@ -39,11 +40,13 @@ export interface QueueStats {
|
|
|
39
40
|
readonly due: number;
|
|
40
41
|
/** The `run_at` of the oldest due pending job, or null: how far behind the workers are. */
|
|
41
42
|
readonly oldestDueAt: Date | null;
|
|
43
|
+
/** The queue's schedule, when `schedules` names it: when it last ran and whether it is late. */
|
|
44
|
+
readonly schedule: ScheduleState | null;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
/**
|
|
45
|
-
* Counts per queue: every configured queue, plus any queue that still has rows in the table
|
|
46
|
-
* script or a health check of the app's own (`ops({ checks })`).
|
|
48
|
+
* Counts per queue: every configured queue, plus any queue that still has rows in the table, with the state of its
|
|
49
|
+
* schedule. For a dashboard, an ops script or a health check of the app's own (`ops({ checks })`).
|
|
47
50
|
*/
|
|
48
51
|
export async function getQueueStats(ctx: JobsContext): Promise<QueueStats[]> {
|
|
49
52
|
const now = ctx.clock.now();
|
|
@@ -56,12 +59,13 @@ export async function getQueueStats(ctx: JobsContext): Promise<QueueStats[]> {
|
|
|
56
59
|
.from(jobs)
|
|
57
60
|
.where(and(eq(jobs.status, "pending"), sql`${jobs.runAt} <= ${now}`))
|
|
58
61
|
.groupBy(jobs.queue);
|
|
62
|
+
const scheduleStates = await getScheduleStates(ctx);
|
|
59
63
|
|
|
60
64
|
const names = new Set([...Object.keys(getJobsOptions(ctx.config).queues), ...counted.map((row) => row.queue)]);
|
|
61
65
|
return [...names].map((queue) => {
|
|
62
66
|
const counts = Object.fromEntries(JOB_STATUSES.map((status) => [status, 0])) as Record<JobStatus, number>;
|
|
63
67
|
for (const row of counted.filter((candidate) => candidate.queue === queue)) counts[row.status] = row.count;
|
|
64
68
|
const due = backlog.find((row) => row.queue === queue);
|
|
65
|
-
return { queue, counts, due: due?.due ?? 0, oldestDueAt: due?.oldestDueAt ?? null };
|
|
69
|
+
return { queue, counts, due: due?.due ?? 0, oldestDueAt: due?.oldestDueAt ?? null, schedule: scheduleStates.get(queue) ?? null };
|
|
66
70
|
});
|
|
67
71
|
}
|
package/src/server/run-due.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { consoleJobsLogger, type JobsLogger } from "./logger.js";
|
|
|
7
7
|
import { getJobsOptions } from "./options.js";
|
|
8
8
|
import { runClaimedJob } from "./process.js";
|
|
9
9
|
import { checkHandlers } from "./handler-check.js";
|
|
10
|
+
import { enqueueDueSchedules } from "./schedules.js";
|
|
10
11
|
|
|
11
12
|
const DEFAULT_MAX_JOBS = 1000;
|
|
12
13
|
const NEVER_ABORTED = new AbortController().signal;
|
|
@@ -33,11 +34,12 @@ export interface RunDueJobsReport {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
|
-
*
|
|
37
|
+
* Enqueues the due runs of the schedules among its queues, then claims and runs due jobs until none is due (a retried job is due later, so it waits for the clock). Returns what
|
|
37
38
|
* happened. Throws on a handler for a queue the config does not define; database failures propagate.
|
|
38
39
|
*/
|
|
39
40
|
export async function runDueJobs(ctx: JobsContext, options: RunDueJobsOptions): Promise<RunDueJobsReport> {
|
|
40
41
|
const queues = checkHandlers(getJobsOptions(ctx.config), options.handlers, options.queues);
|
|
42
|
+
await enqueueDueSchedules(ctx, { queues });
|
|
41
43
|
const report = { succeeded: 0, retried: 0, failed: 0, stale: 0 };
|
|
42
44
|
const maxJobs = options.maxJobs ?? DEFAULT_MAX_JOBS;
|
|
43
45
|
const runOptions = {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Recurring schedules: each schedule's runs start on whole periods counted from the Unix epoch, so every worker
|
|
2
|
+
// computes the same period. A worker takes a period with a conditional upsert of `jobs.schedules` and enqueues its run
|
|
3
|
+
// in the same transaction, so each period is enqueued once however many workers race for it.
|
|
4
|
+
import { eq, inArray, lt } from "drizzle-orm";
|
|
5
|
+
import { parseEverySeconds, type JobScheduleOptions, type JobsOptions } from "../options.js";
|
|
6
|
+
import { schedules } from "../schema.js";
|
|
7
|
+
import type { JobsContext } from "./context.js";
|
|
8
|
+
import { enqueueJob } from "./enqueue.js";
|
|
9
|
+
import { getJobsOptions } from "./options.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The dedup key of every scheduled run: while one run of a schedule is pending or running (retrying a failure, say),
|
|
13
|
+
* the next period joins it instead of piling up a second run behind it.
|
|
14
|
+
*/
|
|
15
|
+
export const SCHEDULE_DEDUP_KEY = "schedule";
|
|
16
|
+
|
|
17
|
+
/** A schedule's health as `getQueueStats` reports it. */
|
|
18
|
+
export interface ScheduleState {
|
|
19
|
+
readonly everySeconds: number;
|
|
20
|
+
/** The start of the latest period a run was enqueued for; null before any worker saw the schedule. */
|
|
21
|
+
readonly lastEnqueuedAt: Date | null;
|
|
22
|
+
/** When a job of the schedule's queue last succeeded; null when none has yet. */
|
|
23
|
+
readonly lastSucceededAt: Date | null;
|
|
24
|
+
/**
|
|
25
|
+
* True when no run succeeded within two periods (counted from when a worker first saw the schedule), or no worker
|
|
26
|
+
* has seen it at all: no worker runs its queue, or its runs keep failing.
|
|
27
|
+
*/
|
|
28
|
+
readonly isLate: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface EnqueueDueSchedulesInput {
|
|
32
|
+
/** Only the schedules of these queues; default every schedule. */
|
|
33
|
+
readonly queues?: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The start of the period `now` falls in. */
|
|
37
|
+
export function getScheduleSlot(schedule: JobScheduleOptions, now: Date): Date {
|
|
38
|
+
const periodMs = getEverySeconds(schedule) * 1000;
|
|
39
|
+
return new Date(Math.floor(now.getTime() / periodMs) * periodMs);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Enqueues a run for every schedule whose current period has none yet, and returns their queue names. Missed periods
|
|
44
|
+
* (no worker ran) collapse into one run for the current period. The worker calls it; an app that drains its queues
|
|
45
|
+
* from a cron (`runDueJobs`) gets it from there. Database failures propagate.
|
|
46
|
+
*/
|
|
47
|
+
export async function enqueueDueSchedules(ctx: JobsContext, input: EnqueueDueSchedulesInput = {}): Promise<string[]> {
|
|
48
|
+
const options = getJobsOptions(ctx.config);
|
|
49
|
+
const enqueued: string[] = [];
|
|
50
|
+
for (const name of getScheduleNames(options, input.queues)) {
|
|
51
|
+
const slot = getScheduleSlot(getSchedule(options, name), ctx.clock.now());
|
|
52
|
+
if (await enqueueScheduledRun(ctx, name, slot)) enqueued.push(name);
|
|
53
|
+
}
|
|
54
|
+
return enqueued;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Takes `slot` for the schedule `name` and enqueues its run, in one transaction. False when the schedule already has
|
|
59
|
+
* this period or a later one (another worker took it). Throws on a name that is not a schedule.
|
|
60
|
+
*/
|
|
61
|
+
export async function enqueueScheduledRun(ctx: JobsContext, name: string, slot: Date): Promise<boolean> {
|
|
62
|
+
const schedule = getSchedule(getJobsOptions(ctx.config), name);
|
|
63
|
+
const now = ctx.clock.now();
|
|
64
|
+
return ctx.db.transaction(async (tx) => {
|
|
65
|
+
const [taken] = await tx
|
|
66
|
+
.insert(schedules)
|
|
67
|
+
.values({ name, lastSlotAt: slot, createdAt: now, updatedAt: now })
|
|
68
|
+
.onConflictDoUpdate({
|
|
69
|
+
target: schedules.name,
|
|
70
|
+
set: { lastSlotAt: slot, updatedAt: now },
|
|
71
|
+
setWhere: lt(schedules.lastSlotAt, slot),
|
|
72
|
+
})
|
|
73
|
+
.returning({ name: schedules.name });
|
|
74
|
+
if (taken === undefined) return false;
|
|
75
|
+
await enqueueJob({ ...ctx, db: tx }, { queue: name, payload: schedule.payload, dedupKey: SCHEDULE_DEDUP_KEY });
|
|
76
|
+
return true;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Notes a succeeded job of a scheduled queue (any job of it, a run enqueued by hand too). */
|
|
81
|
+
export async function recordScheduleSuccess(ctx: JobsContext, queue: string): Promise<void> {
|
|
82
|
+
if (!Object.hasOwn(getJobsOptions(ctx.config).schedules, queue)) return;
|
|
83
|
+
const now = ctx.clock.now();
|
|
84
|
+
await ctx.db.update(schedules).set({ lastSucceededAt: now, updatedAt: now }).where(eq(schedules.name, queue));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The state of every configured schedule, by queue name. */
|
|
88
|
+
export async function getScheduleStates(ctx: JobsContext): Promise<Map<string, ScheduleState>> {
|
|
89
|
+
const options = getJobsOptions(ctx.config);
|
|
90
|
+
const names = Object.keys(options.schedules);
|
|
91
|
+
if (names.length === 0) return new Map();
|
|
92
|
+
const rows = await ctx.db.select().from(schedules).where(inArray(schedules.name, names));
|
|
93
|
+
const now = ctx.clock.now().getTime();
|
|
94
|
+
return new Map(
|
|
95
|
+
names.map((name) => {
|
|
96
|
+
const everySeconds = getEverySeconds(getSchedule(options, name));
|
|
97
|
+
const row = rows.find((candidate) => candidate.name === name);
|
|
98
|
+
const since = row?.lastSucceededAt ?? row?.createdAt ?? null;
|
|
99
|
+
const isLate = since === null || now - since.getTime() > 2 * everySeconds * 1000;
|
|
100
|
+
return [name, { everySeconds, lastEnqueuedAt: row?.lastSlotAt ?? null, lastSucceededAt: row?.lastSucceededAt ?? null, isLate }];
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The schedules among `queues` (default all), in configuration order. */
|
|
106
|
+
export function getScheduleNames(options: JobsOptions, queues: readonly string[] | undefined): string[] {
|
|
107
|
+
return Object.keys(options.schedules).filter((name) => queues === undefined || queues.includes(name));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function getSchedule(options: JobsOptions, name: string): JobScheduleOptions {
|
|
111
|
+
const schedule = Object.hasOwn(options.schedules, name) ? options.schedules[name] : undefined;
|
|
112
|
+
if (schedule === undefined) throw new Error(`@softure-ai/jobs: "${name}" is not a configured schedule`);
|
|
113
|
+
return schedule;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getEverySeconds(schedule: JobScheduleOptions): number {
|
|
117
|
+
const seconds = parseEverySeconds(schedule.every);
|
|
118
|
+
// The options schema refused any other value at startup.
|
|
119
|
+
if (seconds === null) throw new Error(`@softure-ai/jobs: invalid schedule period "${schedule.every}"`);
|
|
120
|
+
return seconds;
|
|
121
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The worker loop:
|
|
2
|
-
// keeps their leases alive, prunes old jobs once an hour, and
|
|
3
|
-
// releases what is still running.
|
|
1
|
+
// The worker loop: enqueues the due runs of the schedules among its queues, claims due jobs of every queue it has a
|
|
2
|
+
// handler for, up to each queue's concurrency, runs them, keeps their leases alive, prunes old jobs once an hour, and
|
|
3
|
+
// on abort stops claiming, waits the grace period and releases what is still running.
|
|
4
4
|
import { hostname } from "node:os";
|
|
5
5
|
import { systemClock, type Clock, type SoftureConfig } from "@softure-ai/core";
|
|
6
6
|
import type { Queryable } from "@softure-ai/db";
|
|
@@ -12,6 +12,7 @@ import { consoleJobsLogger, type JobsLogger } from "../server/logger.js";
|
|
|
12
12
|
import { pruneJobs } from "../server/maintenance.js";
|
|
13
13
|
import { getJobsOptions, getQueue } from "../server/options.js";
|
|
14
14
|
import { runClaimedJob } from "../server/process.js";
|
|
15
|
+
import { enqueueScheduledRun, getSchedule, getScheduleNames, getScheduleSlot } from "../server/schedules.js";
|
|
15
16
|
|
|
16
17
|
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
|
|
17
18
|
/** A lease is extended three times per lease period, so one slow write does not lose it. */
|
|
@@ -60,6 +61,9 @@ export async function runJobWorker(options: RunJobWorkerOptions): Promise<void>
|
|
|
60
61
|
const running = new Map<string, Set<RunningJob>>(queues.map((queue) => [queue, new Set()]));
|
|
61
62
|
const wakeUp = createWakeUp();
|
|
62
63
|
let lastPruneAt = Number.NEGATIVE_INFINITY;
|
|
64
|
+
// Per schedule, the latest period this worker already settled (enqueued, or found taken), so it writes once per period.
|
|
65
|
+
const settledSlots = new Map<string, number>();
|
|
66
|
+
const scheduleNames = getScheduleNames(jobsOptions, queues);
|
|
63
67
|
|
|
64
68
|
const idle = Object.keys(jobsOptions.queues).filter((queue) => !queues.includes(queue));
|
|
65
69
|
if (idle.length > 0 && options.queues === undefined) {
|
|
@@ -98,6 +102,17 @@ export async function runJobWorker(options: RunJobWorkerOptions): Promise<void>
|
|
|
98
102
|
};
|
|
99
103
|
|
|
100
104
|
while (!options.signal.aborted) {
|
|
105
|
+
for (const name of scheduleNames) {
|
|
106
|
+
const slot = getScheduleSlot(getSchedule(jobsOptions, name), ctx.clock.now());
|
|
107
|
+
if ((settledSlots.get(name) ?? Number.NEGATIVE_INFINITY) >= slot.getTime()) continue;
|
|
108
|
+
try {
|
|
109
|
+
const isEnqueued = await enqueueScheduledRun(ctx, name, slot);
|
|
110
|
+
settledSlots.set(name, slot.getTime());
|
|
111
|
+
if (isEnqueued) logger.info("scheduled run enqueued", { queue: name, slot: slot.toISOString() });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
logger.error("could not enqueue a scheduled run; trying again next round", { queue: name, error });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
101
116
|
for (const queue of queues) {
|
|
102
117
|
const slots = running.get(queue) ?? new Set<RunningJob>();
|
|
103
118
|
const free = getQueue(jobsOptions, queue).concurrency - slots.size;
|