Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,162 @@
# @suaveplan/job-scheduler - Universal in-process job scheduler with cron + queued runs, retries, timeouts, concurrency control, and OTel observability
## Summary
Universal in-process job scheduler with cron + queued runs, retries, timeouts, concurrency control, and OpenTelemetry observability. Built on `@suaveplan/cron` for expression parsing and next-fire computation.
<!-- begin:auto-docs -->
## Documentation tree
- [errors](./src/errors/job-scheduler-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [job-scheduler-error](./src/errors/job-scheduler-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [job-execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [job-scheduler-otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [job-queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [job-registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cron-rescheduler](./src/scheduler/cron-rescheduler.md) — Houses the two cron-related decisions the scheduler makes after a job settles or resumes: (1) compute the next fire fro…
- [job-dispatcher](./src/scheduler/job-dispatcher.md) — Encapsulates the "hand a job to the executor" step of the scheduler's tick loop. The scheduler core decides *which* job…
- [job-scheduler](./src/scheduler/job-scheduler.md) — The `JobScheduler` class is the package's main entry point: it accepts both cron-scheduled and one-shot queued jobs, ru…
- [job-snapshot](./src/scheduler/job-snapshot.md) — Projects a mutable `ScheduledJob` record (the scheduler's internal data structure) to an immutable `JobStatus` snapshot…
<!-- end:auto-docs --> - [job-scheduler-error](./src/errors/job-scheduler-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_- [execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-scheduler-otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-rescheduler](./src/scheduler/cron-rescheduler.md) — Houses the two cron-related decisions the scheduler makes after a job settles or resumes: (1) compute the next fire fro…
- [job-dispatcher](./src/scheduler/job-dispatcher.md) — Encapsulates the "hand a job to the executor" step of the scheduler's tick loop. The scheduler core decides _which_ job…
- [job-scheduler](./src/scheduler/job-scheduler.md) — The `JobScheduler` class is the package's main entry point: it accepts both cron-scheduled and one-shot queued jobs, ru…
- [job-snapshot](./src/scheduler/job-snapshot.md) — Projects a mutable `ScheduledJob` record (the scheduler's internal data structure) to an immutable `JobStatus` snapshot…
- [job-scheduler-error](./src/errors/job-scheduler-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_- [execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-scheduler-otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-rescheduler](./src/scheduler/cron-rescheduler.md) — Houses the two cron-related decisions the scheduler makes after a job settles or resumes: (1) compute the next fire fro…
- [job-dispatcher](./src/scheduler/job-dispatcher.md) — Encapsulates the "hand a job to the executor" step of the scheduler's tick loop. The scheduler core decides _which_ job…
- [job-scheduler](./src/scheduler/job-scheduler.md) — The `JobScheduler` class is the package's main entry point: it accepts both cron-scheduled and one-shot queued jobs, ru…
- [job-snapshot](./src/scheduler/job-snapshot.md) — Projects a mutable `ScheduledJob` record (the scheduler's internal data structure) to an immutable `JobStatus` snapshot…
- [job-scheduler-error](./src/errors/job-scheduler-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_- [execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-scheduler-otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-rescheduler](./src/scheduler/cron-rescheduler.md) — Houses the two cron-related decisions the scheduler makes after a job settles or resumes: (1) compute the next fire fro…
- [job-dispatcher](./src/scheduler/job-dispatcher.md) — Encapsulates the "hand a job to the executor" step of the scheduler's tick loop. The scheduler core decides _which_ job…
- [job-scheduler](./src/scheduler/job-scheduler.md) — The `JobScheduler` class is the package's main entry point: it accepts both cron-scheduled and one-shot queued jobs, ru…
- [job-snapshot](./src/scheduler/job-snapshot.md) — Projects a mutable `ScheduledJob` record (the scheduler's internal data structure) to an immutable `JobStatus` snapshot…
- [job-scheduler-error](./src/errors/job-scheduler-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_ - [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_- [execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_ - [job-execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [job-scheduler-otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_ - [job-queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [job-registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-rescheduler](./src/scheduler/cron-rescheduler.md) — Houses the two cron-related decisions the scheduler makes after a job settles or resumes: (1) compute the next fire fro…
- [job-dispatcher](./src/scheduler/job-dispatcher.md) — Encapsulates the "hand a job to the executor" step of the scheduler's tick loop. The scheduler core decides _which_ job…
- [job-scheduler](./src/scheduler/job-scheduler.md) — The `JobScheduler` class is the package's main entry point: it accepts both cron-scheduled and one-shot queued jobs, ru…
- [job-snapshot](./src/scheduler/job-snapshot.md) — Projects a mutable `ScheduledJob` record (the scheduler's internal data structure) to an immutable `JobStatus` snapshot…
## Install
```bash
bun add @suaveplan/job-scheduler
```
## Quick start
```ts
import { createJobScheduler } from "@suaveplan/job-scheduler";
const scheduler = createJobScheduler();
scheduler.schedule("@hourly", async ({ logger }) => {
logger.info("hourly heartbeat");
});
scheduler.queue(async () => {
await flushQueue();
}, { delayMs: 5_000 });
scheduler.start();
// On graceful shutdown:
await scheduler.stop();
```
## Retries + timeout
```ts
scheduler.schedule(
"*/15 * * * *",
async ({ signal, attempt }) => {
const res = await fetch("/api/sync", { signal });
if (!res.ok) throw new Error("upstream failed");
if (attempt > 1) console.warn("recovered after retry");
},
{ retries: 3, retryDelayMs: 1_000, timeout: 30_000, tz: "UTC" }
);
```
## Deterministic testing with FakeClock
```ts
import { createJobScheduler } from "@suaveplan/job-scheduler";
interface PendingTimer { fireAt: number; cb: () => void }
const state = { now: 0, nextId: 1, pending: new Map<number, PendingTimer>() };
const timers = {
setTimer: (cb: () => void, ms: number) => {
const id = state.nextId++;
state.pending.set(id, { fireAt: state.now + ms, cb });
return id;
},
clearTimer: (h: unknown) => state.pending.delete(h as number),
};
async function advance(target: number) {
while (true) {
let id: number | undefined;
let at = Infinity;
for (const [k, v] of state.pending) {
if (v.fireAt <= target && v.fireAt < at) { id = k; at = v.fireAt; }
}
if (id === undefined) break;
const t = state.pending.get(id)!;
state.pending.delete(id);
state.now = t.fireAt;
t.cb();
await Promise.resolve();
}
state.now = target;
}
const scheduler = createJobScheduler({
clock: () => state.now,
timers,
});
```
## API summary
| Method | Purpose |
| --------------------------------- | -------------------------------------------------------------------- |
| `schedule(cronExpr, fn, opts?)` | Run `fn` on every cron fire; returns jobId. |
| `queue(fn, opts?)` | One-shot run with `delayMs` or absolute `runAt`. |
| `cancel(jobId)` | Remove from queue + abort in-flight attempt. |
| `pause(jobId?)` / `resume(jobId?)`| Per-job or global pause/resume. |
| `jobs()` | Frozen `JobStatus[]` snapshot for observability. |
| `start()` | Begin the tick loop (idempotent). |
| `stop()` | Drain in-flight + stop ticking (idempotent). |
## Relationship to `@suaveplan/cron`
This package consumes `@suaveplan/cron`'s `parseCron` + `CronExpression` and is the recommended entry point for any application that needs both cron-style and ad-hoc scheduling. The cron parser stays narrow and easy to audit; this scheduler adds retries, timeouts, concurrency, lifecycle, and observability on top.
## Events
The scheduler emits the following topics on the bus passed via `JobSchedulerOptions.events`:
- `job:start` — fired before each attempt.
- `job:complete` — successful settle.
- `job:fail` — terminal failure for an attempt (retries-permitting, the next attempt fires another `job:start`).
- `job:scheduling_exhausted` — cron expression has no future fire; job is auto-cancelled.
- `job:paused` / `job:resumed` / `job:cancelled` — lifecycle transitions.
Use the `JOB_SCHEDULER_EVENTS` constants instead of literal strings to get compile-time guarantees that subscribers match emitters.
## Observability
The package records into the `@suaveplan/job-scheduler` OTel meter:
- `job_scheduler.duration_ms` (histogram, labelled `outcome` + `kind`).
- `job_scheduler.runs_total` (counter, labelled `outcome` + `kind`).
- `job_scheduler.failures_total` (counter, labelled `kind`).
- `job_scheduler.cron_exhausted_total` (counter).
- `job_scheduler.queue_depth` (histogram, sampled per tick).
`outcome ∈ { success, error, timeout }` and `kind ∈ { cron, queued }` — both closed enums to keep label cardinality bounded.

View File

@@ -0,0 +1,127 @@
{
"name": "@suaveplan/job-scheduler",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal in-process job scheduler with cron + queued runs, retries, timeouts, concurrency control, and OTel observability",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./errors/job-scheduler-error": {
"import": {
"types": "./dist/errors/job-scheduler-error.d.ts",
"default": "./dist/errors/job-scheduler-error.js"
},
"require": {
"types": "./dist/errors/job-scheduler-error.d.ts",
"default": "./dist/errors/job-scheduler-error.js"
}
},
"./queue/job-queue": {
"import": {
"types": "./dist/queue/job-queue.d.ts",
"default": "./dist/queue/job-queue.js"
},
"require": {
"types": "./dist/queue/job-queue.d.ts",
"default": "./dist/queue/job-queue.js"
}
},
"./scheduler/job-scheduler": {
"import": {
"types": "./dist/scheduler/job-scheduler.d.ts",
"default": "./dist/scheduler/job-scheduler.js"
},
"require": {
"types": "./dist/scheduler/job-scheduler.d.ts",
"default": "./dist/scheduler/job-scheduler.js"
}
},
"./execution/job-execution": {
"import": {
"types": "./dist/execution/job-execution.d.ts",
"default": "./dist/execution/job-execution.js"
},
"require": {
"types": "./dist/execution/job-execution.d.ts",
"default": "./dist/execution/job-execution.js"
}
},
"./registry/job-registry": {
"import": {
"types": "./dist/registry/job-registry.d.ts",
"default": "./dist/registry/job-registry.js"
},
"require": {
"types": "./dist/registry/job-registry.d.ts",
"default": "./dist/registry/job-registry.js"
}
},
"./otel/job-scheduler-otel": {
"import": {
"types": "./dist/otel/job-scheduler-otel.d.ts",
"default": "./dist/otel/job-scheduler-otel.js"
},
"require": {
"types": "./dist/otel/job-scheduler-otel.d.ts",
"default": "./dist/otel/job-scheduler-otel.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/cron": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/retry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,6 @@
export type { JobSchedulerErrorCode } from "./job-scheduler-error.js";
export {
JOB_SCHEDULER_CODES,
JobSchedulerError,
registerJobSchedulerErrorCodes,
} from "./job-scheduler-error.js";

View File

@@ -0,0 +1,69 @@
# job-scheduler-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the typed error registry and `JobSchedulerError` class for `@suaveplan/job-scheduler`. The package emits exactly three of its own codes — `JOB_SCHEDULER.JOB_NOT_FOUND`, `JOB_SCHEDULER.EXECUTION_FAILED`, and `JOB_SCHEDULER.TIMEOUT`. Every other failure (cron syntax, retry exhaustion, abort) propagates with the originating package's error code unchanged so consumers can branch on the structural cause without scheduler middleware obscuring it.
## Features
- Frozen `JOB_SCHEDULER_CODES` registry built via `defineCodes` from `@suaveplan/error`.
- `JobSchedulerError` class extending `SuaveplanError<JobSchedulerErrorCode>` with runtime code validation at the constructor boundary.
- `registerJobSchedulerErrorCodes()` idempotent side-effect probe returning the registered count.
- Eager registration on module load so consumers do not need to call the registration function manually.
## Usage: Basic Example
```ts
import {
JOB_SCHEDULER_CODES,
JobSchedulerError,
} from "@suaveplan/job-scheduler";
throw new JobSchedulerError(
JOB_SCHEDULER_CODES.JOB_NOT_FOUND,
"job_scheduler.job_not_found",
{ context: { jobId: "abc-123" } }
);
```
## Usage: Advanced Example
```ts
import {
JobSchedulerError,
JOB_SCHEDULER_CODES,
registerJobSchedulerErrorCodes,
} from "@suaveplan/job-scheduler";
// Idempotent probe useful in DI containers and tree-shake audits.
const codeCount = registerJobSchedulerErrorCodes();
if (codeCount !== 3) {
throw new Error("expected exactly three JOB_SCHEDULER codes");
}
try {
// ...
} catch (err) {
if (
err instanceof JobSchedulerError &&
err.code === JOB_SCHEDULER_CODES.TIMEOUT
) {
// Surface timeout-specific UX without leaking other failure modes.
}
throw err;
}
```
## API Reference
- `JOB_SCHEDULER_CODES: { JOB_NOT_FOUND, EXECUTION_FAILED, TIMEOUT }` — frozen string-valued constants.
- `type JobSchedulerErrorCode` — union of the three values above.
- `class JobSchedulerError extends SuaveplanError<JobSchedulerErrorCode>` — constructor `(code, messageKey, options?)`; throws `TypeError` when `code` is not a registered value.
- `registerJobSchedulerErrorCodes(): number` — returns the frozen registry size (always `3`).
## Implementation Notes
The runtime guard inside the constructor is the only defence against unregistered codes once the type system is erased (e.g. across a Worker boundary, an RPC, or a `JSON.parse`). Pre-erasure consumers get a compile-time refusal from the literal union; post-erasure consumers get a `TypeError` at construction time. The package deliberately keeps the registry tiny — three codes — because every other failure mode in the scheduler has a more specific home: cron parse errors live in `CRON.*`, retry exhaustion lives in `RETRY.*`, and aborts surface as `DOMException("AbortError")` per the platform contract. Expanding this registry without a matching `proposal.md` amendment is a contract break.

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
JOB_SCHEDULER_CODES,
JobSchedulerError,
registerJobSchedulerErrorCodes,
} from "./job-scheduler-error.js";
describe("JOB_SCHEDULER_CODES", () => {
it("freezes exactly three codes", () => {
expect(Object.values(JOB_SCHEDULER_CODES).sort()).toEqual([
"JOB_SCHEDULER.EXECUTION_FAILED",
"JOB_SCHEDULER.JOB_NOT_FOUND",
"JOB_SCHEDULER.TIMEOUT",
]);
});
});
describe("JobSchedulerError", () => {
it("constructs a typed error with a known code", () => {
const err = new JobSchedulerError(
JOB_SCHEDULER_CODES.JOB_NOT_FOUND,
"msg.key",
{ context: { jobId: "x" } }
);
expect(err).toBeInstanceOf(JobSchedulerError);
expect(err.code).toBe("JOB_SCHEDULER.JOB_NOT_FOUND");
});
it("rejects an unregistered code at the type-erased boundary", () => {
expect(
() =>
new JobSchedulerError(
"JOB_SCHEDULER.BOGUS" as unknown as typeof JOB_SCHEDULER_CODES.TIMEOUT,
"msg"
)
).toThrow(TypeError);
});
it("carries all three legitimate codes", () => {
for (const code of Object.values(JOB_SCHEDULER_CODES)) {
const err = new JobSchedulerError(code, "msg");
expect(err.code).toBe(code);
}
});
});
describe("registerJobSchedulerErrorCodes", () => {
it("is idempotent and returns the registry size", () => {
expect(registerJobSchedulerErrorCodes()).toBe(3);
expect(registerJobSchedulerErrorCodes()).toBe(3);
});
});

View File

@@ -0,0 +1,60 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Frozen registry of every `JOB_SCHEDULER.*` error code emitted by
* `@suaveplan/job-scheduler`. Built via `defineCodes("JOB_SCHEDULER", ...)`
* and is part of the package's public contract — consumers branch on
* `err.code` rather than parsing `err.message`.
*
* Cron-parse failures are NOT modelled here: they propagate untouched as
* `CRON.*` codes from `@suaveplan/cron`.
*/
export const JOB_SCHEDULER_CODES = defineCodes("JOB_SCHEDULER", [
"JOB_NOT_FOUND",
"EXECUTION_FAILED",
"TIMEOUT",
] as const);
/** Union of every fully-qualified `JOB_SCHEDULER.*` error code. */
export type JobSchedulerErrorCode =
(typeof JOB_SCHEDULER_CODES)[keyof typeof JOB_SCHEDULER_CODES];
const VALID_CODES = new Set<string>(Object.values(JOB_SCHEDULER_CODES));
/**
* Typed error class for the job-scheduler family. The public constructor
* performs a runtime guard so that an unregistered code passed at the
* type-erased boundary (e.g. JSON-deserialised codes from a remote
* scheduler proxy) hard-fails rather than silently constructing a
* malformed error.
*/
export class JobSchedulerError extends SuaveplanError<JobSchedulerErrorCode> {
public constructor(
code: JobSchedulerErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
if (!VALID_CODES.has(code)) {
throw new TypeError(
`Unknown JobSchedulerError code: ${String(code)}`
);
}
super(code, messageKey, options);
}
}
/**
* Eager registration sentinel. Reading the registry size pins the
* `defineCodes` call so the lookup `Set` is materialised before any
* `JobSchedulerError` is thrown. Returns the registered code count so
* callers doing tree-shaking inspection have a side-effect-free probe.
*/
export function registerJobSchedulerErrorCodes(): number {
return VALID_CODES.size;
}
registerJobSchedulerErrorCodes();

View File

@@ -0,0 +1,56 @@
# events
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the closed registry of bus topic names the scheduler emits. By exporting these as a frozen `as const` object rather than literal strings scattered through call sites, consumers get compile-time guarantees that subscriber names match emitter names — a typo on either side becomes a TypeScript error, not a silent miss at runtime.
## Features
- Frozen `JOB_SCHEDULER_EVENTS` object with the canonical name for every emitted topic.
- `JobSchedulerEventName` type union derived from the constant.
- Zero runtime dependencies; tree-shakeable.
## Usage: Basic Example
```ts
import { JOB_SCHEDULER_EVENTS } from "@suaveplan/job-scheduler";
import type { EventBusLike } from "@suaveplan/types";
declare const bus: EventBusLike<Record<string, unknown>>;
bus.on(JOB_SCHEDULER_EVENTS.fail, (payload) => {
console.error("job failed", payload);
});
```
## Usage: Advanced Example
```ts
import { JOB_SCHEDULER_EVENTS, type JobSchedulerEventName } from "@suaveplan/job-scheduler";
function subscribeAll(
bus: EventBusLike<Record<string, unknown>>,
fn: (name: JobSchedulerEventName, payload: unknown) => void
): () => void {
const offs: Array<() => void> = [];
for (const name of Object.values(JOB_SCHEDULER_EVENTS)) {
offs.push(bus.on(name, (p) => fn(name, p)));
}
return () => {
for (const off of offs) off();
};
}
```
## API Reference
- `JOB_SCHEDULER_EVENTS` — frozen `{ start, complete, fail, schedulingExhausted, paused, resumed, cancelled }` constant.
- `type JobSchedulerEventName` — union of the values above.
## Implementation Notes
The map deliberately keys by short camelCase (`schedulingExhausted`) but values are the snake/colon-cased wire names (`"job:scheduling_exhausted"`) the bus actually emits. This split lets call sites read fluently — `JOB_SCHEDULER_EVENTS.fail` is more obvious than `"job:fail"` — while keeping the wire format stable for downstream tooling (log aggregators, dashboards) that grep on the exact emitted string. The `as const` plus `Object.freeze` combination is required: `as const` gives literal types at compile time, and `Object.freeze` prevents mutation at runtime.
Why a flat constant rather than a union of literal strings? Because callers should be able to programmatically iterate over every emitted topic — see the advanced example above where `Object.values(JOB_SCHEDULER_EVENTS)` is used to subscribe to all of them at once. A pure union type would force callers to either hard-code the list (drift hazard) or use an external array of strings (duplication). The constant is the single source of truth that everything else derives from, including the `JobSchedulerEventName` union. Note also that the registry is intentionally small — every new event added here must come with a matching entry in `JobSchedulerEventMap` and a corresponding emitter inside the scheduler, so the contract stays tight and consumers can rely on the registry being exhaustive.

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { JOB_SCHEDULER_EVENTS } from "./events.js";
describe("JOB_SCHEDULER_EVENTS", () => {
it("exposes every canonical topic", () => {
expect(Object.keys(JOB_SCHEDULER_EVENTS).sort()).toEqual([
"cancelled",
"complete",
"fail",
"paused",
"resumed",
"schedulingExhausted",
"start",
]);
});
it("maps to the snake-cased wire names", () => {
expect(JOB_SCHEDULER_EVENTS.start).toBe("job:start");
expect(JOB_SCHEDULER_EVENTS.complete).toBe("job:complete");
expect(JOB_SCHEDULER_EVENTS.fail).toBe("job:fail");
expect(JOB_SCHEDULER_EVENTS.schedulingExhausted).toBe(
"job:scheduling_exhausted"
);
expect(JOB_SCHEDULER_EVENTS.paused).toBe("job:paused");
expect(JOB_SCHEDULER_EVENTS.resumed).toBe("job:resumed");
expect(JOB_SCHEDULER_EVENTS.cancelled).toBe("job:cancelled");
});
it("is frozen", () => {
expect(Object.isFrozen(JOB_SCHEDULER_EVENTS)).toBe(true);
});
});

View File

@@ -0,0 +1,18 @@
/**
* Frozen registry of every bus topic emitted by the scheduler. Consumers
* use these constants instead of literal strings so a typo on the
* subscriber side is a compile error rather than a silent miss.
*/
export const JOB_SCHEDULER_EVENTS = Object.freeze({
start: "job:start",
complete: "job:complete",
fail: "job:fail",
schedulingExhausted: "job:scheduling_exhausted",
paused: "job:paused",
resumed: "job:resumed",
cancelled: "job:cancelled",
} as const);
/** Union of every emitted topic name. */
export type JobSchedulerEventName =
(typeof JOB_SCHEDULER_EVENTS)[keyof typeof JOB_SCHEDULER_EVENTS];

View File

@@ -0,0 +1,2 @@
export type { JobSchedulerEventName } from "./events.js";
export { JOB_SCHEDULER_EVENTS } from "./events.js";

View File

@@ -0,0 +1,7 @@
export type {
ExecuteJobInput,
ExecutionTimers,
JobContext,
JobHandler,
} from "./job-execution.js";
export { executeJob } from "./job-execution.js";

View File

@@ -0,0 +1,91 @@
# job-execution
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Runs a single job from start to terminal outcome with full retry, timeout, and abort semantics. The scheduler delegates per-job execution to this module so the scheduler core stays focused on ordering and concurrency — every detail of "what happens when a handler runs" lives here. The function emits the canonical `job:start` / `job:complete` / `job:fail` events and records the OTel histograms + counters via the colocated `otel` module.
## Features
- Per-attempt `AbortController` linked to the caller's cancel signal AND to an optional timeout deadline.
- Retry loop delegated to `@suaveplan/retry.retry()` with a `createConstantBackoff({ delay: retryDelayMs })` strategy and the scheduler's injected `timers` threaded through.
- Closed event-bus contract (`job:start`, `job:complete`, `job:fail`).
- Per-attempt OTel records, with `timeout` distinguished from `error` in the outcome label.
- `onAttemptController` hook so the scheduler can `cancel()` the in-flight attempt at any time.
## Usage: Basic Example
```ts
import { executeJob } from "@suaveplan/job-scheduler";
import { EventBus } from "@suaveplan/events";
const events = new EventBus();
const controller = new AbortController();
await executeJob({
jobId: "report-1",
kind: "queued",
handler: async ({ logger }) => {
logger.info("running");
},
scheduledAt: Date.now(),
retries: 0,
retryDelayMs: 0,
cancelSignal: controller.signal,
clock: () => Date.now(),
logger: { debug() {}, info() {}, warn() {}, error() {} },
events,
timers: {
setTimer: (cb, ms) => globalThis.setTimeout(cb, ms),
clearTimer: (h) => globalThis.clearTimeout(h as number),
},
});
```
## Usage: Advanced Example
```ts
import { executeJob, JobSchedulerError, JOB_SCHEDULER_CODES } from "@suaveplan/job-scheduler";
let activeController: AbortController | undefined;
try {
await executeJob({
jobId: "flaky",
kind: "queued",
handler: async (ctx) => {
if (ctx.attempt < 3) throw new Error("transient");
},
scheduledAt: 0,
retries: 5,
retryDelayMs: 10,
timeoutMs: 1_000,
cancelSignal: outerController.signal,
clock,
logger,
events,
timers,
onAttemptController: (c) => {
activeController = c;
},
});
} catch (err) {
if (err instanceof JobSchedulerError && err.code === JOB_SCHEDULER_CODES.TIMEOUT) {
// ...
}
throw err;
}
```
## API Reference
- `executeJob(input: ExecuteJobInput): Promise<void>` — orchestrates the full retry/timeout/abort loop.
- `interface JobContext { jobId, signal, attempt, scheduledAt, logger }`.
- `type JobHandler = (ctx: JobContext) => Promise<void>`.
- `type ExecutionTimers = TimersLike` — aliased to the cross-package `TimersLike` contract from `@suaveplan/types`.
- `interface ExecuteJobInput` — see source for the full set of fields (handler, kind, retries, retryDelayMs, timeoutMs, cancelSignal, clock, logger, events, timers, onAttemptController).
## Implementation Notes
The retry loop is delegated to `@suaveplan/retry.retry()` now that the retry primitive accepts an injected `TimersLike` pair through `RetryConfig.timers` — the scheduler threads its own `timers` straight through so FakeClock-driven test runs stay deterministic. The two abort sources (caller cancel + per-attempt timeout) are merged via a single per-attempt `AbortController` inside the retry callback so handlers see one signal, not two. The `onAttemptController` hook is exposed so the scheduler can mark the in-flight controller for an immediate `cancel(jobId)` — without it, the scheduler would need to read internal state from this module across abstraction boundaries. `RetryExhaustedError` is translated to `JOB_SCHEDULER.EXECUTION_FAILED` preserving the underlying error in `cause`; timeout errors are unwrapped and re-thrown directly so callers see the typed `JOB_SCHEDULER.TIMEOUT` they expect.

View File

@@ -0,0 +1,339 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import {
JOB_SCHEDULER_CODES,
JobSchedulerError,
} from "../errors/job-scheduler-error.js";
import {
type ExecutionTimers,
executeJob,
type JobContext,
} from "./job-execution.js";
interface CapturedEvent {
type: string;
payload: unknown;
}
function makeBus(): {
bus: EventBusLike<Record<string, unknown>>;
events: CapturedEvent[];
} {
const events: CapturedEvent[] = [];
const bus: EventBusLike<Record<string, unknown>> = {
on: () => () => undefined,
emit: (type, payload) => {
events.push({ type: type as string, payload });
},
};
return { bus, events };
}
function makeLogger(): LoggerLike {
return {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
}
interface FakeTimerState {
now: number;
nextId: number;
pending: Map<number, { fireAt: number; cb: () => void }>;
}
function makeFakeTimers(state: FakeTimerState): {
timers: ExecutionTimers;
advance: (delta: number) => Promise<void>;
pending: () => number;
} {
const timers: ExecutionTimers = {
setTimer: (cb, ms) => {
const id = state.nextId++;
state.pending.set(id, { fireAt: state.now + ms, cb });
return id;
},
clearTimer: (h) => {
state.pending.delete(h as number);
},
};
const advance = async (delta: number): Promise<void> => {
const target = state.now + delta;
for (;;) {
let nextId: number | undefined;
let nextAt = Number.POSITIVE_INFINITY;
for (const [id, t] of state.pending) {
if (t.fireAt <= target && t.fireAt < nextAt) {
nextId = id;
nextAt = t.fireAt;
}
}
if (nextId === undefined) break;
const entry = state.pending.get(nextId);
if (entry === undefined) break;
state.pending.delete(nextId);
state.now = entry.fireAt;
entry.cb();
await Promise.resolve();
}
state.now = target;
};
return { timers, advance, pending: () => state.pending.size };
}
describe("executeJob", () => {
it("emits start + complete on first-try success", async () => {
const state: FakeTimerState = {
now: 0,
nextId: 1,
pending: new Map(),
};
const { timers } = makeFakeTimers(state);
const { bus, events } = makeBus();
const handler = async (ctx: JobContext): Promise<void> => {
expect(ctx.attempt).toBe(1);
};
await executeJob({
jobId: "j-1",
kind: "queued",
handler,
scheduledAt: 0,
retries: 0,
retryDelayMs: 0,
cancelSignal: new AbortController().signal,
clock: () => state.now,
logger: makeLogger(),
events: bus,
timers,
});
expect(events.map((e) => e.type)).toEqual([
"job:start",
"job:complete",
]);
});
it("retries then succeeds, emitting one start per attempt", async () => {
const state: FakeTimerState = {
now: 0,
nextId: 1,
pending: new Map(),
};
const { timers, advance } = makeFakeTimers(state);
const { bus, events } = makeBus();
let attempts = 0;
const handler = async (ctx: JobContext): Promise<void> => {
attempts = ctx.attempt;
if (ctx.attempt < 3) throw new Error("transient");
};
const promise = executeJob({
jobId: "j-2",
kind: "queued",
handler,
scheduledAt: 0,
retries: 5,
retryDelayMs: 100,
cancelSignal: new AbortController().signal,
clock: () => state.now,
logger: makeLogger(),
events: bus,
timers,
});
// Drain inter-attempt sleeps.
for (let i = 0; i < 10; i++) {
await Promise.resolve();
await Promise.resolve();
await advance(100);
}
await promise;
expect(attempts).toBe(3);
const starts = events.filter((e) => e.type === "job:start").length;
const completes = events.filter(
(e) => e.type === "job:complete"
).length;
const fails = events.filter((e) => e.type === "job:fail").length;
expect(starts).toBe(3);
expect(completes).toBe(1);
expect(fails).toBe(2);
});
it("exhausts retries and throws JOB_SCHEDULER.EXECUTION_FAILED", async () => {
const state: FakeTimerState = {
now: 0,
nextId: 1,
pending: new Map(),
};
const { timers, advance } = makeFakeTimers(state);
const { bus } = makeBus();
const handler = async (): Promise<void> => {
throw new Error("always fails");
};
const promise = executeJob({
jobId: "j-3",
kind: "queued",
handler,
scheduledAt: 0,
retries: 2,
retryDelayMs: 50,
cancelSignal: new AbortController().signal,
clock: () => state.now,
logger: makeLogger(),
events: bus,
timers,
}).catch((e: unknown) => e);
for (let i = 0; i < 10; i++) {
await Promise.resolve();
await Promise.resolve();
await advance(50);
}
const err = await promise;
expect(err).toBeInstanceOf(JobSchedulerError);
expect((err as JobSchedulerError).code).toBe(
JOB_SCHEDULER_CODES.EXECUTION_FAILED
);
});
it("times out a handler that never resolves", async () => {
const state: FakeTimerState = {
now: 0,
nextId: 1,
pending: new Map(),
};
const { timers, advance } = makeFakeTimers(state);
const { bus } = makeBus();
const handler = (ctx: JobContext): Promise<void> =>
new Promise<void>((resolve, reject) => {
ctx.signal.addEventListener("abort", () => {
reject(ctx.signal.reason);
});
});
const promise = executeJob({
jobId: "j-4",
kind: "queued",
handler,
scheduledAt: 0,
retries: 0,
retryDelayMs: 0,
timeoutMs: 100,
cancelSignal: new AbortController().signal,
clock: () => state.now,
logger: makeLogger(),
events: bus,
timers,
}).catch((e: unknown) => e);
await Promise.resolve();
await advance(100);
const err = await promise;
expect(err).toBeInstanceOf(JobSchedulerError);
expect((err as JobSchedulerError).code).toBe(
JOB_SCHEDULER_CODES.TIMEOUT
);
});
it("propagates an externally-aborted cancel signal", async () => {
const state: FakeTimerState = {
now: 0,
nextId: 1,
pending: new Map(),
};
const { timers } = makeFakeTimers(state);
const { bus } = makeBus();
const outer = new AbortController();
outer.abort(new Error("cancelled by scheduler"));
const handler = (ctx: JobContext): Promise<void> =>
new Promise<void>((_resolve, reject) => {
if (ctx.signal.aborted) {
reject(ctx.signal.reason);
return;
}
ctx.signal.addEventListener("abort", () => {
reject(ctx.signal.reason);
});
});
const promise = executeJob({
jobId: "j-5",
kind: "queued",
handler,
scheduledAt: 0,
retries: 0,
retryDelayMs: 0,
cancelSignal: outer.signal,
clock: () => state.now,
logger: makeLogger(),
events: bus,
timers,
}).catch((e: unknown) => e);
const err = await promise;
expect(err).toBeInstanceOf(JobSchedulerError);
expect((err as JobSchedulerError).code).toBe(
JOB_SCHEDULER_CODES.EXECUTION_FAILED
);
});
it("exposes the active per-attempt controller via onAttemptController", async () => {
const state: FakeTimerState = {
now: 0,
nextId: 1,
pending: new Map(),
};
const { timers } = makeFakeTimers(state);
const { bus } = makeBus();
const controllers: AbortController[] = [];
const handler = async (): Promise<void> => undefined;
await executeJob({
jobId: "j-6",
kind: "cron",
handler,
scheduledAt: 0,
retries: 0,
retryDelayMs: 0,
cancelSignal: new AbortController().signal,
clock: () => state.now,
logger: makeLogger(),
events: bus,
timers,
onAttemptController: (c) => controllers.push(c),
});
expect(controllers).toHaveLength(1);
});
it("breaks the retry loop when cancel is requested mid-sleep", async () => {
const state: FakeTimerState = {
now: 0,
nextId: 1,
pending: new Map(),
};
const { timers, advance } = makeFakeTimers(state);
const { bus } = makeBus();
const outer = new AbortController();
let attempts = 0;
const handler = async (): Promise<void> => {
attempts += 1;
throw new Error("fail");
};
const promise = executeJob({
jobId: "j-7",
kind: "queued",
handler,
scheduledAt: 0,
retries: 5,
retryDelayMs: 100,
cancelSignal: outer.signal,
clock: () => state.now,
logger: makeLogger(),
events: bus,
timers,
}).catch((e: unknown) => e);
// Let the first attempt fail.
await Promise.resolve();
// Cancel mid-sleep.
outer.abort(new Error("stop"));
// The sleep's abort listener resolves; the loop sees the aborted
// signal and bails.
await advance(0);
const err = await promise;
expect(err).toBeInstanceOf(JobSchedulerError);
expect(attempts).toBe(1);
});
});

View File

@@ -0,0 +1,230 @@
import {
createConstantBackoff,
type RetryAbortedError,
RetryExhaustedError,
retry,
} from "@suaveplan/retry";
import type { EventBusLike, LoggerLike, TimersLike } from "@suaveplan/types";
import {
JOB_SCHEDULER_CODES,
JobSchedulerError,
} from "../errors/job-scheduler-error.js";
import {
incrementJobFailure,
type JobKind,
type JobOutcome,
recordJobRun,
} from "../otel/job-scheduler-otel.js";
/** Context surface every job handler receives. */
export interface JobContext {
readonly jobId: string;
readonly signal: AbortSignal;
readonly attempt: number;
readonly scheduledAt: number;
readonly logger: LoggerLike;
}
/** Handler contract: receives a `JobContext`, returns a Promise. */
export type JobHandler = (ctx: JobContext) => Promise<void>;
/**
* Timer pair injected from the scheduler so test runs stay deterministic.
* Aliased to `@suaveplan/types`' `TimersLike` so a single `FakeClock`-backed
* pair satisfies the job scheduler, the cron scheduler, and the retry loop
* all at once.
*/
export type ExecutionTimers = TimersLike;
interface BusMap extends Record<string, unknown> {
"job:start": { jobId: string; attempt: number; scheduledAt: number };
"job:complete": {
jobId: string;
attempt: number;
durationMs: number;
};
"job:fail": {
jobId: string;
attempt: number;
durationMs: number;
error: unknown;
};
}
/** Inputs to `executeJob`. Caller owns the lifetimes of every field. */
export interface ExecuteJobInput {
readonly jobId: string;
readonly name?: string;
readonly kind: JobKind;
readonly handler: JobHandler;
readonly scheduledAt: number;
readonly retries: number;
readonly retryDelayMs: number;
readonly timeoutMs?: number;
readonly cancelSignal: AbortSignal;
readonly clock: () => number;
readonly logger: LoggerLike;
readonly events: EventBusLike<BusMap>;
readonly timers: ExecutionTimers;
/**
* Optional handle to expose the active per-attempt controller so the
* scheduler can `cancel()` it. Re-assigned on each retry.
*/
readonly onAttemptController?: (controller: AbortController) => void;
}
interface AttemptHandle {
readonly controller: AbortController;
readonly cleanup: () => void;
}
function linkSignals(
cancelSignal: AbortSignal,
timeoutMs: number | undefined,
timers: ExecutionTimers
): AttemptHandle {
// The retry primitive checks `cancelSignal.aborted` before every
// attempt, so this function is never reached when the signal is
// already aborted — we only have to wire up the live-abort path.
const controller = new AbortController();
const onCancel = (): void => {
controller.abort(cancelSignal.reason);
};
cancelSignal.addEventListener("abort", onCancel, { once: true });
let timerHandle: number | undefined;
if (timeoutMs !== undefined && timeoutMs > 0) {
timerHandle = timers.setTimer(() => {
controller.abort(
new JobSchedulerError(
JOB_SCHEDULER_CODES.TIMEOUT,
"job_scheduler.timeout",
{ context: { timeoutMs } }
)
);
}, timeoutMs);
}
const cleanup = (): void => {
cancelSignal.removeEventListener("abort", onCancel);
if (timerHandle !== undefined) timers.clearTimer(timerHandle);
};
return { controller, cleanup };
}
function isTimeoutError(err: unknown): boolean {
return (
err instanceof JobSchedulerError &&
err.code === JOB_SCHEDULER_CODES.TIMEOUT
);
}
function classifyOutcome(err: unknown): JobOutcome {
return isTimeoutError(err) ? "timeout" : "error";
}
/**
* Execute a single job with retry, timeout, and abort semantics.
*
* - Emits `job:start` for every attempt (1-based `attempt` count).
* - Delegates the retry loop to `@suaveplan/retry.retry()`, threading the
* scheduler's injected `timers` pair into the inter-attempt sleep so
* FakeClock-driven test runs stay deterministic.
* - Emits `job:complete` on success and `job:fail` on every failed attempt
* (including the terminal one).
* - On timeout, throws `JOB_SCHEDULER.TIMEOUT`. On terminal failure for
* any other reason, throws `JOB_SCHEDULER.EXECUTION_FAILED` wrapping
* the original error in `cause`.
*/
export async function executeJob(input: ExecuteJobInput): Promise<void> {
const maxAttempts = Math.max(1, input.retries + 1);
let attemptCounter = 0;
const runAttempt = async (): Promise<void> => {
attemptCounter += 1;
const attempt = attemptCounter;
const handle = linkSignals(
input.cancelSignal,
input.timeoutMs,
input.timers
);
input.onAttemptController?.(handle.controller);
const ctx: JobContext = {
jobId: input.jobId,
signal: handle.controller.signal,
attempt,
scheduledAt: input.scheduledAt,
logger: input.logger,
};
const start = input.clock();
input.events.emit("job:start", {
jobId: input.jobId,
attempt,
scheduledAt: input.scheduledAt,
});
try {
await input.handler(ctx);
const durationMs = input.clock() - start;
handle.cleanup();
recordJobRun(durationMs, "success", input.kind);
input.events.emit("job:complete", {
jobId: input.jobId,
attempt,
durationMs,
});
} catch (err) {
const durationMs = input.clock() - start;
handle.cleanup();
// If the per-attempt controller was aborted with a TIMEOUT
// reason, prefer that error so the outer translator sees the
// typed JobSchedulerError(TIMEOUT) instead of the handler's
// reject value (which is usually the same object, but may be
// the bare ctx.signal.reason).
const reason = handle.controller.signal.reason;
const failure = isTimeoutError(reason) ? reason : err;
const outcome = classifyOutcome(failure);
recordJobRun(durationMs, outcome, input.kind);
incrementJobFailure(input.kind);
input.events.emit("job:fail", {
jobId: input.jobId,
attempt,
durationMs,
error: failure,
});
throw failure;
}
};
try {
await retry(runAttempt, {
maxAttempts,
delay: createConstantBackoff({ delay: input.retryDelayMs }),
signal: input.cancelSignal,
timers: input.timers,
});
} catch (err) {
// `retry()` only ever throws `RetryExhaustedError` or
// `RetryAbortedError`; we translate each to a typed
// `JobSchedulerError`.
if (err instanceof RetryExhaustedError) {
const lastError = err.lastError;
if (isTimeoutError(lastError)) {
throw lastError;
}
throw new JobSchedulerError(
JOB_SCHEDULER_CODES.EXECUTION_FAILED,
"job_scheduler.execution_failed",
{
context: { jobId: input.jobId, attempts: maxAttempts },
cause: lastError,
}
);
}
const aborted = err as RetryAbortedError;
throw new JobSchedulerError(
JOB_SCHEDULER_CODES.EXECUTION_FAILED,
"job_scheduler.execution_failed",
{
context: { jobId: input.jobId, attempts: attemptCounter },
cause: aborted.cause,
}
);
}
}

View File

@@ -0,0 +1,70 @@
// Error codes registration (side-effect import)
import "./errors/job-scheduler-error.js";
// Errors
export type { JobSchedulerErrorCode } from "./errors/job-scheduler-error.js";
export {
JOB_SCHEDULER_CODES,
JobSchedulerError,
registerJobSchedulerErrorCodes,
} from "./errors/job-scheduler-error.js";
// Event constants
export {
JOB_SCHEDULER_EVENTS,
type JobSchedulerEventName,
} from "./events/events.js";
// Execution
export type {
ExecuteJobInput,
ExecutionTimers,
JobContext,
JobHandler,
} from "./execution/job-execution.js";
export { executeJob } from "./execution/job-execution.js";
// OTel
export type { JobKind, JobOutcome } from "./otel/job-scheduler-otel.js";
export {
incrementCronExhausted,
incrementJobFailure,
recordJobDuration,
recordJobRun,
recordQueueDepth,
} from "./otel/job-scheduler-otel.js";
// Queue
export { JobQueue } from "./queue/job-queue.js";
export type { QueuedJob } from "./queue/job-queue.types.js";
// Registry
export { JobRegistry } from "./registry/job-registry.js";
// Scheduler
export {
applyCatchupAndReenqueue,
type CronEnv,
type ReenqueueOutcome,
reenqueueCron,
} from "./scheduler/cron-rescheduler.js";
export {
type DispatchEnv,
dispatch,
type ScheduledJob,
} from "./scheduler/job-dispatcher.js";
export {
createJobScheduler,
JobScheduler,
} from "./scheduler/job-scheduler.js";
export {
JobOptionsSchema,
JobSchedulerOptionsSchema,
QueueJobOptionsSchema,
} from "./scheduler/job-scheduler.schemas.js";
export type {
JobOptions,
JobSchedulerEventMap,
JobSchedulerOptions,
JobStatus,
QueueJobOptions,
} from "./scheduler/job-scheduler.types.js";
export { snapshotJob } from "./scheduler/job-snapshot.js";

View File

@@ -0,0 +1,8 @@
export type { JobKind, JobOutcome } from "./job-scheduler-otel.js";
export {
incrementCronExhausted,
incrementJobFailure,
recordJobDuration,
recordJobRun,
recordQueueDepth,
} from "./job-scheduler-otel.js";

View File

@@ -0,0 +1,76 @@
# job-scheduler-otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wires `@opentelemetry/api` metrics for the scheduler. The module exposes a small set of recording helpers — `recordJobRun`, `recordJobDuration`, `incrementJobFailure`, `incrementCronExhausted`, `recordQueueDepth` — that the scheduler and execution modules call at fixed points. Lazy meter / instrument initialisation keeps cold-start cost near zero for callers that never produce metrics.
## Features
- One meter (`@suaveplan/job-scheduler`) shared across all instruments.
- Lazy-initialised `Histogram`s and `Counter`s — no instrument is created until first use.
- Closed-enum `outcome` and `kind` label values (`success | error | timeout`, `cron | queued`) so cardinality is bounded.
- Pure side-effect-free getters — the module can be imported in tree-shake audits without producing instruments.
## Usage: Basic Example
```ts
import { recordJobRun } from "@suaveplan/job-scheduler";
const start = Date.now();
try {
await runHandler();
recordJobRun(Date.now() - start, "success", "queued");
} catch (err) {
recordJobRun(Date.now() - start, "error", "queued");
throw err;
}
```
## Usage: Advanced Example
```ts
import {
incrementCronExhausted,
incrementJobFailure,
recordJobDuration,
recordJobRun,
recordQueueDepth,
} from "@suaveplan/job-scheduler";
function tick(queueDepth: number): void {
recordQueueDepth(queueDepth);
}
function onCronExhausted(): void {
incrementCronExhausted();
}
function onJobFinished(
durationMs: number,
outcome: "success" | "error" | "timeout",
kind: "cron" | "queued"
): void {
recordJobRun(durationMs, outcome, kind);
if (outcome !== "success") incrementJobFailure(kind);
if (outcome === "timeout") {
recordJobDuration(durationMs, "timeout", kind);
}
}
```
## API Reference
- `type JobOutcome = "success" | "error" | "timeout"`
- `type JobKind = "cron" | "queued"`
- `recordJobRun(durationMs, outcome, kind): void` — records duration histogram sample and increments runs counter.
- `recordJobDuration(durationMs, outcome, kind): void` — duration-only sample, no counter bump.
- `incrementJobFailure(kind): void` — bump failures counter by `kind`.
- `incrementCronExhausted(): void` — bump exhausted-cron counter.
- `recordQueueDepth(depth): void` — sample current heap depth.
## Implementation Notes
All instruments are created exactly once per process via lazy module-scope caches. The OpenTelemetry `metrics` API guarantees stable instrument identity across `getMeter(name).createX` calls only when name + kind + description + unit match — caching here avoids that contract entirely. Label-value enums are closed by TypeScript at the boundary; passing a free-form string would compile-fail. The histograms accept `number` durations; callers compute deltas with the injected scheduler `Clock` (test runs use `FakeClock` so values are deterministic and stable across CI runs).

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
incrementCronExhausted,
incrementJobFailure,
type JobKind,
type JobOutcome,
recordJobDuration,
recordJobRun,
recordQueueDepth,
} from "./job-scheduler-otel.js";
describe("job-scheduler-otel", () => {
it("records a run for every outcome × kind combination", () => {
const outcomes: JobOutcome[] = ["success", "error", "timeout"];
const kinds: JobKind[] = ["cron", "queued"];
for (const outcome of outcomes) {
for (const kind of kinds) {
expect(() => {
recordJobRun(12.5, outcome, kind);
}).not.toThrow();
}
}
});
it("records duration-only samples", () => {
expect(() => {
recordJobDuration(7, "success", "cron");
}).not.toThrow();
});
it("increments failure counter for each kind", () => {
expect(() => {
incrementJobFailure("cron");
incrementJobFailure("queued");
}).not.toThrow();
});
it("increments cron-exhausted counter", () => {
expect(() => {
incrementCronExhausted();
}).not.toThrow();
});
it("records queue depth", () => {
expect(() => {
recordQueueDepth(0);
recordQueueDepth(42);
}).not.toThrow();
});
it("is idempotent across repeated calls (lazy init cache)", () => {
let invocations = 0;
for (let i = 0; i < 5; i++) {
expect(() => {
recordJobRun(1, "success", "queued");
recordJobDuration(1, "success", "queued");
incrementJobFailure("queued");
incrementCronExhausted();
recordQueueDepth(i);
invocations += 1;
}).not.toThrow();
}
expect(invocations).toBe(5);
});
});

View File

@@ -0,0 +1,105 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/job-scheduler";
let _durationHistogram: Histogram | undefined;
let _runsCounter: Counter | undefined;
let _failuresCounter: Counter | undefined;
let _cronExhaustedCounter: Counter | undefined;
let _queueDepthHistogram: Histogram | undefined;
function getDurationHistogram(): Histogram {
if (!_durationHistogram) {
_durationHistogram = metrics
.getMeter(METER_NAME)
.createHistogram("job_scheduler.duration_ms", {
description: "Job execution duration in milliseconds",
unit: "ms",
});
}
return _durationHistogram;
}
function getRunsCounter(): Counter {
if (!_runsCounter) {
_runsCounter = metrics
.getMeter(METER_NAME)
.createCounter("job_scheduler.runs_total", {
description: "Job runs by outcome and kind",
});
}
return _runsCounter;
}
function getFailuresCounter(): Counter {
if (!_failuresCounter) {
_failuresCounter = metrics
.getMeter(METER_NAME)
.createCounter("job_scheduler.failures_total", {
description: "Job failures by kind",
});
}
return _failuresCounter;
}
function getCronExhaustedCounter(): Counter {
if (!_cronExhaustedCounter) {
_cronExhaustedCounter = metrics
.getMeter(METER_NAME)
.createCounter("job_scheduler.cron_exhausted_total", {
description:
"Cron-expression next-run searches that returned no future fire",
});
}
return _cronExhaustedCounter;
}
function getQueueDepthHistogram(): Histogram {
if (!_queueDepthHistogram) {
_queueDepthHistogram = metrics
.getMeter(METER_NAME)
.createHistogram("job_scheduler.queue_depth", {
description: "Queue depth sampled at tick boundaries",
});
}
return _queueDepthHistogram;
}
/** Closed enum so cardinality stays bounded. */
export type JobOutcome = "success" | "error" | "timeout";
/** Closed enum so cardinality stays bounded. */
export type JobKind = "cron" | "queued";
/** Record a run sample: duration histogram + runs counter. */
export function recordJobRun(
durationMs: number,
outcome: JobOutcome,
kind: JobKind
): void {
getDurationHistogram().record(durationMs, { outcome, kind });
getRunsCounter().add(1, { outcome, kind });
}
/** Record a duration sample without bumping the runs counter. */
export function recordJobDuration(
durationMs: number,
outcome: JobOutcome,
kind: JobKind
): void {
getDurationHistogram().record(durationMs, { outcome, kind });
}
/** Increment the failures counter for a job kind. */
export function incrementJobFailure(kind: JobKind): void {
getFailuresCounter().add(1, { kind });
}
/** Increment the `CRON.NEXT_RUN_NOT_FOUND`-drop counter. */
export function incrementCronExhausted(): void {
getCronExhaustedCounter().add(1);
}
/** Sample the queue depth at the current tick. */
export function recordQueueDepth(depth: number): void {
getQueueDepthHistogram().record(depth);
}

View File

@@ -0,0 +1,2 @@
export { JobQueue } from "./job-queue.js";
export type { QueuedJob } from "./job-queue.types.js";

View File

@@ -0,0 +1,72 @@
# job-queue
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
A minimal array-backed binary min-heap that orders `QueuedJob` entries by their `runAt` epoch-ms timestamp. It is the data structure the `JobScheduler` tick loop consults to decide what to run next — `peek` to check whether the earliest job is due, then `dequeue` to claim it. The implementation deliberately has no external dependency so the package's universal bundle stays small and free of polyfill drift.
## Features
- O(log n) `enqueue` and `dequeue`.
- O(1) `peek` and `size`.
- O(n) `remove(jobId)` with worst-case single swap + bidirectional sift.
- Snapshot helper for diagnostics and property tests.
- No external deps; pure TypeScript; tree-shakeable.
## Usage: Basic Example
```ts
import { JobQueue } from "@suaveplan/job-scheduler";
const q = new JobQueue();
q.enqueue({ jobId: "a", runAt: 3 });
q.enqueue({ jobId: "b", runAt: 1 });
q.enqueue({ jobId: "c", runAt: 2 });
while (q.size() > 0) {
const job = q.dequeue();
if (job !== undefined) console.log(job.jobId, job.runAt);
}
// Logs: b 1, c 2, a 3
```
## Usage: Advanced Example
```ts
import { JobQueue } from "@suaveplan/job-scheduler";
const q = new JobQueue();
q.enqueue({ jobId: "cron-1", runAt: 1000, cronExpr: "*/5 * * * *" });
q.enqueue({ jobId: "queued-1", runAt: 2500 });
q.enqueue({ jobId: "queued-2", runAt: 1500 });
// Cancel a job that hasn't run yet.
const removed = q.remove("queued-2");
console.log(removed); // true
// Peek does NOT mutate.
const next = q.peek();
console.log(next?.jobId); // "cron-1"
// snapshot() returns a frozen heap-order view, useful for property tests
// and observability dumps without exposing the internal array.
const view = q.snapshot();
console.log(view.length); // 2
```
## API Reference
- `class JobQueue`
- `enqueue(job: QueuedJob): void`
- `dequeue(): QueuedJob | undefined`
- `peek(): QueuedJob | undefined`
- `size(): number`
- `remove(jobId: string): boolean`
- `snapshot(): readonly QueuedJob[]`
- `interface QueuedJob { jobId: string; runAt: number; cronExpr?: string }`
## Implementation Notes
A linear scan inside `remove` is acceptable because cancellations are rare relative to ticks — most live jobs run to completion and the scheduler reschedules them by `enqueue`, never `remove`. Switching to an O(log n) cancellation scheme (a parallel `Map<jobId, heapIndex>` with index-tracking on every swap) doubles the per-operation constant factor for the common path; we prefer the smaller and obviously-correct linear scan. The bidirectional sift after the swap-with-last is required because the replacement entry may be greater OR lesser than its new parent — one of the two sifts will be a no-op, but skipping it would leave the heap invariant broken in the cases where the removed slot was a left descendant of a smaller sibling.

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { JobQueue } from "./job-queue.js";
describe("JobQueue", () => {
it("returns undefined on empty peek/dequeue", () => {
const q = new JobQueue();
expect(q.peek()).toBeUndefined();
expect(q.dequeue()).toBeUndefined();
expect(q.size()).toBe(0);
});
it("dequeues in strict runAt order", () => {
const q = new JobQueue();
const inputs = [50, 10, 30, 20, 40, 5, 60];
for (const runAt of inputs) {
q.enqueue({ jobId: `j-${runAt}`, runAt });
}
const out: number[] = [];
while (q.size() > 0) {
const j = q.dequeue();
if (j !== undefined) out.push(j.runAt);
}
expect(out).toEqual([...inputs].sort((a, b) => a - b));
});
it("peek does not mutate", () => {
const q = new JobQueue();
q.enqueue({ jobId: "a", runAt: 5 });
q.enqueue({ jobId: "b", runAt: 3 });
expect(q.peek()?.jobId).toBe("b");
expect(q.size()).toBe(2);
expect(q.peek()?.jobId).toBe("b");
});
it("removes the head", () => {
const q = new JobQueue();
q.enqueue({ jobId: "a", runAt: 1 });
q.enqueue({ jobId: "b", runAt: 2 });
q.enqueue({ jobId: "c", runAt: 3 });
expect(q.remove("a")).toBe(true);
expect(q.dequeue()?.jobId).toBe("b");
expect(q.dequeue()?.jobId).toBe("c");
});
it("removes the tail", () => {
const q = new JobQueue();
q.enqueue({ jobId: "a", runAt: 1 });
q.enqueue({ jobId: "b", runAt: 2 });
q.enqueue({ jobId: "c", runAt: 3 });
expect(q.remove("c")).toBe(true);
expect(q.size()).toBe(2);
expect(q.dequeue()?.jobId).toBe("a");
expect(q.dequeue()?.jobId).toBe("b");
});
it("removes mid-heap and preserves order", () => {
const q = new JobQueue();
for (const runAt of [10, 20, 30, 40, 50, 60, 70]) {
q.enqueue({ jobId: `j-${runAt}`, runAt });
}
expect(q.remove("j-30")).toBe(true);
const out: number[] = [];
while (q.size() > 0) {
const j = q.dequeue();
if (j !== undefined) out.push(j.runAt);
}
expect(out).toEqual([10, 20, 40, 50, 60, 70]);
});
it("returns false when removing unknown jobId", () => {
const q = new JobQueue();
q.enqueue({ jobId: "a", runAt: 1 });
expect(q.remove("missing")).toBe(false);
expect(q.size()).toBe(1);
});
it("snapshot returns frozen view", () => {
const q = new JobQueue();
q.enqueue({ jobId: "a", runAt: 2 });
q.enqueue({ jobId: "b", runAt: 1 });
const snap = q.snapshot();
expect(snap).toHaveLength(2);
expect(Object.isFrozen(snap)).toBe(true);
});
it("handles a large randomised sequence", () => {
const q = new JobQueue();
const inputs: number[] = [];
for (let i = 0; i < 1000; i++) {
const runAt = Math.floor(Math.random() * 1_000_000);
inputs.push(runAt);
q.enqueue({ jobId: `j-${i}-${runAt}`, runAt });
}
const sorted = [...inputs].sort((a, b) => a - b);
const out: number[] = [];
while (q.size() > 0) {
const j = q.dequeue();
if (j !== undefined) out.push(j.runAt);
}
expect(out).toEqual(sorted);
});
it("preserves cronExpr on dequeue", () => {
const q = new JobQueue();
q.enqueue({ jobId: "c", runAt: 1, cronExpr: "* * * * *" });
const out = q.dequeue();
expect(out?.cronExpr).toBe("* * * * *");
});
});

View File

@@ -0,0 +1,115 @@
import type { QueuedJob } from "./job-queue.types.js";
/**
* Array-backed binary min-heap keyed by `QueuedJob.runAt`.
*
* - `enqueue` / `dequeue`: O(log n).
* - `peek` / `size`: O(1).
* - `remove(jobId)`: O(n) — linear scan, swap-with-last, sift in the
* appropriate direction.
*
* The heap is intentionally minimal — no external dependency, no GC
* churn beyond the single backing array, no comparator allocation per
* operation. It exists for the scheduler's tick loop, which calls
* `peek` once per tick and `dequeue` at most `concurrency` times.
*/
export class JobQueue {
private readonly heap: QueuedJob[] = [];
/** Add `job` to the heap. Ties on `runAt` are broken by insertion order. */
public enqueue(job: QueuedJob): void {
this.heap.push(job);
this.siftUp(this.heap.length - 1);
}
/** Remove and return the earliest job, or `undefined` if empty. */
public dequeue(): QueuedJob | undefined {
const n = this.heap.length;
if (n === 0) return undefined;
const root = this.heap[0] as QueuedJob;
const last = this.heap.pop() as QueuedJob;
if (n > 1) {
this.heap[0] = last;
this.siftDown(0);
}
return root;
}
/** Earliest job without removing it, or `undefined` if empty. */
public peek(): QueuedJob | undefined {
return this.heap[0];
}
/** Current heap size. */
public size(): number {
return this.heap.length;
}
/**
* Remove the first entry whose `jobId` matches and rebalance.
* Returns `true` if an entry was found and removed.
*/
public remove(jobId: string): boolean {
const idx = this.heap.findIndex((j) => j.jobId === jobId);
if (idx === -1) return false;
const lastIdx = this.heap.length - 1;
if (idx === lastIdx) {
this.heap.pop();
return true;
}
const last = this.heap.pop() as QueuedJob;
this.heap[idx] = last;
// The replacement may belong higher OR lower; try both. Only one
// direction will do work, the other no-ops.
this.siftUp(idx);
this.siftDown(idx);
return true;
}
/** Drain all entries into a snapshot array in heap-order (NOT sorted). */
public snapshot(): readonly QueuedJob[] {
return Object.freeze([...this.heap]);
}
private siftUp(from: number): void {
let i = from;
while (i > 0) {
const parent = (i - 1) >>> 1;
const a = this.heap[i] as QueuedJob;
const b = this.heap[parent] as QueuedJob;
if (a.runAt >= b.runAt) break;
this.heap[i] = b;
this.heap[parent] = a;
i = parent;
}
}
private siftDown(from: number): void {
const n = this.heap.length;
let i = from;
for (;;) {
const left = i * 2 + 1;
const right = left + 1;
let smallest = i;
if (
left < n &&
(this.heap[left] as QueuedJob).runAt <
(this.heap[smallest] as QueuedJob).runAt
) {
smallest = left;
}
if (
right < n &&
(this.heap[right] as QueuedJob).runAt <
(this.heap[smallest] as QueuedJob).runAt
) {
smallest = right;
}
if (smallest === i) break;
const tmp = this.heap[i] as QueuedJob;
this.heap[i] = this.heap[smallest] as QueuedJob;
this.heap[smallest] = tmp;
i = smallest;
}
}
}

View File

@@ -0,0 +1,6 @@
/** A single entry on the priority queue, keyed by `runAt` (epoch ms). */
export interface QueuedJob {
readonly jobId: string;
readonly runAt: number;
readonly cronExpr?: string;
}

View File

@@ -0,0 +1 @@
export { JobRegistry } from "./job-registry.js";

View File

@@ -0,0 +1,73 @@
# job-registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a string-to-jobId binding so application code can refer to scheduled jobs by stable name (`"nightly-cleanup"`, `"hourly-report"`) without leaking the scheduler's internal id generator (`crypto.randomUUID()` by default). The registry is a thin `Map` wrapper with explicit semantics for duplicate names — it throws rather than silently overwriting, because silent overwrite has been a recurring source of "ghost job" bugs in similar systems where a redeploy registers the same name twice and the original job leaks.
## Features
- `register(name, jobId)` — throws on duplicate name with a typed `JobSchedulerError`.
- `unregister(name)` — returns boolean for unambiguous removal semantics.
- `resolve(name) -> jobId | undefined` — pure lookup, never throws.
- `list()` — frozen snapshot for diagnostics and admin endpoints.
- Zero dependencies beyond the package's own error class.
## Usage: Basic Example
```ts
import { JobRegistry } from "@suaveplan/job-scheduler";
const registry = new JobRegistry();
registry.register("nightly", "uuid-1");
console.log(registry.resolve("nightly")); // "uuid-1"
registry.unregister("nightly");
```
## Usage: Advanced Example
```ts
import {
createJobScheduler,
JobRegistry,
JobSchedulerError,
JOB_SCHEDULER_CODES,
} from "@suaveplan/job-scheduler";
const scheduler = createJobScheduler();
const registry = new JobRegistry();
const jobId = scheduler.schedule("0 * * * *", async () => {
// ...
});
registry.register("hourly-report", jobId);
try {
registry.register("hourly-report", "different-id");
} catch (err) {
if (
err instanceof JobSchedulerError &&
err.code === JOB_SCHEDULER_CODES.JOB_NOT_FOUND
) {
// Duplicate-name guard tripped — surface a CLI-friendly message.
}
}
for (const { name, jobId } of registry.list()) {
console.log(name, "->", jobId);
}
```
## API Reference
- `class JobRegistry`
- `register(name: string, jobId: string): void` — throws `JobSchedulerError` on duplicate name.
- `unregister(name: string): boolean` — `true` when removed, `false` when unknown.
- `resolve(name: string): string | undefined` — pure lookup.
- `list(): ReadonlyArray<{ name: string; jobId: string }>` — frozen snapshot.
## Implementation Notes
The duplicate-name branch reuses `JOB_SCHEDULER.JOB_NOT_FOUND` rather than introducing a fourth registry code. The decision is deliberate: the registry is a 12-line wrapper over `Map`, and growing the error registry just for it would couple the package's public contract to a peripheral feature. The `context` payload on the thrown error includes the conflicting `name`, so consumers branching on the duplicate-name case can recognise it without ambiguity. The registry is not synchronised — concurrent registration from two async tasks may race; if you need atomicity, wrap it in a `Mutex` from `@suaveplan/timing`.

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
JOB_SCHEDULER_CODES,
JobSchedulerError,
} from "../errors/job-scheduler-error.js";
import { JobRegistry } from "./job-registry.js";
describe("JobRegistry", () => {
it("registers and resolves a name", () => {
const r = new JobRegistry();
r.register("nightly", "id-1");
expect(r.resolve("nightly")).toBe("id-1");
});
it("returns undefined for unknown name", () => {
const r = new JobRegistry();
expect(r.resolve("missing")).toBeUndefined();
});
it("throws on duplicate name", () => {
const r = new JobRegistry();
r.register("nightly", "id-1");
let captured: unknown;
try {
r.register("nightly", "id-2");
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(JobSchedulerError);
expect((captured as JobSchedulerError).code).toBe(
JOB_SCHEDULER_CODES.JOB_NOT_FOUND
);
});
it("unregisters", () => {
const r = new JobRegistry();
r.register("nightly", "id-1");
expect(r.unregister("nightly")).toBe(true);
expect(r.unregister("nightly")).toBe(false);
expect(r.resolve("nightly")).toBeUndefined();
});
it("allows re-register after unregister", () => {
const r = new JobRegistry();
r.register("nightly", "id-1");
r.unregister("nightly");
expect(() => r.register("nightly", "id-2")).not.toThrow();
expect(r.resolve("nightly")).toBe("id-2");
});
it("lists all bindings as frozen snapshot", () => {
const r = new JobRegistry();
r.register("a", "id-a");
r.register("b", "id-b");
const list = r.list();
expect(list).toHaveLength(2);
expect(Object.isFrozen(list)).toBe(true);
const byName = new Map(list.map((e) => [e.name, e.jobId]));
expect(byName.get("a")).toBe("id-a");
expect(byName.get("b")).toBe("id-b");
});
});

View File

@@ -0,0 +1,51 @@
import {
JOB_SCHEDULER_CODES,
JobSchedulerError,
} from "../errors/job-scheduler-error.js";
/**
* Maps human-friendly names to scheduler-generated job ids so callers
* can refer to jobs by name without leaking the scheduler's internal id
* scheme.
*
* The registry is intentionally minimal: duplicate `register(name, ...)`
* throws `JOB_SCHEDULER.JOB_NOT_FOUND` with a context payload indicating
* the duplicate name — the code is reused because the alternative is
* adding a fourth registry entry that exists only for this one branch.
* Consumers who need stricter semantics can wrap the registry.
*/
export class JobRegistry {
private readonly names = new Map<string, string>();
/** Register `name → jobId`. Throws on duplicate `name`. */
public register(name: string, jobId: string): void {
if (this.names.has(name)) {
throw new JobSchedulerError(
JOB_SCHEDULER_CODES.JOB_NOT_FOUND,
"job_scheduler.registry.duplicate_name",
{ context: { name } }
);
}
this.names.set(name, jobId);
}
/** Remove a name binding; returns `true` when something was removed. */
public unregister(name: string): boolean {
return this.names.delete(name);
}
/** Resolve `name → jobId` or `undefined` if unknown. */
public resolve(name: string): string | undefined {
return this.names.get(name);
}
/** Frozen snapshot of every `(name, jobId)` pair. */
public list(): ReadonlyArray<{ name: string; jobId: string }> {
return Object.freeze(
Array.from(this.names.entries()).map(([name, jobId]) => ({
name,
jobId,
}))
);
}
}

View File

@@ -0,0 +1,61 @@
# cron-rescheduler
## Purpose
Houses the two cron-related decisions the scheduler makes after a job settles or resumes: (1) compute the next fire from the cron expression, OR (2) recognise that the expression has no future fire and gracefully cancel the job. The module also implements optional catchup-missed-fires logic for resume-after-pause scenarios. Extracting this here keeps the scheduler core small and isolates the only place where `CRON.NEXT_RUN_NOT_FOUND` is consumed.
## Features
- `reenqueueCron(job, env, tz)` — pushes the next fire onto the queue, or emits `job:scheduling_exhausted` once and marks the job cancelled when the expression is exhausted.
- `applyCatchupAndReenqueue(...)` — enqueues up to `maxMissedRuns` past fires (those at `now`-or-later are skipped) before re-enqueuing the next future fire.
- Closed `ReenqueueOutcome = "scheduled" | "exhausted"` return for caller branching.
- OTel `cron_exhausted_total` counter is incremented exactly once per exhausted job.
## Usage: Basic Example
```ts
import { reenqueueCron } from "@suaveplan/job-scheduler";
const outcome = reenqueueCron(job, env, "UTC");
if (outcome === "exhausted") {
// job has been marked cancelled; bus has emitted the event
}
```
## Usage: Advanced Example
```ts
import {
applyCatchupAndReenqueue,
reenqueueCron,
} from "@suaveplan/job-scheduler";
function onResume(job: ScheduledJob): void {
if (job.kind !== "cron") return;
const outcome = applyCatchupAndReenqueue(
job,
env,
job.tz ?? "UTC",
/* catchupMissed */ true,
/* maxMissedRuns */ 3
);
if (outcome === "exhausted") {
// log + surface to ops dashboard
}
}
function onComplete(job: ScheduledJob): void {
reenqueueCron(job, env, job.tz ?? "UTC");
}
```
## API Reference
- `type ReenqueueOutcome = "scheduled" | "exhausted"`.
- `interface CronEnv { clock, events, queue, expressionOf }`.
- `reenqueueCron(job, env, tz): ReenqueueOutcome`.
- `applyCatchupAndReenqueue(job, env, tz, catchupMissed, maxMissedRuns): ReenqueueOutcome`.
## Implementation Notes
`expressionOf` is a thin lookup callback rather than a direct map reference so the scheduler can keep the `CronExpression` cache private — exposing it as a parameter would tempt callers to mutate the cache by side channel. The catchup branch uses `expression.nextRuns(maxMissedRuns, lastRun, tz)` from `@suaveplan/cron` rather than rolling its own iteration, ensuring DST transitions and timezone edge cases are handled identically to the rest of the cron stack. Errors other than `CRON.NEXT_RUN_NOT_FOUND` rethrow — they indicate a programming bug (e.g., an invalid tz that somehow slipped past validation) and must surface, not be swallowed.

View File

@@ -0,0 +1,187 @@
import { parseCron } from "@suaveplan/cron";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike } from "@suaveplan/types";
import { JobQueue } from "../queue/job-queue.js";
import {
applyCatchupAndReenqueue,
type CronEnv,
reenqueueCron,
} from "./cron-rescheduler.js";
import type { ScheduledJob } from "./job-dispatcher.js";
import type { JobSchedulerEventMap } from "./job-scheduler.types.js";
function makeEnv(now: number): {
env: CronEnv;
queue: JobQueue;
events: { type: string; payload: unknown }[];
} {
const queue = new JobQueue();
const events: { type: string; payload: unknown }[] = [];
const bus: EventBusLike<JobSchedulerEventMap> = {
on: () => () => undefined,
emit: (type, payload) => {
events.push({ type: type as string, payload });
},
};
const expr = parseCron("* * * * *");
const env: CronEnv = {
clock: () => now,
events: bus,
queue,
expressionOf: () => expr,
};
return { env, queue, events };
}
function makeJob(id = "j-1"): ScheduledJob {
return {
id,
kind: "cron",
cronExpr: "* * * * *",
tz: "UTC",
handler: async () => undefined,
options: {},
status: "pending",
runs: 0,
failures: 0,
};
}
describe("reenqueueCron", () => {
it("returns scheduled and pushes the next fire", () => {
const { env, queue } = makeEnv(Date.UTC(2026, 4, 12, 10, 0, 0));
const job = makeJob();
const outcome = reenqueueCron(job, env, "UTC");
expect(outcome).toBe("scheduled");
expect(queue.size()).toBe(1);
});
it("returns scheduled when expression is unknown (no-op)", () => {
const { env } = makeEnv(Date.UTC(2026, 4, 12, 10, 0, 0));
const job = makeJob();
const unknownEnv: CronEnv = { ...env, expressionOf: () => undefined };
expect(reenqueueCron(job, unknownEnv, "UTC")).toBe("scheduled");
});
it("marks exhausted when nextRun throws CRON.NEXT_RUN_NOT_FOUND", () => {
const { env, events } = makeEnv(Date.UTC(2026, 4, 12, 10, 0, 0));
const job = makeJob();
const fakeExpr = {
expr: "x",
fields: {} as never,
nextRun: () => {
throw Object.assign(new Error("no future fire"), {
code: "CRON.NEXT_RUN_NOT_FOUND",
name: "CronError",
});
},
nextRuns: () => [],
previousRun: () => 0,
matches: () => false,
};
// Provide an env whose expressionOf returns a real CronError via parseCron-impossible
// We can't easily synthesize a CronError without importing — use a fake expression
// that throws an actual CronError by importing the class.
const exhaustingEnv: CronEnv = {
...env,
expressionOf: () => fakeExpr as never,
};
expect(() => reenqueueCron(job, exhaustingEnv, "UTC")).toThrow();
// Above: the fake error is not a real CronError, so it rethrows.
expect(events).toHaveLength(0);
});
});
describe("applyCatchupAndReenqueue", () => {
it("no-ops the catchup branch when lastRun is missing", () => {
const { env, queue } = makeEnv(Date.UTC(2026, 4, 12, 10, 0, 0));
const job = makeJob();
applyCatchupAndReenqueue(job, env, "UTC", true, 3);
// Only the future fire is enqueued.
expect(queue.size()).toBe(1);
});
it("enqueues missed fires before the future fire", () => {
const now = Date.UTC(2026, 4, 12, 10, 5, 0);
const { env, queue } = makeEnv(now);
const job = makeJob();
// Pretend the job last ran 3 minutes ago.
job.lastRun = now - 3 * 60_000;
applyCatchupAndReenqueue(job, env, "UTC", true, 2);
expect(queue.size()).toBeGreaterThanOrEqual(2);
});
it("breaks the catchup loop when a missed fire reaches `now`", () => {
const now = Date.UTC(2026, 4, 12, 10, 0, 0);
const { env, queue } = makeEnv(now);
const job = makeJob();
// lastRun is so recent that the first `nextRuns` result is already
// at-or-after `now`, exercising the `at >= now` break inside
// applyCatchupAndReenqueue.
job.lastRun = now - 30_000;
applyCatchupAndReenqueue(job, env, "UTC", true, 5);
// Only the future fire from reenqueueCron landed.
expect(queue.size()).toBe(1);
});
it("skips catchup when option is false", () => {
const { env, queue } = makeEnv(Date.UTC(2026, 4, 12, 10, 0, 0));
const job = makeJob();
job.lastRun = Date.UTC(2026, 4, 12, 9, 50, 0);
applyCatchupAndReenqueue(job, env, "UTC", false, 99);
expect(queue.size()).toBe(1);
});
it("is a no-op when expression is unknown", () => {
const { env, queue } = makeEnv(Date.UTC(2026, 4, 12, 10, 0, 0));
const job = makeJob();
const noExprEnv: CronEnv = { ...env, expressionOf: () => undefined };
applyCatchupAndReenqueue(job, noExprEnv, "UTC", true, 3);
expect(queue.size()).toBe(0);
});
});
describe("reenqueueCron — without cronExpr field on job", () => {
it("enqueues without the cronExpr passthrough", () => {
const { env, queue } = makeEnv(Date.UTC(2026, 4, 12, 10, 0, 0));
const job = makeJob();
// Strip cronExpr to exercise the conditional-spread else branch.
delete (job as { cronExpr?: string }).cronExpr;
const outcome = reenqueueCron(job, env, "UTC");
expect(outcome).toBe("scheduled");
const head = queue.peek();
expect(head?.cronExpr).toBeUndefined();
});
});
describe("applyCatchupAndReenqueue — without cronExpr field on job", () => {
it("enqueues catchup fires without cronExpr passthrough", () => {
const now = Date.UTC(2026, 4, 12, 10, 5, 0);
const { env, queue } = makeEnv(now);
const job = makeJob();
delete (job as { cronExpr?: string }).cronExpr;
job.lastRun = now - 3 * 60_000;
applyCatchupAndReenqueue(job, env, "UTC", true, 2);
const peek = queue.peek();
expect(peek?.cronExpr).toBeUndefined();
});
});
describe("reenqueueCron — real CronError exhaustion path", () => {
it("emits job:scheduling_exhausted via the typed error class", () => {
// Use a cron expression that genuinely exhausts: months=2 day=30 →
// impossible in February. parseCron accepts this syntactically, but
// nextRun will throw NEXT_RUN_NOT_FOUND when the search horizon is
// exceeded.
const { env, events } = makeEnv(Date.UTC(2026, 1, 28, 0, 0, 0));
const job = makeJob();
const expr = parseCron("0 0 30 2 *"); // Feb 30 — impossible
const exhaustEnv: CronEnv = { ...env, expressionOf: () => expr };
const outcome = reenqueueCron(job, exhaustEnv, "UTC");
expect(outcome).toBe("exhausted");
expect(events.some((e) => e.type === "job:scheduling_exhausted")).toBe(
true
);
expect(job.status).toBe("cancelled");
});
});

View File

@@ -0,0 +1,87 @@
import type { CronExpression } from "@suaveplan/cron";
import { CRON_CODES, CronError } from "@suaveplan/cron/errors/cron-error";
import type { EventBusLike } from "@suaveplan/types";
import { incrementCronExhausted } from "../otel/job-scheduler-otel.js";
import type { JobQueue } from "../queue/job-queue.js";
import type { ScheduledJob } from "./job-dispatcher.js";
import type { JobSchedulerEventMap } from "./job-scheduler.types.js";
/** Discriminator: was the rescheduling successful or exhausted? */
export type ReenqueueOutcome = "scheduled" | "exhausted";
/** Inputs every cron-reschedule decision needs from the scheduler. */
export interface CronEnv {
readonly clock: () => number;
readonly events: EventBusLike<JobSchedulerEventMap>;
readonly queue: JobQueue;
readonly expressionOf: (jobId: string) => CronExpression | undefined;
}
function isCronExhausted(err: unknown): boolean {
return (
err instanceof CronError && err.code === CRON_CODES.NEXT_RUN_NOT_FOUND
);
}
/**
* Compute the next fire for a cron job and push it onto the queue.
* Returns `"exhausted"` when the cron expression no longer has any
* future fire, in which case the job is marked `cancelled` and the
* `job:scheduling_exhausted` event is emitted exactly once.
*/
export function reenqueueCron(
job: ScheduledJob,
env: CronEnv,
tz: string
): ReenqueueOutcome {
const expression = env.expressionOf(job.id);
if (expression === undefined) return "scheduled";
try {
const next = expression.nextRun(env.clock(), tz);
job.nextRun = next;
env.queue.enqueue({
jobId: job.id,
runAt: next,
...(job.cronExpr !== undefined ? { cronExpr: job.cronExpr } : {}),
});
return "scheduled";
} catch (err) {
if (isCronExhausted(err)) {
incrementCronExhausted();
job.status = "cancelled";
env.events.emit("job:scheduling_exhausted", { jobId: job.id });
return "exhausted";
}
throw err;
}
}
/**
* Apply optional catchup-missed-fires logic, then enqueue the next
* scheduled fire via `reenqueueCron`.
*/
export function applyCatchupAndReenqueue(
job: ScheduledJob,
env: CronEnv,
tz: string,
catchupMissed: boolean,
maxMissedRuns: number
): ReenqueueOutcome {
const expression = env.expressionOf(job.id);
if (expression === undefined) return "scheduled";
if (catchupMissed && job.lastRun !== undefined) {
const missed = expression.nextRuns(maxMissedRuns, job.lastRun, tz);
const now = env.clock();
for (const at of missed) {
if (at >= now) break;
env.queue.enqueue({
jobId: job.id,
runAt: at,
...(job.cronExpr !== undefined
? { cronExpr: job.cronExpr }
: {}),
});
}
}
return reenqueueCron(job, env, tz);
}

View File

@@ -0,0 +1,26 @@
export {
applyCatchupAndReenqueue,
type CronEnv,
type ReenqueueOutcome,
reenqueueCron,
} from "./cron-rescheduler.js";
export {
type DispatchEnv,
dispatch,
type ScheduledJob,
} from "./job-dispatcher.js";
export { createJobScheduler, JobScheduler } from "./job-scheduler.js";
export {
JobOptionsSchema,
JobSchedulerOptionsSchema,
QueueJobOptionsSchema,
} from "./job-scheduler.schemas.js";
export type {
JobHandler,
JobOptions,
JobSchedulerEventMap,
JobSchedulerOptions,
JobStatus,
QueueJobOptions,
} from "./job-scheduler.types.js";
export { snapshotJob } from "./job-snapshot.js";

View File

@@ -0,0 +1,67 @@
# job-dispatcher
## Purpose
Encapsulates the "hand a job to the executor" step of the scheduler's tick loop. The scheduler core decides *which* job runs next and *when*; this module is responsible for the *how* — wiring the per-attempt `AbortController`, tracking the in-flight promise, and updating per-job counters (`runs`, `failures`, `lastRun`) on settle. Splitting this out keeps the scheduler core under the 300-line file budget and isolates the small but error-prone bookkeeping work in one focused module.
## Features
- Single `dispatch(job, scheduledAt, env)` function — no class, no state of its own.
- Captures the in-flight promise into the scheduler-owned `Set<Promise>` for `stop()` drain.
- Atomically transitions the job to `running` and registers a fresh `AbortController` on the job record.
- Invokes the scheduler's `afterRun` callback in `finally`, regardless of success or failure.
## Usage: Basic Example
```ts
import { dispatch } from "@suaveplan/job-scheduler";
dispatch(scheduledJob, scheduledAt, {
clock,
logger,
events,
timers,
inFlight,
runningSet,
afterRun: (job) => {
// scheduler decides whether to re-enqueue or remove
},
});
```
## Usage: Advanced Example
```ts
import { dispatch, type ScheduledJob } from "@suaveplan/job-scheduler";
function pump(jobs: Iterable<ScheduledJob>): void {
for (const job of jobs) {
if (job.status !== "pending") continue;
dispatch(job, job.nextRun ?? clock(), {
clock,
logger,
events,
timers,
inFlight,
runningSet,
afterRun: (j) => {
if (j.kind === "queued") {
// one-shot — let the scheduler garbage-collect
return;
}
// re-enqueue cron jobs by computing the next fire
},
});
}
}
```
## API Reference
- `dispatch(job: ScheduledJob, scheduledAt: number, env: DispatchEnv): void` — fires the job. No return value; all bookkeeping happens via `env`.
- `interface ScheduledJob` — internal record shape mutated in place.
- `interface DispatchEnv` — `{ clock, logger, events, timers, inFlight, runningSet, afterRun }`.
## Implementation Notes
The `afterRun` callback is the scheduler's hook for "what happens once the job settles" — for queued jobs the scheduler typically deletes the entry; for cron jobs it computes the next `nextRun` and re-enqueues. Putting this branch in the scheduler core (rather than passing a flag to `dispatch`) keeps `dispatch` polymorphism-free and makes the lifecycle of every cron / queued job obvious at the call site. The promise is tracked in `env.inFlight` so `JobScheduler.stop()` can `Promise.allSettled` over the set and drain the in-flight work cleanly.

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import type { ExecutionTimers } from "../execution/job-execution.js";
import {
type DispatchEnv,
dispatch,
type ScheduledJob,
} from "./job-dispatcher.js";
import type { JobSchedulerEventMap } from "./job-scheduler.types.js";
function makeTimers(): ExecutionTimers {
return {
setTimer: (cb, ms) =>
globalThis.setTimeout(cb, ms) as unknown as number,
clearTimer: (h) => globalThis.clearTimeout(h as number),
};
}
function makeBus(): EventBusLike<JobSchedulerEventMap> {
return { on: () => () => undefined, emit: () => undefined };
}
function makeLogger(): LoggerLike {
return {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
}
describe("dispatch", () => {
it("runs a successful handler and updates counters", async () => {
const job: ScheduledJob = {
id: "j",
kind: "queued",
handler: async () => undefined,
options: {},
status: "pending",
runs: 0,
failures: 0,
};
let calledAfter: ScheduledJob | undefined;
const inFlight = new Set<Promise<void>>();
const env: DispatchEnv = {
clock: () => Date.now(),
logger: makeLogger(),
events: makeBus(),
timers: makeTimers(),
inFlight,
runningSet: new Set(),
afterRun: (j) => {
calledAfter = j;
},
};
dispatch(job, Date.now(), env);
expect(inFlight.size).toBe(1);
await Promise.all(Array.from(inFlight));
expect(job.runs).toBe(1);
expect(job.failures).toBe(0);
expect(calledAfter?.id).toBe("j");
});
it("records a failure and still calls afterRun", async () => {
const job: ScheduledJob = {
id: "j2",
kind: "queued",
handler: async () => {
throw new Error("boom");
},
options: { retries: 0 },
status: "pending",
runs: 0,
failures: 0,
};
let afterRunCalled = false;
const inFlight = new Set<Promise<void>>();
const env: DispatchEnv = {
clock: () => Date.now(),
logger: makeLogger(),
events: makeBus(),
timers: makeTimers(),
inFlight,
runningSet: new Set(),
afterRun: () => {
afterRunCalled = true;
},
};
dispatch(job, Date.now(), env);
await Promise.all(Array.from(inFlight));
expect(job.failures).toBe(1);
expect(afterRunCalled).toBe(true);
});
it("passes through job.name when present", async () => {
const job: ScheduledJob = {
id: "named",
name: "report",
kind: "queued",
handler: async () => undefined,
options: { timeout: 5_000 },
status: "pending",
runs: 0,
failures: 0,
};
const inFlight = new Set<Promise<void>>();
const env: DispatchEnv = {
clock: () => Date.now(),
logger: makeLogger(),
events: makeBus(),
timers: makeTimers(),
inFlight,
runningSet: new Set(),
afterRun: () => undefined,
};
dispatch(job, Date.now(), env);
await Promise.all(Array.from(inFlight));
expect(job.runs).toBe(1);
});
it("clears currentAbort after settle", async () => {
const job: ScheduledJob = {
id: "j3",
kind: "cron",
handler: async () => undefined,
options: {},
status: "pending",
runs: 0,
failures: 0,
};
const inFlight = new Set<Promise<void>>();
const env: DispatchEnv = {
clock: () => Date.now(),
logger: makeLogger(),
events: makeBus(),
timers: makeTimers(),
inFlight,
runningSet: new Set(),
afterRun: () => undefined,
};
dispatch(job, Date.now(), env);
expect(job.currentAbort).toBeDefined();
await Promise.all(Array.from(inFlight));
expect(job.currentAbort).toBeUndefined();
});
});

View File

@@ -0,0 +1,91 @@
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import {
type ExecutionTimers,
executeJob,
type JobHandler,
} from "../execution/job-execution.js";
import type { JobKind } from "../otel/job-scheduler-otel.js";
import type {
JobOptions,
JobSchedulerEventMap,
} from "./job-scheduler.types.js";
const DEFAULT_RETRY_DELAY_MS = 1_000;
/** Internal scheduled-job record. Mutable on purpose — every field is
* owned by the scheduler. */
export interface ScheduledJob {
id: string;
name?: string;
kind: JobKind;
cronExpr?: string;
tz?: string;
handler: JobHandler;
options: JobOptions;
status: "pending" | "running" | "paused" | "cancelled";
runs: number;
failures: number;
lastRun?: number;
nextRun?: number;
currentAbort?: AbortController;
}
/** Inputs the scheduler hands to `dispatch` on every fire. */
export interface DispatchEnv {
readonly clock: () => number;
readonly logger: LoggerLike;
readonly events: EventBusLike<JobSchedulerEventMap>;
readonly timers: ExecutionTimers;
readonly inFlight: Set<Promise<void>>;
readonly runningSet: Set<string>;
readonly afterRun: (job: ScheduledJob) => void;
}
/**
* Hand a single job to `executeJob` with the scheduler's environment.
* The scheduler delegates to this function so its core stays focused
* on ordering and concurrency bookkeeping.
*/
export function dispatch(
job: ScheduledJob,
scheduledAt: number,
env: DispatchEnv
): void {
job.status = "running";
env.runningSet.add(job.id);
const controller = new AbortController();
job.currentAbort = controller;
const promise = executeJob({
jobId: job.id,
...(job.name !== undefined ? { name: job.name } : {}),
kind: job.kind,
handler: job.handler,
scheduledAt,
retries: job.options.retries ?? 0,
retryDelayMs: job.options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS,
...(job.options.timeout !== undefined
? { timeoutMs: job.options.timeout }
: {}),
cancelSignal: controller.signal,
clock: env.clock,
logger: env.logger,
events: env.events,
timers: env.timers,
})
.then(() => {
job.runs += 1;
job.lastRun = scheduledAt;
})
.catch(() => {
job.runs += 1;
job.failures += 1;
job.lastRun = scheduledAt;
})
.finally(() => {
env.runningSet.delete(job.id);
env.inFlight.delete(promise);
delete job.currentAbort;
env.afterRun(job);
});
env.inFlight.add(promise);
}

View File

@@ -0,0 +1,413 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import type { ExecutionTimers } from "../execution/job-execution.js";
import { JobScheduler } from "./job-scheduler.js";
import type { JobSchedulerEventMap } from "./job-scheduler.types.js";
interface TimerState {
now: number;
nextId: number;
pending: Map<number, { fireAt: number; cb: () => void }>;
}
interface Harness {
state: TimerState;
timers: ExecutionTimers;
clock: () => number;
advance: (delta: number) => Promise<void>;
events: { type: string; payload: unknown }[];
bus: EventBusLike<JobSchedulerEventMap>;
logger: LoggerLike;
}
function makeHarness(start = Date.UTC(2026, 4, 12, 10, 0, 0)): Harness {
const state: TimerState = { now: start, nextId: 1, pending: new Map() };
const timers: ExecutionTimers = {
setTimer: (cb, ms) => {
const id = state.nextId++;
state.pending.set(id, { fireAt: state.now + ms, cb });
return id;
},
clearTimer: (h) => {
state.pending.delete(h as number);
},
};
const advance = async (delta: number): Promise<void> => {
const target = state.now + delta;
for (;;) {
let id: number | undefined;
let at = Number.POSITIVE_INFINITY;
for (const [k, v] of state.pending) {
if (v.fireAt <= target && v.fireAt < at) {
id = k;
at = v.fireAt;
}
}
if (id === undefined) break;
const entry = state.pending.get(id);
if (entry === undefined) break;
state.pending.delete(id);
state.now = entry.fireAt;
entry.cb();
await Promise.resolve();
await Promise.resolve();
}
state.now = target;
};
const events: { type: string; payload: unknown }[] = [];
const bus: EventBusLike<JobSchedulerEventMap> = {
on: () => () => undefined,
emit: (type, payload) => {
events.push({ type: type as string, payload });
},
};
const logger: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
return {
state,
timers,
clock: () => state.now,
advance,
events,
bus,
logger,
};
}
function makeScheduler(
h: Harness,
overrides: Record<string, unknown> = {}
): JobScheduler {
let counter = 0;
return new JobScheduler({
clock: h.clock,
timers: h.timers,
events: h.bus,
logger: h.logger,
tickIntervalMs: 100,
generateId: () => `id-${++counter}`,
...overrides,
});
}
describe("JobScheduler — defaults", () => {
it("constructs with zero options", () => {
const s = new JobScheduler();
expect(s).toBeInstanceOf(JobScheduler);
const id = s.queue(async () => undefined, {
runAt: Date.now() + 1_000_000,
});
expect(s.jobs().some((j) => j.id === id)).toBe(true);
s.cancel(id);
});
it("uses Date.now-style default clock when none is injected", () => {
const s = new JobScheduler();
const before = Date.now();
const id = s.queue(async () => undefined, { delayMs: 1_000 });
const job = s.jobs().find((j) => j.id === id);
expect(job?.nextRun).toBeGreaterThanOrEqual(before);
s.cancel(id);
});
it("generates a uuid when no generateId is supplied", () => {
const s = new JobScheduler();
const id = s.queue(async () => undefined, { delayMs: 1_000 });
expect(id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
);
s.cancel(id);
});
it("runs a queued job through default timers + logger + bus", async () => {
const s = new JobScheduler({ tickIntervalMs: 5 });
const internalBus = (
s as unknown as {
events: {
on: (type: string, listener: () => void) => () => void;
};
}
).events;
const unsub = internalBus.on("job:start", () => undefined);
unsub();
let ran = false;
s.queue(
async ({ logger }) => {
logger.debug("d");
logger.info("i");
logger.warn("w");
logger.error("e");
ran = true;
},
{ delayMs: 1 }
);
s.start();
await new Promise((r) => setTimeout(r, 100));
await s.stop();
expect(ran).toBe(true);
});
});
describe("JobScheduler — resume after pause", () => {
it("resumes a paused cron job and re-enqueues the next fire", async () => {
const h = makeHarness();
const s = makeScheduler(h);
let runs = 0;
const id = s.schedule("* * * * *", async () => {
runs += 1;
});
s.pause(id);
s.start();
await h.advance(2 * 60_000);
expect(runs).toBe(0);
s.resume(id);
await h.advance(2 * 60_000 + 500);
await s.stop();
expect(runs).toBeGreaterThanOrEqual(1);
});
it("resumes a paused queued job and runs it", async () => {
const h = makeHarness();
const s = makeScheduler(h);
let ran = false;
const id = s.queue(
async () => {
ran = true;
},
{ delayMs: 100 }
);
s.pause(id);
s.start();
await h.advance(500);
expect(ran).toBe(false);
s.resume(id);
await h.advance(500);
await s.stop();
expect(ran).toBe(true);
});
it("applies catchupMissed on resume of a cron job", async () => {
const h = makeHarness();
const s = makeScheduler(h, {
catchupMissed: true,
maxMissedRuns: 3,
});
let runs = 0;
const id = s.schedule("* * * * *", async () => {
runs += 1;
});
s.start();
await h.advance(70_000);
const after = runs;
s.pause(id);
await h.advance(3 * 60_000);
s.resume(id);
await h.advance(200);
await s.stop();
expect(runs).toBeGreaterThan(after);
});
});
describe("JobScheduler — edge cases for full coverage", () => {
it("queue() accepts handler-only call with default options", () => {
const h = makeHarness();
const s = makeScheduler(h);
const id = s.queue(async () => undefined);
expect(id).toBeDefined();
expect(s.jobs().some((j) => j.id === id)).toBe(true);
});
it("accepts queue() with explicit id + name + zero delay default", () => {
const h = makeHarness();
const s = makeScheduler(h);
const id = s.queue(async () => undefined, {
id: "custom-id",
name: "named-queued",
});
expect(id).toBe("custom-id");
const status = s.jobs().find((j) => j.id === "custom-id");
expect(status?.name).toBe("named-queued");
});
it("accepts schedule() without explicit tz (falls back to UTC)", async () => {
const h = makeHarness();
const s = makeScheduler(h);
let runs = 0;
const id = s.schedule("* * * * *", async () => {
runs += 1;
});
const internal = (
s as unknown as { store: Map<string, { tz?: string }> }
).store.get(id);
if (internal !== undefined) delete internal.tz;
s.start();
await h.advance(60_500);
await s.stop();
expect(runs).toBeGreaterThanOrEqual(1);
});
it("pause + resume a running job", async () => {
const h = makeHarness();
const s = makeScheduler(h);
let resolver: () => void = () => undefined;
const id = s.queue(
() =>
new Promise<void>((res) => {
resolver = res;
}),
{ delayMs: 0 }
);
s.start();
await h.advance(150);
s.pause(id);
const status = s.jobs().find((j) => j.id === id);
expect(status?.status).toBe("paused");
resolver();
s.resume(id);
await s.stop();
});
it("resume of a non-paused job is a no-op (still emits event)", () => {
const h = makeHarness();
const s = makeScheduler(h);
const id = s.schedule("* * * * *", async () => undefined);
s.resume(id);
const status = s.jobs().find((j) => j.id === id);
expect(status?.status).toBe("pending");
});
it("queues with options.runAt skipping delayMs branch", () => {
const h = makeHarness();
const s = makeScheduler(h);
const id = s.queue(async () => undefined, {
runAt: h.clock() + 500,
});
expect(s.jobs().find((j) => j.id === id)).toBeDefined();
});
it("tick callback does not re-arm after stop()", async () => {
const h = makeHarness();
const s = makeScheduler(h);
s.start();
await s.stop();
expect(h.state.pending.size).toBe(0);
});
it("resumes a cron job whose tz field is undefined", () => {
const h = makeHarness();
const s = makeScheduler(h);
const id = s.schedule("* * * * *", async () => undefined);
const internal = (
s as unknown as { store: Map<string, { tz?: string }> }
).store.get(id);
if (internal !== undefined) delete internal.tz;
s.pause(id);
s.resume(id);
expect(s.jobs().find((j) => j.id === id)?.status).toBe("pending");
});
it("pause on a cancelled job is a soft no-op (branch fallthrough)", () => {
const h = makeHarness();
const s = makeScheduler(h);
const id = s.queue(async () => undefined, { delayMs: 1_000 });
s.cancel(id);
s.pause(id);
expect(s.jobs().find((j) => j.id === id)?.status).toBe("cancelled");
});
it("tick callback does not re-arm after stop set running to false", () => {
const h = makeHarness();
const s = makeScheduler(h, { tickIntervalMs: 100 });
s.start();
const internal = s as unknown as { running: boolean };
const pending = Array.from(h.state.pending.entries());
const first = pending[0];
if (first !== undefined) {
const [tid, entry] = first;
h.state.pending.delete(tid);
internal.running = false;
entry.cb();
}
expect(h.state.pending.size).toBe(0);
});
it("dequeues entries whose job has been removed from store", async () => {
const h = makeHarness();
const s = makeScheduler(h);
const id = s.queue(async () => undefined, { delayMs: 100 });
(s as unknown as { store: Map<string, unknown> }).store.delete(id);
s.start();
await h.advance(300);
await s.stop();
expect(s.jobs()).toHaveLength(0);
});
});
describe("JobScheduler — cancel during run", () => {
it("aborts the in-flight handler via stored controller", async () => {
const h = makeHarness();
const s = makeScheduler(h);
let aborted = false;
let resolver: () => void = () => undefined;
const id = s.queue(
(ctx) =>
new Promise<void>((res) => {
ctx.signal.addEventListener("abort", () => {
aborted = true;
});
resolver = res;
}),
{ delayMs: 0 }
);
s.start();
await h.advance(150);
expect(s.cancel(id)).toBe(true);
await Promise.resolve();
resolver();
await s.stop();
expect(aborted).toBe(true);
});
});
describe("JobScheduler — retries + timeout", () => {
it("retries a failing handler then completes", async () => {
const h = makeHarness();
const s = makeScheduler(h);
let attempts = 0;
s.queue(
async () => {
attempts += 1;
if (attempts < 3) throw new Error("retry me");
},
{ delayMs: 0, retries: 5, retryDelayMs: 50 }
);
s.start();
await h.advance(2_000);
await s.stop();
expect(attempts).toBe(3);
});
it("times out a handler that never resolves", async () => {
const h = makeHarness();
const s = makeScheduler(h);
let timedOut = false;
s.queue(
(ctx) =>
new Promise<void>((_resolve, reject) => {
ctx.signal.addEventListener("abort", () => {
timedOut = true;
reject(ctx.signal.reason);
});
}),
{ delayMs: 0, timeout: 200 }
);
s.start();
await h.advance(500);
await s.stop();
expect(timedOut).toBe(true);
});
});

View File

@@ -0,0 +1,86 @@
# job-scheduler
## Purpose
The `JobScheduler` class is the package's main entry point: it accepts both cron-scheduled and one-shot queued jobs, runs them through a single tick loop with bounded concurrency, and exposes lifecycle controls (`start`, `stop`, `pause`, `resume`, `cancel`) plus an observability snapshot via `jobs()`. Cron parsing is delegated to `@suaveplan/cron` so the scheduler never embeds its own expression parser. The injectable `Clock`, `timers`, `events`, and `generateId` make every test run fully deterministic without monkey-patching globals.
## Features
- Cron-scheduled jobs via `schedule(cronExpr, handler, options)`.
- One-shot queued jobs via `queue(handler, options)` with `delayMs` or absolute `runAt`.
- Bounded `concurrency` enforced by the tick loop.
- Per-job `retries`, `retryDelayMs`, and `timeout` honoured by the execution module.
- `pause` / `resume` at job level OR globally.
- `cancel` removes from queue AND aborts the in-flight attempt.
- Optional `catchupMissed` replay of past fires on resume, bounded by `maxMissedRuns`.
- Graceful drain on `stop()` — every in-flight promise is awaited.
- Cron-expression exhaustion handled via `job:scheduling_exhausted` event and `cron_exhausted_total` counter.
## Usage: Basic Example
```ts
import { createJobScheduler } from "@suaveplan/job-scheduler";
const scheduler = createJobScheduler();
scheduler.schedule("*/5 * * * *", async ({ logger }) => {
logger.info("five-minute heartbeat");
});
scheduler.start();
// later
await scheduler.stop();
```
## Usage: Advanced Example
```ts
import { createJobScheduler, JobRegistry } from "@suaveplan/job-scheduler";
import { EventBus } from "@suaveplan/events";
const events = new EventBus();
events.on("job:fail", ({ jobId, error }) => {
console.error("job failed", jobId, error);
});
const registry = new JobRegistry();
const scheduler = createJobScheduler({
concurrency: 10,
events,
catchupMissed: true,
maxMissedRuns: 3,
});
const id = scheduler.schedule(
"0 * * * *",
async ({ signal, attempt }) => {
await fetch("/sync", { signal });
if (attempt > 1) console.warn("recovered after retry");
},
{ retries: 2, retryDelayMs: 500, timeout: 30_000, tz: "UTC" }
);
registry.register("hourly-sync", id);
const adHoc = scheduler.queue(
async () => {
// one-shot job
},
{ delayMs: 5_000 }
);
scheduler.start();
```
## API Reference
- `class JobScheduler`
- `schedule(cronExpr, handler, options?): string` — parses via `@suaveplan/cron`; returns jobId.
- `queue(handler, options?): string` — schedules a one-shot run.
- `cancel(jobId): boolean` — cancels the job; aborts in-flight attempt if any.
- `pause(jobId?): void` / `resume(jobId?): void` — per-job or global.
- `jobs(): readonly JobStatus[]` — frozen snapshot.
- `start(): void` — idempotent.
- `stop(): Promise<void>` — drains every in-flight job before resolving.
- `createJobScheduler(options?): JobScheduler` — factory convenience.
## Implementation Notes
The tick loop is driven by the injected `timers.setTimer` rather than `setInterval` so a slow tick cannot pile up overlapping invocations — every tick schedules its successor in `finally`. The cron-exhaustion branch deliberately emits exactly one event per exhausted job by marking the status `cancelled` before re-entering `afterRun`. The `expressions` cache is private and only reachable through `cronEnv.expressionOf` so the rescheduler helper module cannot mutate it. `stop()` aborts every in-flight controller before awaiting `Promise.allSettled` so handlers that respect `ctx.signal` short-circuit rather than running to completion under shutdown.

View File

@@ -0,0 +1,36 @@
import z from "zod";
export const JobSchedulerOptionsSchema = z
.object({
concurrency: z.number().int().min(1).max(10_000).optional(),
clock: z.function().optional(),
logger: z.unknown().optional(),
catchupMissed: z.boolean().optional(),
maxMissedRuns: z.number().int().min(1).max(10_000).optional(),
tickIntervalMs: z.number().int().min(1).max(60_000).optional(),
timers: z
.object({
setTimer: z.function(),
clearTimer: z.function(),
})
.optional(),
events: z.unknown().optional(),
generateId: z.function().optional(),
})
.strict();
export const JobOptionsSchema = z
.object({
id: z.string().min(1).max(256).optional(),
name: z.string().min(1).max(256).optional(),
retries: z.number().int().min(0).max(10_000).optional(),
retryDelayMs: z.number().int().min(0).max(60_000).optional(),
timeout: z.number().int().min(1).max(86_400_000).optional(),
tz: z.string().min(1).max(64).optional(),
})
.strict();
export const QueueJobOptionsSchema = JobOptionsSchema.extend({
delayMs: z.number().int().min(0).max(86_400_000).optional(),
runAt: z.number().int().min(0).optional(),
}).strict();

View File

@@ -0,0 +1,324 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import type { ExecutionTimers } from "../execution/job-execution.js";
import { createJobScheduler, JobScheduler } from "./job-scheduler.js";
import type { JobSchedulerEventMap } from "./job-scheduler.types.js";
interface TimerState {
now: number;
nextId: number;
pending: Map<number, { fireAt: number; cb: () => void }>;
}
interface Harness {
state: TimerState;
timers: ExecutionTimers;
clock: () => number;
advance: (delta: number) => Promise<void>;
events: { type: string; payload: unknown }[];
bus: EventBusLike<JobSchedulerEventMap>;
logger: LoggerLike;
}
function makeHarness(start = Date.UTC(2026, 4, 12, 10, 0, 0)): Harness {
const state: TimerState = { now: start, nextId: 1, pending: new Map() };
const timers: ExecutionTimers = {
setTimer: (cb, ms) => {
const id = state.nextId++;
state.pending.set(id, { fireAt: state.now + ms, cb });
return id;
},
clearTimer: (h) => {
state.pending.delete(h as number);
},
};
const advance = async (delta: number): Promise<void> => {
const target = state.now + delta;
for (;;) {
let id: number | undefined;
let at = Number.POSITIVE_INFINITY;
for (const [k, v] of state.pending) {
if (v.fireAt <= target && v.fireAt < at) {
id = k;
at = v.fireAt;
}
}
if (id === undefined) break;
const entry = state.pending.get(id);
if (entry === undefined) break;
state.pending.delete(id);
state.now = entry.fireAt;
entry.cb();
await Promise.resolve();
await Promise.resolve();
}
state.now = target;
};
const events: { type: string; payload: unknown }[] = [];
const bus: EventBusLike<JobSchedulerEventMap> = {
on: () => () => undefined,
emit: (type, payload) => {
events.push({ type: type as string, payload });
},
};
const logger: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
return {
state,
timers,
clock: () => state.now,
advance,
events,
bus,
logger,
};
}
function makeScheduler(
h: Harness,
overrides: Record<string, unknown> = {}
): JobScheduler {
let counter = 0;
return new JobScheduler({
clock: h.clock,
timers: h.timers,
events: h.bus,
logger: h.logger,
tickIntervalMs: 100,
generateId: () => `id-${++counter}`,
...overrides,
});
}
describe("JobScheduler — queue", () => {
it("runs a queued job after delayMs", async () => {
const h = makeHarness();
const s = makeScheduler(h);
let ran = false;
s.queue(
async () => {
ran = true;
},
{ delayMs: 200 }
);
s.start();
await h.advance(500);
await s.stop();
expect(ran).toBe(true);
});
it("honours runAt absolute timestamp", async () => {
const h = makeHarness();
const s = makeScheduler(h);
let ran = false;
s.queue(
async () => {
ran = true;
},
{ runAt: h.clock() + 300 }
);
s.start();
await h.advance(500);
await s.stop();
expect(ran).toBe(true);
});
it("respects concurrency cap", async () => {
const h = makeHarness();
const s = makeScheduler(h, { concurrency: 2 });
let inFlight = 0;
let maxSeen = 0;
const finishers: Array<() => void> = [];
for (let i = 0; i < 5; i++) {
s.queue(
async () =>
new Promise<void>((resolve) => {
inFlight += 1;
maxSeen = Math.max(maxSeen, inFlight);
finishers.push(() => {
inFlight -= 1;
resolve();
});
}),
{ delayMs: 0 }
);
}
s.start();
await h.advance(150);
expect(maxSeen).toBeLessThanOrEqual(2);
// Drain handlers in waves so additional jobs flow through the
// concurrency gate.
while (finishers.length > 0) {
const f = finishers.shift();
if (f !== undefined) f();
await Promise.resolve();
await h.advance(150);
}
await s.stop();
expect(maxSeen).toBeLessThanOrEqual(2);
expect(maxSeen).toBeGreaterThan(0);
});
it("cancels a queued job before it runs", async () => {
const h = makeHarness();
const s = makeScheduler(h);
let ran = false;
const id = s.queue(
async () => {
ran = true;
},
{ delayMs: 500 }
);
expect(s.cancel(id)).toBe(true);
s.start();
await h.advance(1_000);
await s.stop();
expect(ran).toBe(false);
});
it("returns false when cancelling unknown id", () => {
const h = makeHarness();
const s = makeScheduler(h);
expect(s.cancel("nope")).toBe(false);
});
it("garbage-collects queued jobs after they complete", async () => {
const h = makeHarness();
const s = makeScheduler(h);
s.queue(async () => undefined, { delayMs: 0 });
s.start();
await h.advance(300);
await s.stop();
expect(s.jobs()).toHaveLength(0);
});
});
describe("JobScheduler — cron", () => {
it("schedules a cron job and reports it via jobs()", async () => {
const h = makeHarness();
const s = makeScheduler(h);
const id = s.schedule("* * * * *", async () => undefined, {
name: "tick",
});
const status = s.jobs().find((j) => j.id === id);
expect(status).toBeDefined();
expect(status?.cronExpr).toBe("* * * * *");
expect(status?.name).toBe("tick");
});
it("fires a cron job repeatedly", async () => {
const h = makeHarness();
const s = makeScheduler(h);
let runs = 0;
s.schedule("* * * * *", async () => {
runs += 1;
});
s.start();
await h.advance(3 * 60_000 + 500);
await s.stop();
expect(runs).toBeGreaterThanOrEqual(2);
});
it("emits job:cancelled when cancelled mid-life", async () => {
const h = makeHarness();
const s = makeScheduler(h);
const id = s.schedule("* * * * *", async () => undefined);
expect(s.cancel(id)).toBe(true);
const cancelled = h.events.find((e) => e.type === "job:cancelled");
expect(cancelled).toBeDefined();
});
});
describe("JobScheduler — pause / resume", () => {
it("globally pauses tick draining", async () => {
const h = makeHarness();
const s = makeScheduler(h);
let ran = 0;
s.queue(
async () => {
ran += 1;
},
{ delayMs: 0 }
);
s.pause();
s.start();
await h.advance(500);
expect(ran).toBe(0);
s.resume();
await h.advance(500);
await s.stop();
expect(ran).toBe(1);
});
it("pauses a single job", async () => {
const h = makeHarness();
const s = makeScheduler(h);
let ran = 0;
const id = s.queue(
async () => {
ran += 1;
},
{ delayMs: 100 }
);
s.pause(id);
s.start();
await h.advance(500);
await s.stop();
expect(ran).toBe(0);
});
it("throws when pausing/resuming unknown id", () => {
const h = makeHarness();
const s = makeScheduler(h);
expect(() => s.pause("nope")).toThrow();
expect(() => s.resume("nope")).toThrow();
});
});
describe("JobScheduler — lifecycle", () => {
it("start is idempotent", () => {
const h = makeHarness();
const s = makeScheduler(h);
s.start();
s.start();
expect(() => s.start()).not.toThrow();
});
it("stop drains in-flight handlers", async () => {
const h = makeHarness();
const s = makeScheduler(h);
let resolved = false;
let resolver: () => void = () => undefined;
s.queue(
() =>
new Promise<void>((res) => {
resolver = () => {
resolved = true;
res();
};
}),
{ delayMs: 0 }
);
s.start();
await h.advance(150);
const stopPromise = s.stop();
// resolver fires after stop initiated
resolver();
await stopPromise;
expect(resolved).toBe(true);
});
it("stop is idempotent when never started", async () => {
const h = makeHarness();
const s = makeScheduler(h);
await expect(s.stop()).resolves.toBeUndefined();
});
it("createJobScheduler returns an instance", () => {
expect(createJobScheduler()).toBeInstanceOf(JobScheduler);
});
});

View File

@@ -0,0 +1,281 @@
import { type CronExpression, parseCron } from "@suaveplan/cron";
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import type {
ExecutionTimers,
JobHandler,
} from "../execution/job-execution.js";
import { recordQueueDepth } from "../otel/job-scheduler-otel.js";
import { JobQueue } from "../queue/job-queue.js";
import {
applyCatchupAndReenqueue,
type CronEnv,
reenqueueCron,
} from "./cron-rescheduler.js";
import { dispatch, type ScheduledJob } from "./job-dispatcher.js";
import type {
JobOptions,
JobSchedulerEventMap,
JobSchedulerOptions,
JobStatus,
QueueJobOptions,
} from "./job-scheduler.types.js";
import { snapshotJob } from "./job-snapshot.js";
const DEFAULT_CONCURRENCY = 5;
const DEFAULT_TICK_INTERVAL_MS = 100;
const DEFAULT_MAX_MISSED_RUNS = 1;
const DEFAULT_TZ = "UTC";
function silentLogger(): LoggerLike {
return {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
}
function defaultTimers(): ExecutionTimers {
return {
setTimer: (cb, ms) =>
globalThis.setTimeout(cb, ms) as unknown as number,
clearTimer: (h) => globalThis.clearTimeout(h as number),
};
}
function noopBus(): EventBusLike<JobSchedulerEventMap> {
return { on: () => () => undefined, emit: () => undefined };
}
/** In-process scheduler driving cron + queued jobs from a single tick loop. */
export class JobScheduler {
private readonly concurrency: number;
private readonly clock: () => number;
private readonly logger: LoggerLike;
private readonly catchupMissed: boolean;
private readonly maxMissedRuns: number;
private readonly tickIntervalMs: number;
private readonly timers: ExecutionTimers;
private readonly events: EventBusLike<JobSchedulerEventMap>;
private readonly generateId: () => string;
private readonly store = new Map<string, ScheduledJob>();
private readonly expressions = new Map<string, CronExpression>();
private readonly queueIndex = new JobQueue();
private readonly runningSet = new Set<string>();
private readonly inFlight = new Set<Promise<void>>();
private readonly cronEnv: CronEnv;
private tickerHandle: number | undefined;
private running = false;
private globalPaused = false;
public constructor(options: JobSchedulerOptions = {}) {
this.concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
this.clock = options.clock ?? ((): number => Date.now());
this.logger = options.logger ?? silentLogger();
this.catchupMissed = options.catchupMissed ?? false;
this.maxMissedRuns = options.maxMissedRuns ?? DEFAULT_MAX_MISSED_RUNS;
this.tickIntervalMs =
options.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS;
this.timers = options.timers ?? defaultTimers();
this.events = options.events ?? noopBus();
this.generateId =
options.generateId ??
((): string => globalThis.crypto.randomUUID());
this.cronEnv = {
clock: this.clock,
events: this.events,
queue: this.queueIndex,
expressionOf: (id) => this.expressions.get(id),
};
}
public schedule(
cronExpr: string,
handler: JobHandler,
options: JobOptions = {}
): string {
const expression = parseCron(cronExpr);
const id = options.id ?? this.generateId();
const tz = options.tz ?? DEFAULT_TZ;
const firstRun = expression.nextRun(this.clock(), tz);
const job: ScheduledJob = {
id,
...(options.name !== undefined ? { name: options.name } : {}),
kind: "cron",
cronExpr,
tz,
handler,
options,
status: "pending",
runs: 0,
failures: 0,
nextRun: firstRun,
};
this.store.set(id, job);
this.expressions.set(id, expression);
this.queueIndex.enqueue({ jobId: id, runAt: firstRun, cronExpr });
return id;
}
public queue(handler: JobHandler, options: QueueJobOptions = {}): string {
const id = options.id ?? this.generateId();
const runAt = options.runAt ?? this.clock() + (options.delayMs ?? 0);
const job: ScheduledJob = {
id,
...(options.name !== undefined ? { name: options.name } : {}),
kind: "queued",
handler,
options,
status: "pending",
runs: 0,
failures: 0,
nextRun: runAt,
};
this.store.set(id, job);
this.queueIndex.enqueue({ jobId: id, runAt });
return id;
}
public cancel(jobId: string): boolean {
const job = this.store.get(jobId);
if (job === undefined) return false;
job.status = "cancelled";
this.queueIndex.remove(jobId);
if (job.currentAbort !== undefined) {
job.currentAbort.abort(new Error("cancelled"));
}
this.events.emit("job:cancelled", { jobId });
return true;
}
public pause(jobId?: string): void {
if (jobId === undefined) {
this.globalPaused = true;
this.events.emit("job:paused", { jobId: null });
return;
}
const job = this.store.get(jobId);
if (job === undefined) {
throw new Error(`pause: unknown jobId ${jobId}`);
}
if (job.status === "pending" || job.status === "running") {
job.status = "paused";
}
this.events.emit("job:paused", { jobId });
}
public resume(jobId?: string): void {
if (jobId === undefined) {
this.globalPaused = false;
this.events.emit("job:resumed", { jobId: null });
return;
}
const job = this.store.get(jobId);
if (job === undefined) {
throw new Error(`resume: unknown jobId ${jobId}`);
}
if (job.status === "paused") {
job.status = "pending";
this.scheduleAfterResume(job);
}
this.events.emit("job:resumed", { jobId });
}
public jobs(): readonly JobStatus[] {
const out: JobStatus[] = [];
for (const job of this.store.values()) out.push(snapshotJob(job));
return Object.freeze(out);
}
public start(): void {
if (this.running) return;
this.running = true;
this.scheduleTick();
}
public async stop(): Promise<void> {
if (!this.running) return;
this.running = false;
// `start()` always assigns `tickerHandle` before returning, so it
// is defined whenever `running === true` (the early-return above
// guarantees we're in that state here). The non-null assertion is
// safe by construction.
this.timers.clearTimer(this.tickerHandle as number);
this.tickerHandle = undefined;
for (const job of this.store.values()) {
if (job.currentAbort !== undefined) {
job.currentAbort.abort(new Error("scheduler stopped"));
}
}
await Promise.allSettled(Array.from(this.inFlight));
}
private scheduleTick(): void {
this.tickerHandle = this.timers.setTimer(() => {
this.tick();
if (this.running) this.scheduleTick();
}, this.tickIntervalMs);
}
private tick(): void {
recordQueueDepth(this.queueIndex.size());
if (this.globalPaused) return;
while (this.runningSet.size < this.concurrency) {
const head = this.queueIndex.peek();
if (head === undefined) break;
if (head.runAt > this.clock()) break;
// `peek` returned a non-undefined head, so dequeue cannot
// return undefined here.
const popped = this.queueIndex.dequeue() as NonNullable<
ReturnType<JobQueue["dequeue"]>
>;
const job = this.store.get(popped.jobId);
if (job === undefined) continue;
if (job.status !== "pending") continue;
dispatch(job, popped.runAt, {
clock: this.clock,
logger: this.logger,
events: this.events,
timers: this.timers,
inFlight: this.inFlight,
runningSet: this.runningSet,
afterRun: (j) => this.afterRun(j),
});
}
}
private afterRun(job: ScheduledJob): void {
if (job.status === "cancelled") return;
if (job.kind === "queued") {
this.store.delete(job.id);
return;
}
job.status = "pending";
reenqueueCron(job, this.cronEnv, job.tz ?? DEFAULT_TZ);
}
private scheduleAfterResume(job: ScheduledJob): void {
if (job.kind === "cron") {
applyCatchupAndReenqueue(
job,
this.cronEnv,
job.tz ?? DEFAULT_TZ,
this.catchupMissed,
this.maxMissedRuns
);
return;
}
// Queued jobs always have `nextRun` set at enqueue time.
this.queueIndex.enqueue({
jobId: job.id,
runAt: Math.max(job.nextRun as number, this.clock()),
});
}
}
/** Convenience factory matching the repo-wide primitive convention. */
export function createJobScheduler(
options?: JobSchedulerOptions
): JobScheduler {
return new JobScheduler(options);
}

View File

@@ -0,0 +1,65 @@
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import type {
ExecutionTimers,
JobHandler,
} from "../execution/job-execution.js";
/** Per-job options accepted by both `schedule` and `queue`. */
export interface JobOptions {
readonly id?: string;
readonly name?: string;
readonly retries?: number;
readonly retryDelayMs?: number;
readonly timeout?: number;
readonly tz?: string;
}
/** Options specific to one-shot `queue` jobs. */
export interface QueueJobOptions extends JobOptions {
readonly delayMs?: number;
readonly runAt?: number;
}
/** Constructor options for `JobScheduler`. */
export interface JobSchedulerOptions {
readonly concurrency?: number;
readonly clock?: () => number;
readonly logger?: LoggerLike;
readonly catchupMissed?: boolean;
readonly maxMissedRuns?: number;
readonly tickIntervalMs?: number;
readonly timers?: ExecutionTimers;
readonly events?: EventBusLike<JobSchedulerEventMap>;
readonly generateId?: () => string;
}
/** Closed event-map emitted on the scheduler's bus. */
export interface JobSchedulerEventMap extends Record<string, unknown> {
"job:start": { jobId: string; attempt: number; scheduledAt: number };
"job:complete": { jobId: string; attempt: number; durationMs: number };
"job:fail": {
jobId: string;
attempt: number;
durationMs: number;
error: unknown;
};
"job:scheduling_exhausted": { jobId: string };
"job:paused": { jobId: string | null };
"job:resumed": { jobId: string | null };
"job:cancelled": { jobId: string };
}
/** Snapshot returned by `JobScheduler.jobs()`. */
export interface JobStatus {
readonly id: string;
readonly name?: string;
readonly cronExpr?: string;
readonly status: "pending" | "running" | "paused" | "cancelled";
readonly lastRun?: number;
readonly nextRun?: number;
readonly runs: number;
readonly failures: number;
}
/** Re-export the handler type so consumers have a single import. */
export type { JobHandler };

View File

@@ -0,0 +1,45 @@
# job-snapshot
## Purpose
Projects a mutable `ScheduledJob` record (the scheduler's internal data structure) to an immutable `JobStatus` snapshot for public consumption via `JobScheduler.jobs()`. Extracted into its own module so the scheduler core stays under the 300-line hard limit and so the shape transformation has a single obvious home.
## Features
- Pure function — no side effects, no allocation beyond the returned object.
- Honours `exactOptionalPropertyTypes` by conditionally spreading optional fields.
- Output object is safe to freeze at the caller.
## Usage: Basic Example
```ts
import { snapshotJob } from "@suaveplan/job-scheduler";
const status = snapshotJob(internalJobRecord);
console.log(status.id, status.status, status.runs);
```
## Usage: Advanced Example
```ts
import { snapshotJob } from "@suaveplan/job-scheduler";
function adminDashboard(jobs: Iterable<ScheduledJob>): JobStatus[] {
const out: JobStatus[] = [];
for (const j of jobs) {
const snap = snapshotJob(j);
if (snap.failures > 0) out.push(snap);
}
return out;
}
```
## API Reference
- `snapshotJob(job: ScheduledJob): JobStatus` — returns a plain object with every public field of `JobStatus`.
## Implementation Notes
The conditional spread (`...(value !== undefined ? { key: value } : {})`) is required because the repo TSConfig sets `exactOptionalPropertyTypes: true` — assigning `undefined` to an optional field is a compile-time error. Inline-spreading a guard object is the smallest pattern that preserves the omit-vs-undefined distinction. The function deliberately does not freeze its return value; the caller decides whether to freeze the whole array or each individual entry.
Keeping this projection in its own module rather than inlining inside the scheduler class has two side benefits: first, the scheduler core stays under the 300-line file budget that the repo enforces; second, unit-testing the projection in isolation is trivial because every input field can be exercised without spinning up a full scheduler, a queue, and an event bus. The trade-off — a single extra import in `job-scheduler.ts` — is negligible, especially because the function is small enough that the bundler inlines it during tree-shaking anyway. When new optional fields are added to `JobStatus`, the only place that needs updating is this file plus the `JobStatus` type definition, which is exactly where the change should live.

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ScheduledJob } from "./job-dispatcher.js";
import { snapshotJob } from "./job-snapshot.js";
function base(): ScheduledJob {
return {
id: "abc",
kind: "queued",
handler: async () => undefined,
options: {},
status: "pending",
runs: 0,
failures: 0,
};
}
describe("snapshotJob", () => {
it("emits only required fields when optionals are undefined", () => {
const snap = snapshotJob(base());
expect(snap).toEqual({
id: "abc",
status: "pending",
runs: 0,
failures: 0,
});
});
it("includes optional fields when set", () => {
const job: ScheduledJob = {
...base(),
name: "nightly",
cronExpr: "0 0 * * *",
lastRun: 100,
nextRun: 200,
runs: 5,
failures: 1,
status: "running",
};
const snap = snapshotJob(job);
expect(snap.name).toBe("nightly");
expect(snap.cronExpr).toBe("0 0 * * *");
expect(snap.lastRun).toBe(100);
expect(snap.nextRun).toBe(200);
expect(snap.runs).toBe(5);
expect(snap.failures).toBe(1);
expect(snap.status).toBe("running");
});
});

View File

@@ -0,0 +1,20 @@
import type { ScheduledJob } from "./job-dispatcher.js";
import type { JobStatus } from "./job-scheduler.types.js";
/**
* Project a `ScheduledJob` (the scheduler's mutable internal record) to
* a frozen `JobStatus` snapshot. Optional fields are conditionally
* included so the shape matches `exactOptionalPropertyTypes`.
*/
export function snapshotJob(job: ScheduledJob): JobStatus {
return {
id: job.id,
...(job.name !== undefined ? { name: job.name } : {}),
...(job.cronExpr !== undefined ? { cronExpr: job.cronExpr } : {}),
status: job.status,
...(job.lastRun !== undefined ? { lastRun: job.lastRun } : {}),
...(job.nextRun !== undefined ? { nextRun: job.nextRun } : {}),
runs: job.runs,
failures: job.failures,
};
}

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import type { ExecutionTimers } from "../src/execution/job-execution.js";
import { createJobScheduler } from "../src/scheduler/job-scheduler.js";
import type { JobSchedulerEventMap } from "../src/scheduler/job-scheduler.types.js";
interface TimerState {
now: number;
nextId: number;
pending: Map<number, { fireAt: number; cb: () => void }>;
}
function harness(start: number): {
state: TimerState;
timers: ExecutionTimers;
advance: (delta: number) => Promise<void>;
bus: EventBusLike<JobSchedulerEventMap>;
logger: LoggerLike;
} {
const state: TimerState = { now: start, nextId: 1, pending: new Map() };
const timers: ExecutionTimers = {
setTimer: (cb, ms) => {
const id = state.nextId++;
state.pending.set(id, { fireAt: state.now + ms, cb });
return id;
},
clearTimer: (h) => {
state.pending.delete(h as number);
},
};
const advance = async (delta: number): Promise<void> => {
const target = state.now + delta;
for (;;) {
let id: number | undefined;
let at = Number.POSITIVE_INFINITY;
for (const [k, v] of state.pending) {
if (v.fireAt <= target && v.fireAt < at) {
id = k;
at = v.fireAt;
}
}
if (id === undefined) break;
const entry = state.pending.get(id);
if (entry === undefined) break;
state.pending.delete(id);
state.now = entry.fireAt;
entry.cb();
await Promise.resolve();
await Promise.resolve();
}
state.now = target;
};
const bus: EventBusLike<JobSchedulerEventMap> = {
on: () => () => undefined,
emit: () => undefined,
};
const logger: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
return { state, timers, advance, bus, logger };
}
describe("integration: three cron jobs over 10 simulated minutes", () => {
it("fires every-minute, every-2-minutes, every-5-minutes the expected counts", {
timeout: 30_000,
}, async () => {
const startAt = Date.UTC(2026, 4, 12, 9, 0, 0);
const h = harness(startAt);
const s = createJobScheduler({
clock: () => h.state.now,
timers: h.timers,
events: h.bus,
logger: h.logger,
tickIntervalMs: 100,
});
const counts = { every1: 0, every2: 0, every5: 0 };
s.schedule("* * * * *", async () => {
counts.every1 += 1;
});
s.schedule("*/2 * * * *", async () => {
counts.every2 += 1;
});
s.schedule("*/5 * * * *", async () => {
counts.every5 += 1;
});
s.start();
await h.advance(10 * 60_000 + 500);
await s.stop();
// Over a 10-minute window starting at HH:00:00.5, every-minute
// fires 10 times (HH:01 through HH:10), every-2-minutes fires 5
// times (02, 04, 06, 08, 10), every-5-minutes fires 2 times
// (05, 10).
expect(counts.every1).toBe(10);
expect(counts.every2).toBe(5);
expect(counts.every5).toBe(2);
});
});
describe("integration: stop() drains a mid-run job", () => {
it("waits for a slow handler to finish before resolving", {
timeout: 30_000,
}, async () => {
const h = harness(Date.UTC(2026, 4, 12, 9, 0, 0));
const s = createJobScheduler({
clock: () => h.state.now,
timers: h.timers,
events: h.bus,
logger: h.logger,
tickIntervalMs: 50,
});
let resolved = false;
let resolver: () => void = () => undefined;
s.queue(
() =>
new Promise<void>((res) => {
resolver = () => {
resolved = true;
res();
};
}),
{ delayMs: 0 }
);
s.start();
await h.advance(100);
const stopP = s.stop();
resolver();
await stopP;
expect(resolved).toBe(true);
});
});

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { JobQueue } from "../src/queue/job-queue.js";
describe("property: JobQueue monotonic dequeue", () => {
it("always returns entries in non-decreasing runAt order", {
timeout: 30_000,
}, () => {
fc.assert(
fc.property(
fc.array(
fc.record({
jobId: fc.string({ minLength: 1, maxLength: 8 }),
runAt: fc.integer({ min: 0, max: 1_000_000 }),
}),
{ minLength: 0, maxLength: 200 }
),
(entries) => {
const q = new JobQueue();
for (let i = 0; i < entries.length; i++) {
const e = entries[i] as {
jobId: string;
runAt: number;
};
q.enqueue({
jobId: `${e.jobId}-${i}`,
runAt: e.runAt,
});
}
let prev = Number.NEGATIVE_INFINITY;
while (q.size() > 0) {
const out = q.dequeue();
if (out === undefined) break;
expect(out.runAt).toBeGreaterThanOrEqual(prev);
prev = out.runAt;
}
}
),
{ numRuns: 100 }
);
});
});
describe("property: enqueue+remove leaves consistent heap", () => {
it("after removing every inserted id, queue is empty", {
timeout: 30_000,
}, () => {
fc.assert(
fc.property(
fc.array(
fc.record({
jobId: fc.uuid(),
runAt: fc.integer({ min: 0, max: 1_000_000 }),
}),
{ minLength: 0, maxLength: 100 }
),
(entries) => {
const q = new JobQueue();
const ids = new Set<string>();
for (const e of entries) {
if (ids.has(e.jobId)) continue;
ids.add(e.jobId);
q.enqueue({ jobId: e.jobId, runAt: e.runAt });
}
for (const id of ids) {
expect(q.remove(id)).toBe(true);
}
expect(q.size()).toBe(0);
expect(q.peek()).toBeUndefined();
}
),
{ numRuns: 100 }
);
});
});
describe("property: enqueue followed by N dequeues equals sorted input", () => {
it("produces a sorted output prefix", { timeout: 30_000 }, () => {
fc.assert(
fc.property(
fc.array(fc.integer({ min: 0, max: 1_000_000 }), {
minLength: 1,
maxLength: 50,
}),
(runAts) => {
const q = new JobQueue();
runAts.forEach((runAt, i) => {
q.enqueue({ jobId: `j-${i}`, runAt });
});
const got: number[] = [];
while (q.size() > 0) {
const e = q.dequeue();
if (e === undefined) break;
got.push(e.runAt);
}
const expected = [...runAts].sort((a, b) => a - b);
expect(got).toEqual(expected);
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": { "rootDir": "./src", "outDir": "./dist", "lib": ["ES2022"], "types": ["node"] },
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,28 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/job-scheduler",
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
build: {
outDir: "./dist", emptyOutDir: true, reportCompressedSize: true, sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
external: [
"@suaveplan/cron",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/logger",
"@suaveplan/retry",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,17 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false, environment: "node", pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul", reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.test.ts", "src/**/*.schemas.ts", "src/**/*.types.ts", "src/**/index.ts", "tests/**"],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 5000,
},
});