@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
|
@@ -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 } from "@softure-ai/core";
|
|
6
6
|
import { claimJobs, extendJobLease, releaseJob } from "../server/claim.js";
|
|
@@ -9,6 +9,7 @@ import { consoleJobsLogger } from "../server/logger.js";
|
|
|
9
9
|
import { pruneJobs } from "../server/maintenance.js";
|
|
10
10
|
import { getJobsOptions, getQueue } from "../server/options.js";
|
|
11
11
|
import { runClaimedJob } from "../server/process.js";
|
|
12
|
+
import { enqueueScheduledRun, getSchedule, getScheduleNames, getScheduleSlot } from "../server/schedules.js";
|
|
12
13
|
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
|
|
13
14
|
/** A lease is extended three times per lease period, so one slow write does not lose it. */
|
|
14
15
|
const HEARTBEATS_PER_LEASE = 3;
|
|
@@ -30,6 +31,9 @@ export async function runJobWorker(options) {
|
|
|
30
31
|
const running = new Map(queues.map((queue) => [queue, new Set()]));
|
|
31
32
|
const wakeUp = createWakeUp();
|
|
32
33
|
let lastPruneAt = Number.NEGATIVE_INFINITY;
|
|
34
|
+
// Per schedule, the latest period this worker already settled (enqueued, or found taken), so it writes once per period.
|
|
35
|
+
const settledSlots = new Map();
|
|
36
|
+
const scheduleNames = getScheduleNames(jobsOptions, queues);
|
|
33
37
|
const idle = Object.keys(jobsOptions.queues).filter((queue) => !queues.includes(queue));
|
|
34
38
|
if (idle.length > 0 && options.queues === undefined) {
|
|
35
39
|
logger.warn("configured queues without a handler are not run by this worker", { queues: idle.join(", ") });
|
|
@@ -60,6 +64,20 @@ export async function runJobWorker(options) {
|
|
|
60
64
|
return entry;
|
|
61
65
|
};
|
|
62
66
|
while (!options.signal.aborted) {
|
|
67
|
+
for (const name of scheduleNames) {
|
|
68
|
+
const slot = getScheduleSlot(getSchedule(jobsOptions, name), ctx.clock.now());
|
|
69
|
+
if ((settledSlots.get(name) ?? Number.NEGATIVE_INFINITY) >= slot.getTime())
|
|
70
|
+
continue;
|
|
71
|
+
try {
|
|
72
|
+
const isEnqueued = await enqueueScheduledRun(ctx, name, slot);
|
|
73
|
+
settledSlots.set(name, slot.getTime());
|
|
74
|
+
if (isEnqueued)
|
|
75
|
+
logger.info("scheduled run enqueued", { queue: name, slot: slot.toISOString() });
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
logger.error("could not enqueue a scheduled run; trying again next round", { queue: name, error });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
63
81
|
for (const queue of queues) {
|
|
64
82
|
const slots = running.get(queue) ?? new Set();
|
|
65
83
|
const free = getQueue(jobsOptions, queue).concurrency - slots.size;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run-job-worker.js","sourceRoot":"","sources":["../../src/worker/run-job-worker.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"run-job-worker.js","sourceRoot":"","sources":["../../src/worker/run-job-worker.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** A date and a time of day as a clock in some time zone shows them; `month` counts from 1. */
|
|
2
|
+
export interface WallClock {
|
|
3
|
+
readonly year: number;
|
|
4
|
+
readonly month: number;
|
|
5
|
+
readonly day: number;
|
|
6
|
+
readonly hour: number;
|
|
7
|
+
readonly minute: number;
|
|
8
|
+
}
|
|
9
|
+
/** True when `timeZone` is an IANA zone the runtime knows, such as `Europe/Warsaw` or `UTC`. */
|
|
10
|
+
export declare function isTimeZone(timeZone: string): boolean;
|
|
11
|
+
/** The wall clock of `instant` in `timeZone`, to the minute. */
|
|
12
|
+
export declare function getWallClock(instant: number, timeZone: string): WallClock;
|
|
13
|
+
/**
|
|
14
|
+
* The instant a clock in `timeZone` shows `wallClock`. A time that occurs twice (the hour a DST change repeats) gives
|
|
15
|
+
* its first occurrence; a time that does not occur (the hour a DST change skips) gives the instant the same distance
|
|
16
|
+
* after the change, so 02:30 on a day that jumps from 02:00 to 03:00 gives 03:30.
|
|
17
|
+
*/
|
|
18
|
+
export declare function getZonedInstant(wallClock: WallClock, timeZone: string): number;
|
|
19
|
+
//# sourceMappingURL=zoned-time.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zoned-time.d.ts","sourceRoot":"","sources":["../src/zoned-time.ts"],"names":[],"mappings":"AAKA,+FAA+F;AAC/F,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,gGAAgG;AAChG,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAOpD;AAED,gEAAgE;AAChE,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS,CASzE;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAO9E"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Wall-clock time in an IANA time zone, with the platform's Intl data and no date library.
|
|
2
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
3
|
+
const formatters = new Map();
|
|
4
|
+
/** True when `timeZone` is an IANA zone the runtime knows, such as `Europe/Warsaw` or `UTC`. */
|
|
5
|
+
export function isTimeZone(timeZone) {
|
|
6
|
+
try {
|
|
7
|
+
getFormatter(timeZone);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** The wall clock of `instant` in `timeZone`, to the minute. */
|
|
15
|
+
export function getWallClock(instant, timeZone) {
|
|
16
|
+
const parts = Object.fromEntries(getFormatter(timeZone).formatToParts(new Date(instant)).map((part) => [part.type, part.value]));
|
|
17
|
+
return {
|
|
18
|
+
year: Number(parts.year),
|
|
19
|
+
month: Number(parts.month),
|
|
20
|
+
day: Number(parts.day),
|
|
21
|
+
hour: Number(parts.hour),
|
|
22
|
+
minute: Number(parts.minute),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The instant a clock in `timeZone` shows `wallClock`. A time that occurs twice (the hour a DST change repeats) gives
|
|
27
|
+
* its first occurrence; a time that does not occur (the hour a DST change skips) gives the instant the same distance
|
|
28
|
+
* after the change, so 02:30 on a day that jumps from 02:00 to 03:00 gives 03:30.
|
|
29
|
+
*/
|
|
30
|
+
export function getZonedInstant(wallClock, timeZone) {
|
|
31
|
+
const asUtc = toUtcMs(wallClock);
|
|
32
|
+
// One DST change a day at most: the offsets half a day before and after cover both sides of it.
|
|
33
|
+
const offsetBefore = getOffsetMs(asUtc - 12 * HOUR_MS, timeZone);
|
|
34
|
+
const offsetAfter = getOffsetMs(asUtc + 12 * HOUR_MS, timeZone);
|
|
35
|
+
const matching = [asUtc - offsetBefore, asUtc - offsetAfter].filter((instant) => toUtcMs(getWallClock(instant, timeZone)) === asUtc);
|
|
36
|
+
return matching.length > 0 ? Math.min(...matching) : asUtc - offsetBefore;
|
|
37
|
+
}
|
|
38
|
+
function getOffsetMs(instant, timeZone) {
|
|
39
|
+
const minute = Math.floor(instant / 60_000) * 60_000;
|
|
40
|
+
return toUtcMs(getWallClock(minute, timeZone)) - minute;
|
|
41
|
+
}
|
|
42
|
+
function toUtcMs(wallClock) {
|
|
43
|
+
return Date.UTC(wallClock.year, wallClock.month - 1, wallClock.day, wallClock.hour, wallClock.minute);
|
|
44
|
+
}
|
|
45
|
+
function getFormatter(timeZone) {
|
|
46
|
+
let formatter = formatters.get(timeZone);
|
|
47
|
+
if (formatter === undefined) {
|
|
48
|
+
formatter = new Intl.DateTimeFormat("en-US", {
|
|
49
|
+
timeZone,
|
|
50
|
+
hourCycle: "h23",
|
|
51
|
+
year: "numeric",
|
|
52
|
+
month: "numeric",
|
|
53
|
+
day: "numeric",
|
|
54
|
+
hour: "numeric",
|
|
55
|
+
minute: "numeric",
|
|
56
|
+
});
|
|
57
|
+
formatters.set(timeZone, formatter);
|
|
58
|
+
}
|
|
59
|
+
return formatter;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=zoned-time.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zoned-time.js","sourceRoot":"","sources":["../src/zoned-time.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Recurring schedules: per schedule the last period a worker enqueued a run for, and when a run last succeeded.
|
|
2
|
+
-- Rollback: DROP TABLE jobs.schedules; then
|
|
3
|
+
-- DELETE FROM softure.migrations WHERE module = 'jobs' AND version = 2;
|
|
4
|
+
CREATE TABLE schedules (
|
|
5
|
+
-- The schedule's queue.
|
|
6
|
+
name text PRIMARY KEY CHECK (name ~ '^[a-z][a-z0-9_.-]{0,62}$'),
|
|
7
|
+
-- The start of the latest period a run was enqueued for; a worker takes a period only when it is later than this.
|
|
8
|
+
last_slot_at timestamptz NOT NULL,
|
|
9
|
+
last_succeeded_at timestamptz,
|
|
10
|
+
created_at timestamptz NOT NULL,
|
|
11
|
+
updated_at timestamptz NOT NULL
|
|
12
|
+
);
|
package/module.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softure-ai/jobs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "SOFTURE AI jobs module: background jobs in Postgres. Enqueue inside the caller's transaction with an optional dedup key, claim with FOR UPDATE SKIP LOCKED under a lease, fenced completion, retry with exponential backoff and a terminal failed state, a worker process with graceful shutdown, and an in-process runner for tests.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/index.ts
CHANGED
|
@@ -15,10 +15,10 @@ export const MODULE_ID = "jobs";
|
|
|
15
15
|
export const jobs = defineModule({
|
|
16
16
|
manifest: {
|
|
17
17
|
id: MODULE_ID,
|
|
18
|
-
version: "0.1.
|
|
18
|
+
version: "0.1.2",
|
|
19
19
|
dependsOn: {},
|
|
20
20
|
dbSchema: "jobs",
|
|
21
|
-
tables: ["jobs"],
|
|
21
|
+
tables: ["jobs", "schedules"],
|
|
22
22
|
env: [],
|
|
23
23
|
switches: [],
|
|
24
24
|
routes: {},
|
|
@@ -40,6 +40,15 @@ export {
|
|
|
40
40
|
type JobStatus,
|
|
41
41
|
} from "./contract.js";
|
|
42
42
|
export { jobsMessages, type JobsMessages } from "./messages/index.js";
|
|
43
|
-
export {
|
|
43
|
+
export {
|
|
44
|
+
jobsOptionsSchema,
|
|
45
|
+
type JobQueueOptions,
|
|
46
|
+
type JobScheduleOptions,
|
|
47
|
+
type JobSchedulesInput,
|
|
48
|
+
type JobsOptions,
|
|
49
|
+
type JobsOptionsInput,
|
|
50
|
+
type LocalTimeScheduleOptions,
|
|
51
|
+
type PeriodScheduleOptions,
|
|
52
|
+
} from "./options.js";
|
|
44
53
|
export { defineJobHandler, failJob, retryJobLater, type JobHandler, type JobHandlerContext, type JobHandlers } from "./handlers.js";
|
|
45
|
-
export { jobs as jobsTable } from "./schema.js";
|
|
54
|
+
export { jobs as jobsTable, schedules as jobSchedulesTable } from "./schema.js";
|
package/src/options.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The options an app passes to `jobs({ ... })` in softure.config.ts, parsed at startup.
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { isTimeZone } from "./zoned-time.js";
|
|
3
4
|
|
|
4
5
|
const QUEUE_NAME = /^[a-z][a-z0-9_.-]{0,62}$/;
|
|
5
6
|
const QUEUE_NAME_HINT = "is not a queue name: lowercase letters, digits, _ . and -, starting with a letter";
|
|
@@ -7,15 +8,80 @@ const DAY_SECONDS = 24 * 60 * 60;
|
|
|
7
8
|
|
|
8
9
|
const backoffSchema = z
|
|
9
10
|
.strictObject({
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
/** Delay before the second attempt; each later one doubles it. */
|
|
12
|
+
baseSeconds: z.number().int().min(1).max(DAY_SECONDS).default(10),
|
|
13
|
+
/** The longest delay between two attempts. */
|
|
14
|
+
maxSeconds: z.number().int().min(1).max(7 * DAY_SECONDS).default(3600),
|
|
15
|
+
})
|
|
16
|
+
.refine((backoff) => backoff.maxSeconds >= backoff.baseSeconds, {
|
|
17
|
+
message: "maxSeconds must be at least baseSeconds",
|
|
18
|
+
path: ["maxSeconds"],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const EVERY = /^([1-9][0-9]{0,4})(m|h|d)$/;
|
|
22
|
+
const UNIT_SECONDS = { m: 60, h: 60 * 60, d: DAY_SECONDS } as const;
|
|
23
|
+
const MAX_EVERY_SECONDS = 7 * DAY_SECONDS;
|
|
24
|
+
const EVERY_HINT = "must be a whole number of minutes, hours or days (e.g. 15m, 1h, 1d), from 1m to 7d";
|
|
25
|
+
|
|
26
|
+
/** Seconds of an `every` text such as `15m`, `1h` or `1d`; null when it is not one or is outside 1m to 7d. */
|
|
27
|
+
export function parseEverySeconds(every: string): number | null {
|
|
28
|
+
const match = EVERY.exec(every);
|
|
29
|
+
if (match === null) return null;
|
|
30
|
+
const seconds = Number(match[1]) * UNIT_SECONDS[match[2] as keyof typeof UNIT_SECONDS];
|
|
31
|
+
return seconds <= MAX_EVERY_SECONDS ? seconds : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const AT_TIME = /^([01][0-9]|2[0-3]):[0-5][0-9]$/;
|
|
35
|
+
const AT_HINT = "must be times of day as HH:MM (00:00 to 23:59), each once";
|
|
36
|
+
const MAX_AT_TIMES = 48;
|
|
37
|
+
|
|
38
|
+
const scheduleSchema = z
|
|
39
|
+
.strictObject({
|
|
40
|
+
/** How often the schedule runs: `15m`, `1h`, `1d`. Runs start on whole periods counted from 1970-01-01 00:00 UTC. */
|
|
41
|
+
every: z.string().refine((every) => parseEverySeconds(every) !== null, { message: EVERY_HINT }).optional(),
|
|
42
|
+
/** Instead of `every`: the local times of day the schedule runs at, `HH:MM`, in `timeZone`. */
|
|
43
|
+
at: z
|
|
44
|
+
.array(z.string().regex(AT_TIME, { message: AT_HINT }))
|
|
45
|
+
.min(1)
|
|
46
|
+
.max(MAX_AT_TIMES)
|
|
47
|
+
.refine((times) => new Set(times).size === times.length, { message: AT_HINT })
|
|
48
|
+
.optional(),
|
|
49
|
+
/** The IANA time zone of `at`, such as `Europe/Warsaw`; required with `at`. */
|
|
50
|
+
timeZone: z.string().refine(isTimeZone, { message: "is not a time zone the runtime knows (an IANA name such as Europe/Warsaw)" }).optional(),
|
|
51
|
+
/** The payload of every run; default `{}`. */
|
|
52
|
+
payload: z.record(z.string(), z.unknown()).default({}),
|
|
53
|
+
})
|
|
54
|
+
.superRefine((schedule, context) => {
|
|
55
|
+
if ((schedule.every === undefined) === (schedule.at === undefined)) {
|
|
56
|
+
context.addIssue({ code: "custom", path: ["every"], message: "set either every or at, not both" });
|
|
57
|
+
}
|
|
58
|
+
if (schedule.at !== undefined && schedule.timeZone === undefined) {
|
|
59
|
+
context.addIssue({ code: "custom", path: ["timeZone"], message: "is required with at" });
|
|
60
|
+
}
|
|
61
|
+
if (schedule.every !== undefined && schedule.timeZone !== undefined) {
|
|
62
|
+
context.addIssue({ code: "custom", path: ["timeZone"], message: "applies to at only; every runs in UTC" });
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
.transform((schedule): PeriodScheduleOptions | LocalTimeScheduleOptions => {
|
|
66
|
+
if (schedule.at !== undefined && schedule.timeZone !== undefined) {
|
|
67
|
+
return { at: [...schedule.at].sort(), timeZone: schedule.timeZone, payload: schedule.payload };
|
|
68
|
+
}
|
|
69
|
+
// The refinement above leaves every set whenever at is not.
|
|
70
|
+
return { every: schedule.every ?? "", payload: schedule.payload };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** A schedule that runs once per period, aligned to UTC. */
|
|
74
|
+
export interface PeriodScheduleOptions {
|
|
75
|
+
readonly every: string;
|
|
76
|
+
readonly payload: Record<string, unknown>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A schedule that runs at local times of day in a time zone; `at` is sorted. */
|
|
80
|
+
export interface LocalTimeScheduleOptions {
|
|
81
|
+
readonly at: readonly string[];
|
|
82
|
+
readonly timeZone: string;
|
|
83
|
+
readonly payload: Record<string, unknown>;
|
|
84
|
+
}
|
|
19
85
|
|
|
20
86
|
const queueSchema = z.strictObject({
|
|
21
87
|
/** Attempts before a job is `failed` for good; the first run counts. */
|
|
@@ -40,25 +106,42 @@ const retentionSchema = z.strictObject({
|
|
|
40
106
|
cancelledDays: z.number().int().min(0).max(3650).default(7),
|
|
41
107
|
});
|
|
42
108
|
|
|
43
|
-
export const jobsOptionsSchema = z
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
109
|
+
export const jobsOptionsSchema = z
|
|
110
|
+
.strictObject({
|
|
111
|
+
/** The queues, by name. A job is enqueued to one of them and runs with its settings. */
|
|
112
|
+
queues: z.record(z.string(), queueSchema).default({}).superRefine(checkNames),
|
|
113
|
+
/**
|
|
114
|
+
* Recurring runs, by queue name: the worker enqueues one job per period (`every`) or per local time of day (`at`
|
|
115
|
+
* in `timeZone`) however many workers run. A schedule's queue takes the default queue settings unless `queues`
|
|
116
|
+
* names it.
|
|
117
|
+
*/
|
|
118
|
+
schedules: z.record(z.string(), scheduleSchema).default({}).superRefine(checkNames),
|
|
119
|
+
/** How long an idle worker waits before it looks for due jobs again. */
|
|
120
|
+
pollIntervalMs: z.number().int().min(50).max(60_000).default(1000),
|
|
121
|
+
/** How long a stopping worker waits for running handlers before it releases their jobs. */
|
|
122
|
+
shutdownGraceMs: z.number().int().min(0).max(10 * 60_000).default(20_000),
|
|
123
|
+
/** How long finished jobs are kept; the worker prunes once an hour. */
|
|
124
|
+
retention: retentionSchema.default({ succeededDays: 7, failedDays: 30, cancelledDays: 7 }),
|
|
125
|
+
})
|
|
126
|
+
.superRefine((options, context) => {
|
|
127
|
+
if (Object.keys(options.queues).length === 0 && Object.keys(options.schedules).length === 0) {
|
|
128
|
+
context.addIssue({ code: "custom", path: ["queues"], message: "must define at least one queue or schedule" });
|
|
52
129
|
}
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
130
|
+
})
|
|
131
|
+
.transform((options) => {
|
|
132
|
+
const scheduledOnly = Object.keys(options.schedules).filter((name) => !Object.hasOwn(options.queues, name));
|
|
133
|
+
return { ...options, queues: { ...options.queues, ...Object.fromEntries(scheduledOnly.map((name) => [name, queueSchema.parse({})])) } };
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
function checkNames(record: Record<string, unknown>, context: z.RefinementCtx): void {
|
|
137
|
+
for (const name of Object.keys(record).filter((candidate) => !QUEUE_NAME.test(candidate))) {
|
|
138
|
+
context.addIssue({ code: "custom", path: [name], message: QUEUE_NAME_HINT });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
61
141
|
|
|
62
142
|
export type JobsOptionsInput = z.input<typeof jobsOptionsSchema>;
|
|
63
143
|
export type JobsOptions = z.output<typeof jobsOptionsSchema>;
|
|
64
144
|
export type JobQueueOptions = JobsOptions["queues"][string];
|
|
145
|
+
export type JobScheduleOptions = JobsOptions["schedules"][string];
|
|
146
|
+
/** Schedules as an app or a package writes them, e.g. a package's ready-made prune schedules. */
|
|
147
|
+
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 = {
|