@softure-ai/jobs 0.1.1 → 0.1.3
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 +39 -0
- package/README.md +63 -10
- package/dist/index.d.ts +13 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/options.d.ts +23 -11
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +37 -4
- package/dist/options.js.map +1 -1
- package/dist/schema.d.ts +34 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2 -0
- package/dist/schema.js.map +1 -1
- package/dist/server/claim.d.ts +2 -1
- package/dist/server/claim.d.ts.map +1 -1
- package/dist/server/claim.js +22 -14
- package/dist/server/claim.js.map +1 -1
- package/dist/server/schedules.d.ts +13 -1
- package/dist/server/schedules.d.ts.map +1 -1
- package/dist/server/schedules.js +51 -4
- package/dist/server/schedules.js.map +1 -1
- package/dist/worker/health.d.ts +30 -0
- package/dist/worker/health.d.ts.map +1 -0
- package/dist/worker/health.js +53 -0
- package/dist/worker/health.js.map +1 -0
- package/dist/worker/index.d.ts +1 -0
- package/dist/worker/index.d.ts.map +1 -1
- package/dist/worker/index.js +3 -2
- package/dist/worker/index.js.map +1 -1
- package/dist/worker/process.d.ts +8 -2
- package/dist/worker/process.d.ts.map +1 -1
- package/dist/worker/process.js +27 -3
- package/dist/worker/process.js.map +1 -1
- package/dist/worker/run-job-worker.d.ts +5 -0
- package/dist/worker/run-job-worker.d.ts.map +1 -1
- package/dist/worker/run-job-worker.js +9 -0
- 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/0003_add_schedule_failures.sql +8 -0
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/index.ts +3 -1
- package/src/options.ts +55 -8
- package/src/schema.ts +2 -0
- package/src/server/claim.ts +20 -14
- package/src/server/schedules.ts +58 -5
- package/src/worker/health.ts +64 -0
- package/src/worker/index.ts +3 -2
- package/src/worker/process.ts +32 -4
- package/src/worker/run-job-worker.ts +13 -0
- package/src/zoned-time.ts +75 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- The last terminal failure of a scheduled queue, so getQueueStats can tell whether it fails since its last success.
|
|
2
|
+
-- Rollback: ALTER TABLE jobs.schedules DROP COLUMN last_failed_at, DROP COLUMN last_failure_code; then
|
|
3
|
+
-- DELETE FROM softure.migrations WHERE module = 'jobs' AND version = 3;
|
|
4
|
+
ALTER TABLE schedules
|
|
5
|
+
-- When a job of the schedule's queue last ended `failed`.
|
|
6
|
+
ADD COLUMN last_failed_at timestamptz,
|
|
7
|
+
-- That job's error code (`last_error`), never an error's text.
|
|
8
|
+
ADD COLUMN last_failure_code text;
|
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.3",
|
|
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,7 +15,7 @@ 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.3",
|
|
19
19
|
dependsOn: {},
|
|
20
20
|
dbSchema: "jobs",
|
|
21
21
|
tables: ["jobs", "schedules"],
|
|
@@ -47,6 +47,8 @@ export {
|
|
|
47
47
|
type JobSchedulesInput,
|
|
48
48
|
type JobsOptions,
|
|
49
49
|
type JobsOptionsInput,
|
|
50
|
+
type LocalTimeScheduleOptions,
|
|
51
|
+
type PeriodScheduleOptions,
|
|
50
52
|
} from "./options.js";
|
|
51
53
|
export { defineJobHandler, failJob, retryJobLater, type JobHandler, type JobHandlerContext, type JobHandlers } from "./handlers.js";
|
|
52
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";
|
|
@@ -30,12 +31,57 @@ const backoffSchema = z
|
|
|
30
31
|
return seconds <= MAX_EVERY_SECONDS ? seconds : null;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
}
|
|
39
85
|
|
|
40
86
|
const queueSchema = z.strictObject({
|
|
41
87
|
/** Attempts before a job is `failed` for good; the first run counts. */
|
|
@@ -65,8 +111,9 @@ export const jobsOptionsSchema = z
|
|
|
65
111
|
/** The queues, by name. A job is enqueued to one of them and runs with its settings. */
|
|
66
112
|
queues: z.record(z.string(), queueSchema).default({}).superRefine(checkNames),
|
|
67
113
|
/**
|
|
68
|
-
* Recurring runs, by queue name: the worker enqueues one job per period (`every`)
|
|
69
|
-
* schedule's queue takes the default queue settings unless `queues`
|
|
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.
|
|
70
117
|
*/
|
|
71
118
|
schedules: z.record(z.string(), scheduleSchema).default({}).superRefine(checkNames),
|
|
72
119
|
/** How long an idle worker waits before it looks for due jobs again. */
|
package/src/schema.ts
CHANGED
|
@@ -28,6 +28,8 @@ export const schedules = jobsSchema.table("schedules", {
|
|
|
28
28
|
name: text("name").primaryKey(),
|
|
29
29
|
lastSlotAt: timestamp("last_slot_at", { withTimezone: true }).notNull(),
|
|
30
30
|
lastSucceededAt: timestamp("last_succeeded_at", { withTimezone: true }),
|
|
31
|
+
lastFailedAt: timestamp("last_failed_at", { withTimezone: true }),
|
|
32
|
+
lastFailureCode: text("last_failure_code"),
|
|
31
33
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
|
32
34
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
|
|
33
35
|
});
|
package/src/server/claim.ts
CHANGED
|
@@ -6,7 +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
|
+
import { recordScheduleFailure, recordScheduleSuccess } from "./schedules.js";
|
|
10
10
|
|
|
11
11
|
/** A job this worker holds: the job and the token its writes are fenced with. */
|
|
12
12
|
export interface ClaimedJob {
|
|
@@ -89,7 +89,8 @@ export async function extendJobLease(ctx: JobsContext, claimed: ClaimedJob): Pro
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
* Records the end of an attempt; a success of a scheduled queue also updates its schedule, in
|
|
92
|
+
* Records the end of an attempt; a success or a terminal failure of a scheduled queue also updates its schedule, in
|
|
93
|
+
* the same transaction.
|
|
93
94
|
* False when the job is no longer this claim's: nothing was written.
|
|
94
95
|
*/
|
|
95
96
|
export async function completeJob(ctx: JobsContext, claimed: ClaimedJob, completion: Completion): Promise<boolean> {
|
|
@@ -105,6 +106,7 @@ export async function completeJob(ctx: JobsContext, claimed: ClaimedJob, complet
|
|
|
105
106
|
const rows = await tx.update(jobs).set(set).where(isHeld(claimed)).returning();
|
|
106
107
|
if (rows.length === 0) return false;
|
|
107
108
|
if (completion.kind === "succeeded") await recordScheduleSuccess({ ...ctx, db: tx }, claimed.job.queue);
|
|
109
|
+
if (completion.kind === "failed") await recordScheduleFailure({ ...ctx, db: tx }, claimed.job.queue, completion.error);
|
|
108
110
|
return true;
|
|
109
111
|
});
|
|
110
112
|
}
|
|
@@ -132,18 +134,22 @@ export async function releaseJob(ctx: JobsContext, claimed: ClaimedJob): Promise
|
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
async function failExhaustedLeases(ctx: JobsContext, queue: string, now: Date): Promise<void> {
|
|
135
|
-
await ctx.db
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
137
|
+
await ctx.db.transaction(async (tx) => {
|
|
138
|
+
const failed = await tx
|
|
139
|
+
.update(jobs)
|
|
140
|
+
.set({
|
|
141
|
+
status: "failed",
|
|
142
|
+
lastError: LEASE_EXPIRED,
|
|
143
|
+
finishedAt: now,
|
|
144
|
+
lockedUntil: null,
|
|
145
|
+
lockedBy: null,
|
|
146
|
+
claimToken: null,
|
|
147
|
+
updatedAt: now,
|
|
148
|
+
})
|
|
149
|
+
.where(and(eq(jobs.queue, queue), eq(jobs.status, "running"), lte(jobs.lockedUntil, now), gte(jobs.attempts, jobs.maxAttempts)))
|
|
150
|
+
.returning({ id: jobs.id });
|
|
151
|
+
if (failed.length > 0) await recordScheduleFailure({ ...ctx, db: tx }, queue, LEASE_EXPIRED);
|
|
152
|
+
});
|
|
147
153
|
}
|
|
148
154
|
|
|
149
155
|
function isHeld(claimed: ClaimedJob) {
|
package/src/server/schedules.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// Recurring schedules:
|
|
2
|
-
// computes the same
|
|
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
3
|
// in the same transaction, so each period is enqueued once however many workers race for it.
|
|
4
4
|
import { eq, inArray, lt } from "drizzle-orm";
|
|
5
|
-
import { parseEverySeconds, type JobScheduleOptions, type JobsOptions } from "../options.js";
|
|
5
|
+
import { parseEverySeconds, type JobScheduleOptions, type JobsOptions, type LocalTimeScheduleOptions } from "../options.js";
|
|
6
6
|
import { schedules } from "../schema.js";
|
|
7
|
+
import { getWallClock, getZonedInstant } from "../zoned-time.js";
|
|
7
8
|
import type { JobsContext } from "./context.js";
|
|
8
9
|
import { enqueueJob } from "./enqueue.js";
|
|
9
10
|
import { getJobsOptions } from "./options.js";
|
|
@@ -16,16 +17,23 @@ export const SCHEDULE_DEDUP_KEY = "schedule";
|
|
|
16
17
|
|
|
17
18
|
/** A schedule's health as `getQueueStats` reports it. */
|
|
18
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). */
|
|
19
21
|
readonly everySeconds: number;
|
|
20
22
|
/** The start of the latest period a run was enqueued for; null before any worker saw the schedule. */
|
|
21
23
|
readonly lastEnqueuedAt: Date | null;
|
|
22
24
|
/** When a job of the schedule's queue last succeeded; null when none has yet. */
|
|
23
25
|
readonly lastSucceededAt: Date | null;
|
|
26
|
+
/** When a job of the schedule's queue last ended `failed` (attempts used up, or failed for good); null when none has. */
|
|
27
|
+
readonly lastFailedAt: Date | null;
|
|
28
|
+
/** That job's error code (the handler's code, `jobs.handler_threw`, `jobs.lease_expired`…); null when none failed. */
|
|
29
|
+
readonly lastFailureCode: string | null;
|
|
24
30
|
/**
|
|
25
31
|
* True when no run succeeded within two periods (counted from when a worker first saw the schedule), or no worker
|
|
26
32
|
* has seen it at all: no worker runs its queue, or its runs keep failing.
|
|
27
33
|
*/
|
|
28
34
|
readonly isLate: boolean;
|
|
35
|
+
/** True when the latest outcome is a failure: `lastFailedAt` is later than `lastSucceededAt`, or nothing succeeded. */
|
|
36
|
+
readonly isFailing: boolean;
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
export interface EnqueueDueSchedulesInput {
|
|
@@ -33,8 +41,12 @@ export interface EnqueueDueSchedulesInput {
|
|
|
33
41
|
readonly queues?: readonly string[];
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* The slot `now` falls in: the start of its period, or the latest of the schedule's local times at or before `now`.
|
|
46
|
+
* Slots only move forward as `now` does, across DST changes too.
|
|
47
|
+
*/
|
|
37
48
|
export function getScheduleSlot(schedule: JobScheduleOptions, now: Date): Date {
|
|
49
|
+
if ("at" in schedule) return new Date(getLocalTimeSlot(schedule, now.getTime()));
|
|
38
50
|
const periodMs = getEverySeconds(schedule) * 1000;
|
|
39
51
|
return new Date(Math.floor(now.getTime() / periodMs) * periodMs);
|
|
40
52
|
}
|
|
@@ -84,6 +96,13 @@ export async function recordScheduleSuccess(ctx: JobsContext, queue: string): Pr
|
|
|
84
96
|
await ctx.db.update(schedules).set({ lastSucceededAt: now, updatedAt: now }).where(eq(schedules.name, queue));
|
|
85
97
|
}
|
|
86
98
|
|
|
99
|
+
/** Notes a failed job of a scheduled queue (terminal, not a retried attempt) with its error code. */
|
|
100
|
+
export async function recordScheduleFailure(ctx: JobsContext, queue: string, code: string): Promise<void> {
|
|
101
|
+
if (!Object.hasOwn(getJobsOptions(ctx.config).schedules, queue)) return;
|
|
102
|
+
const now = ctx.clock.now();
|
|
103
|
+
await ctx.db.update(schedules).set({ lastFailedAt: now, lastFailureCode: code, updatedAt: now }).where(eq(schedules.name, queue));
|
|
104
|
+
}
|
|
105
|
+
|
|
87
106
|
/** The state of every configured schedule, by queue name. */
|
|
88
107
|
export async function getScheduleStates(ctx: JobsContext): Promise<Map<string, ScheduleState>> {
|
|
89
108
|
const options = getJobsOptions(ctx.config);
|
|
@@ -97,7 +116,11 @@ export async function getScheduleStates(ctx: JobsContext): Promise<Map<string, S
|
|
|
97
116
|
const row = rows.find((candidate) => candidate.name === name);
|
|
98
117
|
const since = row?.lastSucceededAt ?? row?.createdAt ?? null;
|
|
99
118
|
const isLate = since === null || now - since.getTime() > 2 * everySeconds * 1000;
|
|
100
|
-
|
|
119
|
+
const lastSucceededAt = row?.lastSucceededAt ?? null;
|
|
120
|
+
const lastFailedAt = row?.lastFailedAt ?? null;
|
|
121
|
+
const isFailing = lastFailedAt !== null && (lastSucceededAt === null || lastFailedAt > lastSucceededAt);
|
|
122
|
+
const lastFailureCode = row?.lastFailureCode ?? null;
|
|
123
|
+
return [name, { everySeconds, lastEnqueuedAt: row?.lastSlotAt ?? null, lastSucceededAt, lastFailedAt, lastFailureCode, isLate, isFailing }];
|
|
101
124
|
}),
|
|
102
125
|
);
|
|
103
126
|
}
|
|
@@ -114,8 +137,38 @@ export function getSchedule(options: JobsOptions, name: string): JobScheduleOpti
|
|
|
114
137
|
}
|
|
115
138
|
|
|
116
139
|
function getEverySeconds(schedule: JobScheduleOptions): number {
|
|
140
|
+
if ("at" in schedule) return getLongestGapSeconds(schedule.at);
|
|
117
141
|
const seconds = parseEverySeconds(schedule.every);
|
|
118
142
|
// The options schema refused any other value at startup.
|
|
119
143
|
if (seconds === null) throw new Error(`@softure-ai/jobs: invalid schedule period "${schedule.every}"`);
|
|
120
144
|
return seconds;
|
|
121
145
|
}
|
|
146
|
+
|
|
147
|
+
function getLocalTimeSlot(schedule: LocalTimeScheduleOptions, now: number): number {
|
|
148
|
+
const today = getWallClock(now, schedule.timeZone);
|
|
149
|
+
// The calendar day before today's local date; Date.UTC rolls day 0 over to the month before.
|
|
150
|
+
const yesterday = new Date(Date.UTC(today.year, today.month - 1, today.day - 1));
|
|
151
|
+
const days = [
|
|
152
|
+
{ year: yesterday.getUTCFullYear(), month: yesterday.getUTCMonth() + 1, day: yesterday.getUTCDate() },
|
|
153
|
+
{ year: today.year, month: today.month, day: today.day },
|
|
154
|
+
];
|
|
155
|
+
let slot = Number.NEGATIVE_INFINITY;
|
|
156
|
+
for (const day of days) {
|
|
157
|
+
for (const time of schedule.at) {
|
|
158
|
+
const [hour, minute] = time.split(":").map(Number);
|
|
159
|
+
const instant = getZonedInstant({ ...day, hour: hour ?? 0, minute: minute ?? 0 }, schedule.timeZone);
|
|
160
|
+
if (instant <= now && instant > slot) slot = instant;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return slot;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The longest gap between two consecutive times of day, wrapping past midnight; `times` is sorted. */
|
|
167
|
+
function getLongestGapSeconds(times: readonly string[]): number {
|
|
168
|
+
const minutes = times.map((time) => {
|
|
169
|
+
const [hour, minute] = time.split(":").map(Number);
|
|
170
|
+
return (hour ?? 0) * 60 + (minute ?? 0);
|
|
171
|
+
});
|
|
172
|
+
const gaps = minutes.map((minute, index) => (minutes[(index + 1) % minutes.length] ?? minute) - minute + (index === minutes.length - 1 ? 24 * 60 : 0));
|
|
173
|
+
return Math.max(...gaps) * 60;
|
|
174
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The worker's heartbeat: a file the loop touches after each round whose claims reached the database, and the check
|
|
2
|
+
// an image runs (`node worker.mjs --health`) to tell a running loop from a process that only stays alive.
|
|
3
|
+
import { rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import type { SoftureConfig } from "@softure-ai/core";
|
|
5
|
+
import type { JobsLogger } from "../server/logger.js";
|
|
6
|
+
import { getJobsOptions } from "../server/options.js";
|
|
7
|
+
|
|
8
|
+
/** The age limit never drops below this, so a slow round (an hourly prune) does not read as a stuck loop. */
|
|
9
|
+
const MIN_HEALTH_MAX_AGE_MS = 30_000;
|
|
10
|
+
const POLL_INTERVALS_PER_MAX_AGE = 5;
|
|
11
|
+
|
|
12
|
+
export type JobWorkerHealth = { ok: true; ageMs: number } | { ok: false; reason: "missing" } | { ok: false; reason: "stale"; ageMs: number };
|
|
13
|
+
|
|
14
|
+
export interface CheckJobWorkerHealthInput {
|
|
15
|
+
readonly healthFile: string;
|
|
16
|
+
/** The oldest heartbeat that still counts; `getJobWorkerHealthMaxAgeMs(config)` by default in the process. */
|
|
17
|
+
readonly maxAgeMs: number;
|
|
18
|
+
/** Epoch ms to compare the file's modification time with; default `Date.now()`. */
|
|
19
|
+
readonly now?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The default age limit: five poll intervals, at least 30 s. */
|
|
23
|
+
export function getJobWorkerHealthMaxAgeMs(config: SoftureConfig): number {
|
|
24
|
+
return Math.max(MIN_HEALTH_MAX_AGE_MS, POLL_INTERVALS_PER_MAX_AGE * getJobsOptions(config).pollIntervalMs);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether the heartbeat file exists and is no older than `maxAgeMs`. Reads the file system only. */
|
|
28
|
+
export function checkJobWorkerHealth(input: CheckJobWorkerHealthInput): JobWorkerHealth {
|
|
29
|
+
let mtimeMs: number;
|
|
30
|
+
try {
|
|
31
|
+
mtimeMs = statSync(input.healthFile).mtimeMs;
|
|
32
|
+
} catch {
|
|
33
|
+
return { ok: false, reason: "missing" };
|
|
34
|
+
}
|
|
35
|
+
const ageMs = Math.max(0, Math.round((input.now ?? Date.now()) - mtimeMs));
|
|
36
|
+
return ageMs > input.maxAgeMs ? { ok: false, reason: "stale", ageMs } : { ok: true, ageMs };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Writes and removes the heartbeat file; a failure is logged once until a write succeeds again, never thrown. */
|
|
40
|
+
export function createHealthFile(healthFile: string, logger: JobsLogger): { touch: () => void; remove: () => void } {
|
|
41
|
+
let hasFailed = false;
|
|
42
|
+
const report = (operation: string, error: unknown) => {
|
|
43
|
+
if (hasFailed) return;
|
|
44
|
+
hasFailed = true;
|
|
45
|
+
logger.error(`could not ${operation} the worker's health file`, { healthFile, error });
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
touch: () => {
|
|
49
|
+
try {
|
|
50
|
+
writeFileSync(healthFile, new Date().toISOString());
|
|
51
|
+
hasFailed = false;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
report("write", error);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
remove: () => {
|
|
57
|
+
try {
|
|
58
|
+
rmSync(healthFile, { force: true });
|
|
59
|
+
} catch (error) {
|
|
60
|
+
report("remove", error);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/worker/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// The worker of @softure-ai/jobs: the loop (`runJobWorker`)
|
|
2
|
-
// (`runJobWorkerProcess`).
|
|
1
|
+
// The worker of @softure-ai/jobs: the loop (`runJobWorker`), the process entry an app's `scripts/worker.ts` calls
|
|
2
|
+
// (`runJobWorkerProcess`) and the check of its heartbeat (`checkJobWorkerHealth`).
|
|
3
|
+
export { checkJobWorkerHealth, getJobWorkerHealthMaxAgeMs, type CheckJobWorkerHealthInput, type JobWorkerHealth } from "./health.js";
|
|
3
4
|
export { runJobWorkerProcess, type RunJobWorkerProcessOptions } from "./process.js";
|
|
4
5
|
export { runJobWorker, type RunJobWorkerOptions } from "./run-job-worker.js";
|
package/src/worker/process.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
// The worker as a process: what an app's `scripts/worker.ts` calls. It opens its own database pool, turns SIGTERM and
|
|
2
|
-
// SIGINT into a graceful stop, closes the pool and reports an exit code.
|
|
2
|
+
// SIGINT into a graceful stop, closes the pool and reports an exit code. With `--health` it checks the heartbeat file
|
|
3
|
+
// of a running worker instead.
|
|
3
4
|
import { errorLogLabel, type SoftureConfig } from "@softure-ai/core";
|
|
4
5
|
import { openCommandDatabase, type Database } from "@softure-ai/db";
|
|
5
6
|
import type { JobHandlers } from "../handlers.js";
|
|
6
7
|
import { consoleJobsLogger, type JobsLogger } from "../server/logger.js";
|
|
7
8
|
import { getJobsOptions } from "../server/options.js";
|
|
9
|
+
import { checkJobWorkerHealth, getJobWorkerHealthMaxAgeMs } from "./health.js";
|
|
8
10
|
import { runJobWorker, type RunJobWorkerOptions } from "./run-job-worker.js";
|
|
9
11
|
|
|
10
12
|
const EXIT_OK = 0;
|
|
11
13
|
const EXIT_FAILED = 1;
|
|
12
14
|
const STOP_SIGNALS = ["SIGTERM", "SIGINT"] as const;
|
|
15
|
+
const HEALTH_FLAG = "--health";
|
|
13
16
|
|
|
14
|
-
export interface RunJobWorkerProcessOptions
|
|
17
|
+
export interface RunJobWorkerProcessOptions
|
|
18
|
+
extends Partial<Pick<RunJobWorkerOptions, "queues" | "clock" | "workerId" | "random" | "heartbeatMs" | "healthFile">> {
|
|
15
19
|
readonly config: SoftureConfig;
|
|
16
20
|
readonly handlers: JobHandlers;
|
|
17
21
|
readonly logger?: JobsLogger;
|
|
@@ -19,11 +23,17 @@ export interface RunJobWorkerProcessOptions extends Partial<Pick<RunJobWorkerOpt
|
|
|
19
23
|
readonly database?: Database;
|
|
20
24
|
/** Stops the worker instead of SIGTERM and SIGINT (tests). */
|
|
21
25
|
readonly signal?: AbortSignal;
|
|
26
|
+
/** The oldest heartbeat `--health` accepts; default `getJobWorkerHealthMaxAgeMs(config)` (five poll intervals, at least 30 s). */
|
|
27
|
+
readonly healthMaxAgeMs?: number;
|
|
28
|
+
/** The process arguments; default `process.argv.slice(2)`. `--health` checks `healthFile` instead of running. */
|
|
29
|
+
readonly argv?: readonly string[];
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
/**
|
|
25
33
|
* Runs the worker until SIGTERM or SIGINT, then stops gracefully. Resolves to the exit code and sets
|
|
26
|
-
* `process.exitCode`: 0 after a clean stop, 1 when it could not start or the database could not be opened.
|
|
34
|
+
* `process.exitCode`: 0 after a clean stop, 1 when it could not start or the database could not be opened. Started
|
|
35
|
+
* with `--health`, it opens nothing and checks the running worker's `healthFile` instead: 0 when its loop ran within
|
|
36
|
+
* `healthMaxAgeMs`, else 1.
|
|
27
37
|
*
|
|
28
38
|
* // scripts/worker.ts
|
|
29
39
|
* import { runJobWorkerProcess } from "@softure-ai/jobs/worker";
|
|
@@ -32,11 +42,29 @@ export interface RunJobWorkerProcessOptions extends Partial<Pick<RunJobWorkerOpt
|
|
|
32
42
|
* await runJobWorkerProcess({ config, handlers });
|
|
33
43
|
*/
|
|
34
44
|
export async function runJobWorkerProcess(options: RunJobWorkerProcessOptions): Promise<number> {
|
|
35
|
-
const
|
|
45
|
+
const argv = options.argv ?? process.argv.slice(2);
|
|
46
|
+
const exitCode = argv.includes(HEALTH_FLAG) ? checkHealth(options) : await runProcess(options);
|
|
36
47
|
process.exitCode = exitCode;
|
|
37
48
|
return exitCode;
|
|
38
49
|
}
|
|
39
50
|
|
|
51
|
+
function checkHealth(options: RunJobWorkerProcessOptions): number {
|
|
52
|
+
const logger = options.logger ?? consoleJobsLogger;
|
|
53
|
+
if (options.healthFile === undefined) {
|
|
54
|
+
logger.error(`${HEALTH_FLAG} needs the worker's healthFile option; pass healthFile to runJobWorkerProcess`);
|
|
55
|
+
return EXIT_FAILED;
|
|
56
|
+
}
|
|
57
|
+
const maxAgeMs = options.healthMaxAgeMs ?? getJobWorkerHealthMaxAgeMs(options.config);
|
|
58
|
+
const health = checkJobWorkerHealth({ healthFile: options.healthFile, maxAgeMs });
|
|
59
|
+
if (health.ok) {
|
|
60
|
+
logger.info(`the worker's loop ran ${String(health.ageMs)} ms ago`);
|
|
61
|
+
return EXIT_OK;
|
|
62
|
+
}
|
|
63
|
+
if (health.reason === "missing") logger.error(`the worker's health file is missing: ${options.healthFile}`);
|
|
64
|
+
else logger.error(`the worker's loop last ran ${String(health.ageMs)} ms ago (limit ${String(maxAgeMs)} ms)`);
|
|
65
|
+
return EXIT_FAILED;
|
|
66
|
+
}
|
|
67
|
+
|
|
40
68
|
async function runProcess(options: RunJobWorkerProcessOptions): Promise<number> {
|
|
41
69
|
const logger = options.logger ?? consoleJobsLogger;
|
|
42
70
|
const database = options.config.database;
|
|
@@ -13,6 +13,7 @@ import { pruneJobs } from "../server/maintenance.js";
|
|
|
13
13
|
import { getJobsOptions, getQueue } from "../server/options.js";
|
|
14
14
|
import { runClaimedJob } from "../server/process.js";
|
|
15
15
|
import { enqueueScheduledRun, getSchedule, getScheduleNames, getScheduleSlot } from "../server/schedules.js";
|
|
16
|
+
import { createHealthFile } from "./health.js";
|
|
16
17
|
|
|
17
18
|
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
|
|
18
19
|
/** A lease is extended three times per lease period, so one slow write does not lose it. */
|
|
@@ -35,6 +36,11 @@ export interface RunJobWorkerOptions {
|
|
|
35
36
|
readonly random?: () => number;
|
|
36
37
|
/** How often a running job's lease is extended; default a third of the queue's `leaseSeconds`. */
|
|
37
38
|
readonly heartbeatMs?: number;
|
|
39
|
+
/**
|
|
40
|
+
* A file the loop touches after each round whose claims reached the database (removed at start and after a clean
|
|
41
|
+
* stop), for a health check of the loop: `checkJobWorkerHealth`. Default none.
|
|
42
|
+
*/
|
|
43
|
+
readonly healthFile?: string;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
interface RunningJob {
|
|
@@ -69,6 +75,9 @@ export async function runJobWorker(options: RunJobWorkerOptions): Promise<void>
|
|
|
69
75
|
if (idle.length > 0 && options.queues === undefined) {
|
|
70
76
|
logger.warn("configured queues without a handler are not run by this worker", { queues: idle.join(", ") });
|
|
71
77
|
}
|
|
78
|
+
const healthFile = options.healthFile === undefined ? undefined : createHealthFile(options.healthFile, logger);
|
|
79
|
+
// A file left by an earlier run of the same container must not pass the check before this loop ran a round.
|
|
80
|
+
healthFile?.remove();
|
|
72
81
|
logger.info("worker started", { workerId, queues: queues.join(", ") });
|
|
73
82
|
|
|
74
83
|
const start = (claimed: ClaimedJob): RunningJob => {
|
|
@@ -113,6 +122,7 @@ export async function runJobWorker(options: RunJobWorkerOptions): Promise<void>
|
|
|
113
122
|
logger.error("could not enqueue a scheduled run; trying again next round", { queue: name, error });
|
|
114
123
|
}
|
|
115
124
|
}
|
|
125
|
+
let isClaimFailed = false;
|
|
116
126
|
for (const queue of queues) {
|
|
117
127
|
const slots = running.get(queue) ?? new Set<RunningJob>();
|
|
118
128
|
const free = getQueue(jobsOptions, queue).concurrency - slots.size;
|
|
@@ -121,6 +131,7 @@ export async function runJobWorker(options: RunJobWorkerOptions): Promise<void>
|
|
|
121
131
|
const claimed = await claimJobs(ctx, { queue, limit: free, workerId });
|
|
122
132
|
for (const job of claimed) slots.add(start(job));
|
|
123
133
|
} catch (error) {
|
|
134
|
+
isClaimFailed = true;
|
|
124
135
|
logger.error("could not claim jobs", { queue, error });
|
|
125
136
|
}
|
|
126
137
|
}
|
|
@@ -133,10 +144,12 @@ export async function runJobWorker(options: RunJobWorkerOptions): Promise<void>
|
|
|
133
144
|
logger.error("could not prune finished jobs", { error });
|
|
134
145
|
}
|
|
135
146
|
}
|
|
147
|
+
if (!isClaimFailed) healthFile?.touch();
|
|
136
148
|
await wakeUp.wait(jobsOptions.pollIntervalMs, options.signal);
|
|
137
149
|
}
|
|
138
150
|
|
|
139
151
|
await stop(running, { ctx, logger, graceMs: jobsOptions.shutdownGraceMs });
|
|
152
|
+
healthFile?.remove();
|
|
140
153
|
logger.info("worker stopped", { workerId });
|
|
141
154
|
}
|
|
142
155
|
|
|
@@ -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
|
+
}
|