@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +63 -10
  3. package/dist/index.d.ts +13 -12
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/options.d.ts +23 -11
  8. package/dist/options.d.ts.map +1 -1
  9. package/dist/options.js +37 -4
  10. package/dist/options.js.map +1 -1
  11. package/dist/schema.d.ts +34 -0
  12. package/dist/schema.d.ts.map +1 -1
  13. package/dist/schema.js +2 -0
  14. package/dist/schema.js.map +1 -1
  15. package/dist/server/claim.d.ts +2 -1
  16. package/dist/server/claim.d.ts.map +1 -1
  17. package/dist/server/claim.js +22 -14
  18. package/dist/server/claim.js.map +1 -1
  19. package/dist/server/schedules.d.ts +13 -1
  20. package/dist/server/schedules.d.ts.map +1 -1
  21. package/dist/server/schedules.js +51 -4
  22. package/dist/server/schedules.js.map +1 -1
  23. package/dist/worker/health.d.ts +30 -0
  24. package/dist/worker/health.d.ts.map +1 -0
  25. package/dist/worker/health.js +53 -0
  26. package/dist/worker/health.js.map +1 -0
  27. package/dist/worker/index.d.ts +1 -0
  28. package/dist/worker/index.d.ts.map +1 -1
  29. package/dist/worker/index.js +3 -2
  30. package/dist/worker/index.js.map +1 -1
  31. package/dist/worker/process.d.ts +8 -2
  32. package/dist/worker/process.d.ts.map +1 -1
  33. package/dist/worker/process.js +27 -3
  34. package/dist/worker/process.js.map +1 -1
  35. package/dist/worker/run-job-worker.d.ts +5 -0
  36. package/dist/worker/run-job-worker.d.ts.map +1 -1
  37. package/dist/worker/run-job-worker.js +9 -0
  38. package/dist/worker/run-job-worker.js.map +1 -1
  39. package/dist/zoned-time.d.ts +19 -0
  40. package/dist/zoned-time.d.ts.map +1 -0
  41. package/dist/zoned-time.js +61 -0
  42. package/dist/zoned-time.js.map +1 -0
  43. package/migrations/0003_add_schedule_failures.sql +8 -0
  44. package/module.json +1 -1
  45. package/package.json +1 -1
  46. package/src/index.ts +3 -1
  47. package/src/options.ts +55 -8
  48. package/src/schema.ts +2 -0
  49. package/src/server/claim.ts +20 -14
  50. package/src/server/schedules.ts +58 -5
  51. package/src/worker/health.ts +64 -0
  52. package/src/worker/index.ts +3 -2
  53. package/src/worker/process.ts +32 -4
  54. package/src/worker/run-job-worker.ts +13 -0
  55. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "jobs",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "dependsOn": {},
5
5
  "dbSchema": "jobs",
6
6
  "tables": ["jobs", "schedules"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softure-ai/jobs",
3
- "version": "0.1.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.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 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({}),
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`) however many workers run. A
69
- * schedule's queue takes the default queue settings unless `queues` names it.
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
  });
@@ -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 the same transaction.
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
- .update(jobs)
137
- .set({
138
- status: "failed",
139
- lastError: LEASE_EXPIRED,
140
- finishedAt: now,
141
- lockedUntil: null,
142
- lockedBy: null,
143
- claimToken: null,
144
- updatedAt: now,
145
- })
146
- .where(and(eq(jobs.queue, queue), eq(jobs.status, "running"), lte(jobs.lockedUntil, now), gte(jobs.attempts, jobs.maxAttempts)));
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) {
@@ -1,9 +1,10 @@
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
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
- /** The start of the period `now` falls in. */
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
- return [name, { everySeconds, lastEnqueuedAt: row?.lastSlotAt ?? null, lastSucceededAt: row?.lastSucceededAt ?? null, isLate }];
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
+ }
@@ -1,4 +1,5 @@
1
- // The worker of @softure-ai/jobs: the loop (`runJobWorker`) and the process entry an app's `scripts/worker.ts` calls
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";
@@ -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 extends Partial<Pick<RunJobWorkerOptions, "queues" | "clock" | "workerId" | "random" | "heartbeatMs">> {
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 exitCode = await runProcess(options);
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
+ }