first commit
This commit is contained in:
@@ -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