first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 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,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,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,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,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,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,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,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,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,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,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.