@softure-ai/jobs 0.1.0 → 0.1.2
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 +28 -0
- package/README.md +86 -10
- package/dist/index.d.ts +55 -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 +69 -4
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +73 -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 +49 -0
- package/dist/server/schedules.d.ts.map +1 -0
- package/dist/server/schedules.js +133 -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/dist/zoned-time.d.ts +19 -0
- package/dist/zoned-time.d.ts.map +1 -0
- package/dist/zoned-time.js +61 -0
- package/dist/zoned-time.js.map +1 -0
- package/migrations/0002_create_schedules.sql +12 -0
- package/module.json +2 -2
- package/package.json +1 -1
- package/src/index.ts +13 -4
- package/src/options.ts +109 -26
- 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 +157 -0
- package/src/worker/run-job-worker.ts +18 -3
- package/src/zoned-time.ts +75 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Recurring schedules: a period schedule's runs start on whole periods counted from the Unix epoch, a local-time
|
|
2
|
+
// schedule's at its listed times of day in its time zone, so every worker computes the same slot. 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, type LocalTimeScheduleOptions } from "../options.js";
|
|
6
|
+
import { schedules } from "../schema.js";
|
|
7
|
+
import { getWallClock, getZonedInstant } from "../zoned-time.js";
|
|
8
|
+
import type { JobsContext } from "./context.js";
|
|
9
|
+
import { enqueueJob } from "./enqueue.js";
|
|
10
|
+
import { getJobsOptions } from "./options.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The dedup key of every scheduled run: while one run of a schedule is pending or running (retrying a failure, say),
|
|
14
|
+
* the next period joins it instead of piling up a second run behind it.
|
|
15
|
+
*/
|
|
16
|
+
export const SCHEDULE_DEDUP_KEY = "schedule";
|
|
17
|
+
|
|
18
|
+
/** A schedule's health as `getQueueStats` reports it. */
|
|
19
|
+
export interface ScheduleState {
|
|
20
|
+
/** The period; for a local-time schedule the longest gap between two of its times of day (24h for one time). */
|
|
21
|
+
readonly everySeconds: number;
|
|
22
|
+
/** The start of the latest period a run was enqueued for; null before any worker saw the schedule. */
|
|
23
|
+
readonly lastEnqueuedAt: Date | null;
|
|
24
|
+
/** When a job of the schedule's queue last succeeded; null when none has yet. */
|
|
25
|
+
readonly lastSucceededAt: Date | null;
|
|
26
|
+
/**
|
|
27
|
+
* True when no run succeeded within two periods (counted from when a worker first saw the schedule), or no worker
|
|
28
|
+
* has seen it at all: no worker runs its queue, or its runs keep failing.
|
|
29
|
+
*/
|
|
30
|
+
readonly isLate: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EnqueueDueSchedulesInput {
|
|
34
|
+
/** Only the schedules of these queues; default every schedule. */
|
|
35
|
+
readonly queues?: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The slot `now` falls in: the start of its period, or the latest of the schedule's local times at or before `now`.
|
|
40
|
+
* Slots only move forward as `now` does, across DST changes too.
|
|
41
|
+
*/
|
|
42
|
+
export function getScheduleSlot(schedule: JobScheduleOptions, now: Date): Date {
|
|
43
|
+
if ("at" in schedule) return new Date(getLocalTimeSlot(schedule, now.getTime()));
|
|
44
|
+
const periodMs = getEverySeconds(schedule) * 1000;
|
|
45
|
+
return new Date(Math.floor(now.getTime() / periodMs) * periodMs);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Enqueues a run for every schedule whose current period has none yet, and returns their queue names. Missed periods
|
|
50
|
+
* (no worker ran) collapse into one run for the current period. The worker calls it; an app that drains its queues
|
|
51
|
+
* from a cron (`runDueJobs`) gets it from there. Database failures propagate.
|
|
52
|
+
*/
|
|
53
|
+
export async function enqueueDueSchedules(ctx: JobsContext, input: EnqueueDueSchedulesInput = {}): Promise<string[]> {
|
|
54
|
+
const options = getJobsOptions(ctx.config);
|
|
55
|
+
const enqueued: string[] = [];
|
|
56
|
+
for (const name of getScheduleNames(options, input.queues)) {
|
|
57
|
+
const slot = getScheduleSlot(getSchedule(options, name), ctx.clock.now());
|
|
58
|
+
if (await enqueueScheduledRun(ctx, name, slot)) enqueued.push(name);
|
|
59
|
+
}
|
|
60
|
+
return enqueued;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Takes `slot` for the schedule `name` and enqueues its run, in one transaction. False when the schedule already has
|
|
65
|
+
* this period or a later one (another worker took it). Throws on a name that is not a schedule.
|
|
66
|
+
*/
|
|
67
|
+
export async function enqueueScheduledRun(ctx: JobsContext, name: string, slot: Date): Promise<boolean> {
|
|
68
|
+
const schedule = getSchedule(getJobsOptions(ctx.config), name);
|
|
69
|
+
const now = ctx.clock.now();
|
|
70
|
+
return ctx.db.transaction(async (tx) => {
|
|
71
|
+
const [taken] = await tx
|
|
72
|
+
.insert(schedules)
|
|
73
|
+
.values({ name, lastSlotAt: slot, createdAt: now, updatedAt: now })
|
|
74
|
+
.onConflictDoUpdate({
|
|
75
|
+
target: schedules.name,
|
|
76
|
+
set: { lastSlotAt: slot, updatedAt: now },
|
|
77
|
+
setWhere: lt(schedules.lastSlotAt, slot),
|
|
78
|
+
})
|
|
79
|
+
.returning({ name: schedules.name });
|
|
80
|
+
if (taken === undefined) return false;
|
|
81
|
+
await enqueueJob({ ...ctx, db: tx }, { queue: name, payload: schedule.payload, dedupKey: SCHEDULE_DEDUP_KEY });
|
|
82
|
+
return true;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Notes a succeeded job of a scheduled queue (any job of it, a run enqueued by hand too). */
|
|
87
|
+
export async function recordScheduleSuccess(ctx: JobsContext, queue: string): Promise<void> {
|
|
88
|
+
if (!Object.hasOwn(getJobsOptions(ctx.config).schedules, queue)) return;
|
|
89
|
+
const now = ctx.clock.now();
|
|
90
|
+
await ctx.db.update(schedules).set({ lastSucceededAt: now, updatedAt: now }).where(eq(schedules.name, queue));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The state of every configured schedule, by queue name. */
|
|
94
|
+
export async function getScheduleStates(ctx: JobsContext): Promise<Map<string, ScheduleState>> {
|
|
95
|
+
const options = getJobsOptions(ctx.config);
|
|
96
|
+
const names = Object.keys(options.schedules);
|
|
97
|
+
if (names.length === 0) return new Map();
|
|
98
|
+
const rows = await ctx.db.select().from(schedules).where(inArray(schedules.name, names));
|
|
99
|
+
const now = ctx.clock.now().getTime();
|
|
100
|
+
return new Map(
|
|
101
|
+
names.map((name) => {
|
|
102
|
+
const everySeconds = getEverySeconds(getSchedule(options, name));
|
|
103
|
+
const row = rows.find((candidate) => candidate.name === name);
|
|
104
|
+
const since = row?.lastSucceededAt ?? row?.createdAt ?? null;
|
|
105
|
+
const isLate = since === null || now - since.getTime() > 2 * everySeconds * 1000;
|
|
106
|
+
return [name, { everySeconds, lastEnqueuedAt: row?.lastSlotAt ?? null, lastSucceededAt: row?.lastSucceededAt ?? null, isLate }];
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The schedules among `queues` (default all), in configuration order. */
|
|
112
|
+
export function getScheduleNames(options: JobsOptions, queues: readonly string[] | undefined): string[] {
|
|
113
|
+
return Object.keys(options.schedules).filter((name) => queues === undefined || queues.includes(name));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getSchedule(options: JobsOptions, name: string): JobScheduleOptions {
|
|
117
|
+
const schedule = Object.hasOwn(options.schedules, name) ? options.schedules[name] : undefined;
|
|
118
|
+
if (schedule === undefined) throw new Error(`@softure-ai/jobs: "${name}" is not a configured schedule`);
|
|
119
|
+
return schedule;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getEverySeconds(schedule: JobScheduleOptions): number {
|
|
123
|
+
if ("at" in schedule) return getLongestGapSeconds(schedule.at);
|
|
124
|
+
const seconds = parseEverySeconds(schedule.every);
|
|
125
|
+
// The options schema refused any other value at startup.
|
|
126
|
+
if (seconds === null) throw new Error(`@softure-ai/jobs: invalid schedule period "${schedule.every}"`);
|
|
127
|
+
return seconds;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getLocalTimeSlot(schedule: LocalTimeScheduleOptions, now: number): number {
|
|
131
|
+
const today = getWallClock(now, schedule.timeZone);
|
|
132
|
+
// The calendar day before today's local date; Date.UTC rolls day 0 over to the month before.
|
|
133
|
+
const yesterday = new Date(Date.UTC(today.year, today.month - 1, today.day - 1));
|
|
134
|
+
const days = [
|
|
135
|
+
{ year: yesterday.getUTCFullYear(), month: yesterday.getUTCMonth() + 1, day: yesterday.getUTCDate() },
|
|
136
|
+
{ year: today.year, month: today.month, day: today.day },
|
|
137
|
+
];
|
|
138
|
+
let slot = Number.NEGATIVE_INFINITY;
|
|
139
|
+
for (const day of days) {
|
|
140
|
+
for (const time of schedule.at) {
|
|
141
|
+
const [hour, minute] = time.split(":").map(Number);
|
|
142
|
+
const instant = getZonedInstant({ ...day, hour: hour ?? 0, minute: minute ?? 0 }, schedule.timeZone);
|
|
143
|
+
if (instant <= now && instant > slot) slot = instant;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return slot;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The longest gap between two consecutive times of day, wrapping past midnight; `times` is sorted. */
|
|
150
|
+
function getLongestGapSeconds(times: readonly string[]): number {
|
|
151
|
+
const minutes = times.map((time) => {
|
|
152
|
+
const [hour, minute] = time.split(":").map(Number);
|
|
153
|
+
return (hour ?? 0) * 60 + (minute ?? 0);
|
|
154
|
+
});
|
|
155
|
+
const gaps = minutes.map((minute, index) => (minutes[(index + 1) % minutes.length] ?? minute) - minute + (index === minutes.length - 1 ? 24 * 60 : 0));
|
|
156
|
+
return Math.max(...gaps) * 60;
|
|
157
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Wall-clock time in an IANA time zone, with the platform's Intl data and no date library.
|
|
2
|
+
|
|
3
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
4
|
+
const formatters = new Map<string, Intl.DateTimeFormat>();
|
|
5
|
+
|
|
6
|
+
/** A date and a time of day as a clock in some time zone shows them; `month` counts from 1. */
|
|
7
|
+
export interface WallClock {
|
|
8
|
+
readonly year: number;
|
|
9
|
+
readonly month: number;
|
|
10
|
+
readonly day: number;
|
|
11
|
+
readonly hour: number;
|
|
12
|
+
readonly minute: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** True when `timeZone` is an IANA zone the runtime knows, such as `Europe/Warsaw` or `UTC`. */
|
|
16
|
+
export function isTimeZone(timeZone: string): boolean {
|
|
17
|
+
try {
|
|
18
|
+
getFormatter(timeZone);
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The wall clock of `instant` in `timeZone`, to the minute. */
|
|
26
|
+
export function getWallClock(instant: number, timeZone: string): WallClock {
|
|
27
|
+
const parts = Object.fromEntries(getFormatter(timeZone).formatToParts(new Date(instant)).map((part) => [part.type, part.value]));
|
|
28
|
+
return {
|
|
29
|
+
year: Number(parts.year),
|
|
30
|
+
month: Number(parts.month),
|
|
31
|
+
day: Number(parts.day),
|
|
32
|
+
hour: Number(parts.hour),
|
|
33
|
+
minute: Number(parts.minute),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The instant a clock in `timeZone` shows `wallClock`. A time that occurs twice (the hour a DST change repeats) gives
|
|
39
|
+
* its first occurrence; a time that does not occur (the hour a DST change skips) gives the instant the same distance
|
|
40
|
+
* after the change, so 02:30 on a day that jumps from 02:00 to 03:00 gives 03:30.
|
|
41
|
+
*/
|
|
42
|
+
export function getZonedInstant(wallClock: WallClock, timeZone: string): number {
|
|
43
|
+
const asUtc = toUtcMs(wallClock);
|
|
44
|
+
// One DST change a day at most: the offsets half a day before and after cover both sides of it.
|
|
45
|
+
const offsetBefore = getOffsetMs(asUtc - 12 * HOUR_MS, timeZone);
|
|
46
|
+
const offsetAfter = getOffsetMs(asUtc + 12 * HOUR_MS, timeZone);
|
|
47
|
+
const matching = [asUtc - offsetBefore, asUtc - offsetAfter].filter((instant) => toUtcMs(getWallClock(instant, timeZone)) === asUtc);
|
|
48
|
+
return matching.length > 0 ? Math.min(...matching) : asUtc - offsetBefore;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getOffsetMs(instant: number, timeZone: string): number {
|
|
52
|
+
const minute = Math.floor(instant / 60_000) * 60_000;
|
|
53
|
+
return toUtcMs(getWallClock(minute, timeZone)) - minute;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toUtcMs(wallClock: WallClock): number {
|
|
57
|
+
return Date.UTC(wallClock.year, wallClock.month - 1, wallClock.day, wallClock.hour, wallClock.minute);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getFormatter(timeZone: string): Intl.DateTimeFormat {
|
|
61
|
+
let formatter = formatters.get(timeZone);
|
|
62
|
+
if (formatter === undefined) {
|
|
63
|
+
formatter = new Intl.DateTimeFormat("en-US", {
|
|
64
|
+
timeZone,
|
|
65
|
+
hourCycle: "h23",
|
|
66
|
+
year: "numeric",
|
|
67
|
+
month: "numeric",
|
|
68
|
+
day: "numeric",
|
|
69
|
+
hour: "numeric",
|
|
70
|
+
minute: "numeric",
|
|
71
|
+
});
|
|
72
|
+
formatters.set(timeZone, formatter);
|
|
73
|
+
}
|
|
74
|
+
return formatter;
|
|
75
|
+
}
|