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,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.