first commit
This commit is contained in:
162
docs/foundation/flow/job-scheduler/README.md
Normal file
162
docs/foundation/flow/job-scheduler/README.md
Normal 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.
|
||||
@@ -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.
|
||||
56
docs/foundation/flow/job-scheduler/src/events/events.md
Normal file
56
docs/foundation/flow/job-scheduler/src/events/events.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
72
docs/foundation/flow/job-scheduler/src/queue/job-queue.md
Normal file
72
docs/foundation/flow/job-scheduler/src/queue/job-queue.md
Normal 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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user