Initial Commit
This commit is contained in:
162
packages/foundation/flow/job-scheduler/README.md
Normal file
162
packages/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.
|
||||
127
packages/foundation/flow/job-scheduler/package.json
Normal file
127
packages/foundation/flow/job-scheduler/package.json
Normal file
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"name": "@suaveplan/job-scheduler",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal in-process job scheduler with cron + queued runs, retries, timeouts, concurrency control, and OTel observability",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./errors/job-scheduler-error": {
|
||||
"import": {
|
||||
"types": "./dist/errors/job-scheduler-error.d.ts",
|
||||
"default": "./dist/errors/job-scheduler-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/job-scheduler-error.d.ts",
|
||||
"default": "./dist/errors/job-scheduler-error.js"
|
||||
}
|
||||
},
|
||||
"./queue/job-queue": {
|
||||
"import": {
|
||||
"types": "./dist/queue/job-queue.d.ts",
|
||||
"default": "./dist/queue/job-queue.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/queue/job-queue.d.ts",
|
||||
"default": "./dist/queue/job-queue.js"
|
||||
}
|
||||
},
|
||||
"./scheduler/job-scheduler": {
|
||||
"import": {
|
||||
"types": "./dist/scheduler/job-scheduler.d.ts",
|
||||
"default": "./dist/scheduler/job-scheduler.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/scheduler/job-scheduler.d.ts",
|
||||
"default": "./dist/scheduler/job-scheduler.js"
|
||||
}
|
||||
},
|
||||
"./execution/job-execution": {
|
||||
"import": {
|
||||
"types": "./dist/execution/job-execution.d.ts",
|
||||
"default": "./dist/execution/job-execution.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/execution/job-execution.d.ts",
|
||||
"default": "./dist/execution/job-execution.js"
|
||||
}
|
||||
},
|
||||
"./registry/job-registry": {
|
||||
"import": {
|
||||
"types": "./dist/registry/job-registry.d.ts",
|
||||
"default": "./dist/registry/job-registry.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/registry/job-registry.d.ts",
|
||||
"default": "./dist/registry/job-registry.js"
|
||||
}
|
||||
},
|
||||
"./otel/job-scheduler-otel": {
|
||||
"import": {
|
||||
"types": "./dist/otel/job-scheduler-otel.d.ts",
|
||||
"default": "./dist/otel/job-scheduler-otel.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/otel/job-scheduler-otel.d.ts",
|
||||
"default": "./dist/otel/job-scheduler-otel.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/cron": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/logger": "workspace:^",
|
||||
"@suaveplan/retry": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { JobSchedulerErrorCode } from "./job-scheduler-error.js";
|
||||
export {
|
||||
JOB_SCHEDULER_CODES,
|
||||
JobSchedulerError,
|
||||
registerJobSchedulerErrorCodes,
|
||||
} from "./job-scheduler-error.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
JOB_SCHEDULER_CODES,
|
||||
JobSchedulerError,
|
||||
registerJobSchedulerErrorCodes,
|
||||
} from "./job-scheduler-error.js";
|
||||
|
||||
describe("JOB_SCHEDULER_CODES", () => {
|
||||
it("freezes exactly three codes", () => {
|
||||
expect(Object.values(JOB_SCHEDULER_CODES).sort()).toEqual([
|
||||
"JOB_SCHEDULER.EXECUTION_FAILED",
|
||||
"JOB_SCHEDULER.JOB_NOT_FOUND",
|
||||
"JOB_SCHEDULER.TIMEOUT",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JobSchedulerError", () => {
|
||||
it("constructs a typed error with a known code", () => {
|
||||
const err = new JobSchedulerError(
|
||||
JOB_SCHEDULER_CODES.JOB_NOT_FOUND,
|
||||
"msg.key",
|
||||
{ context: { jobId: "x" } }
|
||||
);
|
||||
expect(err).toBeInstanceOf(JobSchedulerError);
|
||||
expect(err.code).toBe("JOB_SCHEDULER.JOB_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("rejects an unregistered code at the type-erased boundary", () => {
|
||||
expect(
|
||||
() =>
|
||||
new JobSchedulerError(
|
||||
"JOB_SCHEDULER.BOGUS" as unknown as typeof JOB_SCHEDULER_CODES.TIMEOUT,
|
||||
"msg"
|
||||
)
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("carries all three legitimate codes", () => {
|
||||
for (const code of Object.values(JOB_SCHEDULER_CODES)) {
|
||||
const err = new JobSchedulerError(code, "msg");
|
||||
expect(err.code).toBe(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerJobSchedulerErrorCodes", () => {
|
||||
it("is idempotent and returns the registry size", () => {
|
||||
expect(registerJobSchedulerErrorCodes()).toBe(3);
|
||||
expect(registerJobSchedulerErrorCodes()).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen registry of every `JOB_SCHEDULER.*` error code emitted by
|
||||
* `@suaveplan/job-scheduler`. Built via `defineCodes("JOB_SCHEDULER", ...)`
|
||||
* and is part of the package's public contract — consumers branch on
|
||||
* `err.code` rather than parsing `err.message`.
|
||||
*
|
||||
* Cron-parse failures are NOT modelled here: they propagate untouched as
|
||||
* `CRON.*` codes from `@suaveplan/cron`.
|
||||
*/
|
||||
export const JOB_SCHEDULER_CODES = defineCodes("JOB_SCHEDULER", [
|
||||
"JOB_NOT_FOUND",
|
||||
"EXECUTION_FAILED",
|
||||
"TIMEOUT",
|
||||
] as const);
|
||||
|
||||
/** Union of every fully-qualified `JOB_SCHEDULER.*` error code. */
|
||||
export type JobSchedulerErrorCode =
|
||||
(typeof JOB_SCHEDULER_CODES)[keyof typeof JOB_SCHEDULER_CODES];
|
||||
|
||||
const VALID_CODES = new Set<string>(Object.values(JOB_SCHEDULER_CODES));
|
||||
|
||||
/**
|
||||
* Typed error class for the job-scheduler family. The public constructor
|
||||
* performs a runtime guard so that an unregistered code passed at the
|
||||
* type-erased boundary (e.g. JSON-deserialised codes from a remote
|
||||
* scheduler proxy) hard-fails rather than silently constructing a
|
||||
* malformed error.
|
||||
*/
|
||||
export class JobSchedulerError extends SuaveplanError<JobSchedulerErrorCode> {
|
||||
public constructor(
|
||||
code: JobSchedulerErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
if (!VALID_CODES.has(code)) {
|
||||
throw new TypeError(
|
||||
`Unknown JobSchedulerError code: ${String(code)}`
|
||||
);
|
||||
}
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eager registration sentinel. Reading the registry size pins the
|
||||
* `defineCodes` call so the lookup `Set` is materialised before any
|
||||
* `JobSchedulerError` is thrown. Returns the registered code count so
|
||||
* callers doing tree-shaking inspection have a side-effect-free probe.
|
||||
*/
|
||||
export function registerJobSchedulerErrorCodes(): number {
|
||||
return VALID_CODES.size;
|
||||
}
|
||||
|
||||
registerJobSchedulerErrorCodes();
|
||||
56
packages/foundation/flow/job-scheduler/src/events/events.md
Normal file
56
packages/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,32 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { JOB_SCHEDULER_EVENTS } from "./events.js";
|
||||
|
||||
describe("JOB_SCHEDULER_EVENTS", () => {
|
||||
it("exposes every canonical topic", () => {
|
||||
expect(Object.keys(JOB_SCHEDULER_EVENTS).sort()).toEqual([
|
||||
"cancelled",
|
||||
"complete",
|
||||
"fail",
|
||||
"paused",
|
||||
"resumed",
|
||||
"schedulingExhausted",
|
||||
"start",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps to the snake-cased wire names", () => {
|
||||
expect(JOB_SCHEDULER_EVENTS.start).toBe("job:start");
|
||||
expect(JOB_SCHEDULER_EVENTS.complete).toBe("job:complete");
|
||||
expect(JOB_SCHEDULER_EVENTS.fail).toBe("job:fail");
|
||||
expect(JOB_SCHEDULER_EVENTS.schedulingExhausted).toBe(
|
||||
"job:scheduling_exhausted"
|
||||
);
|
||||
expect(JOB_SCHEDULER_EVENTS.paused).toBe("job:paused");
|
||||
expect(JOB_SCHEDULER_EVENTS.resumed).toBe("job:resumed");
|
||||
expect(JOB_SCHEDULER_EVENTS.cancelled).toBe("job:cancelled");
|
||||
});
|
||||
|
||||
it("is frozen", () => {
|
||||
expect(Object.isFrozen(JOB_SCHEDULER_EVENTS)).toBe(true);
|
||||
});
|
||||
});
|
||||
18
packages/foundation/flow/job-scheduler/src/events/events.ts
Normal file
18
packages/foundation/flow/job-scheduler/src/events/events.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Frozen registry of every bus topic emitted by the scheduler. Consumers
|
||||
* use these constants instead of literal strings so a typo on the
|
||||
* subscriber side is a compile error rather than a silent miss.
|
||||
*/
|
||||
export const JOB_SCHEDULER_EVENTS = Object.freeze({
|
||||
start: "job:start",
|
||||
complete: "job:complete",
|
||||
fail: "job:fail",
|
||||
schedulingExhausted: "job:scheduling_exhausted",
|
||||
paused: "job:paused",
|
||||
resumed: "job:resumed",
|
||||
cancelled: "job:cancelled",
|
||||
} as const);
|
||||
|
||||
/** Union of every emitted topic name. */
|
||||
export type JobSchedulerEventName =
|
||||
(typeof JOB_SCHEDULER_EVENTS)[keyof typeof JOB_SCHEDULER_EVENTS];
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { JobSchedulerEventName } from "./events.js";
|
||||
export { JOB_SCHEDULER_EVENTS } from "./events.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
ExecuteJobInput,
|
||||
ExecutionTimers,
|
||||
JobContext,
|
||||
JobHandler,
|
||||
} from "./job-execution.js";
|
||||
export { executeJob } from "./job-execution.js";
|
||||
@@ -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,339 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
import {
|
||||
JOB_SCHEDULER_CODES,
|
||||
JobSchedulerError,
|
||||
} from "../errors/job-scheduler-error.js";
|
||||
import {
|
||||
type ExecutionTimers,
|
||||
executeJob,
|
||||
type JobContext,
|
||||
} from "./job-execution.js";
|
||||
|
||||
interface CapturedEvent {
|
||||
type: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
function makeBus(): {
|
||||
bus: EventBusLike<Record<string, unknown>>;
|
||||
events: CapturedEvent[];
|
||||
} {
|
||||
const events: CapturedEvent[] = [];
|
||||
const bus: EventBusLike<Record<string, unknown>> = {
|
||||
on: () => () => undefined,
|
||||
emit: (type, payload) => {
|
||||
events.push({ type: type as string, payload });
|
||||
},
|
||||
};
|
||||
return { bus, events };
|
||||
}
|
||||
|
||||
function makeLogger(): LoggerLike {
|
||||
return {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeTimerState {
|
||||
now: number;
|
||||
nextId: number;
|
||||
pending: Map<number, { fireAt: number; cb: () => void }>;
|
||||
}
|
||||
|
||||
function makeFakeTimers(state: FakeTimerState): {
|
||||
timers: ExecutionTimers;
|
||||
advance: (delta: number) => Promise<void>;
|
||||
pending: () => number;
|
||||
} {
|
||||
const timers: ExecutionTimers = {
|
||||
setTimer: (cb, ms) => {
|
||||
const id = state.nextId++;
|
||||
state.pending.set(id, { fireAt: state.now + ms, cb });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (h) => {
|
||||
state.pending.delete(h as number);
|
||||
},
|
||||
};
|
||||
const advance = async (delta: number): Promise<void> => {
|
||||
const target = state.now + delta;
|
||||
for (;;) {
|
||||
let nextId: number | undefined;
|
||||
let nextAt = Number.POSITIVE_INFINITY;
|
||||
for (const [id, t] of state.pending) {
|
||||
if (t.fireAt <= target && t.fireAt < nextAt) {
|
||||
nextId = id;
|
||||
nextAt = t.fireAt;
|
||||
}
|
||||
}
|
||||
if (nextId === undefined) break;
|
||||
const entry = state.pending.get(nextId);
|
||||
if (entry === undefined) break;
|
||||
state.pending.delete(nextId);
|
||||
state.now = entry.fireAt;
|
||||
entry.cb();
|
||||
await Promise.resolve();
|
||||
}
|
||||
state.now = target;
|
||||
};
|
||||
return { timers, advance, pending: () => state.pending.size };
|
||||
}
|
||||
|
||||
describe("executeJob", () => {
|
||||
it("emits start + complete on first-try success", async () => {
|
||||
const state: FakeTimerState = {
|
||||
now: 0,
|
||||
nextId: 1,
|
||||
pending: new Map(),
|
||||
};
|
||||
const { timers } = makeFakeTimers(state);
|
||||
const { bus, events } = makeBus();
|
||||
const handler = async (ctx: JobContext): Promise<void> => {
|
||||
expect(ctx.attempt).toBe(1);
|
||||
};
|
||||
await executeJob({
|
||||
jobId: "j-1",
|
||||
kind: "queued",
|
||||
handler,
|
||||
scheduledAt: 0,
|
||||
retries: 0,
|
||||
retryDelayMs: 0,
|
||||
cancelSignal: new AbortController().signal,
|
||||
clock: () => state.now,
|
||||
logger: makeLogger(),
|
||||
events: bus,
|
||||
timers,
|
||||
});
|
||||
expect(events.map((e) => e.type)).toEqual([
|
||||
"job:start",
|
||||
"job:complete",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retries then succeeds, emitting one start per attempt", async () => {
|
||||
const state: FakeTimerState = {
|
||||
now: 0,
|
||||
nextId: 1,
|
||||
pending: new Map(),
|
||||
};
|
||||
const { timers, advance } = makeFakeTimers(state);
|
||||
const { bus, events } = makeBus();
|
||||
let attempts = 0;
|
||||
const handler = async (ctx: JobContext): Promise<void> => {
|
||||
attempts = ctx.attempt;
|
||||
if (ctx.attempt < 3) throw new Error("transient");
|
||||
};
|
||||
const promise = executeJob({
|
||||
jobId: "j-2",
|
||||
kind: "queued",
|
||||
handler,
|
||||
scheduledAt: 0,
|
||||
retries: 5,
|
||||
retryDelayMs: 100,
|
||||
cancelSignal: new AbortController().signal,
|
||||
clock: () => state.now,
|
||||
logger: makeLogger(),
|
||||
events: bus,
|
||||
timers,
|
||||
});
|
||||
// Drain inter-attempt sleeps.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await advance(100);
|
||||
}
|
||||
await promise;
|
||||
expect(attempts).toBe(3);
|
||||
const starts = events.filter((e) => e.type === "job:start").length;
|
||||
const completes = events.filter(
|
||||
(e) => e.type === "job:complete"
|
||||
).length;
|
||||
const fails = events.filter((e) => e.type === "job:fail").length;
|
||||
expect(starts).toBe(3);
|
||||
expect(completes).toBe(1);
|
||||
expect(fails).toBe(2);
|
||||
});
|
||||
|
||||
it("exhausts retries and throws JOB_SCHEDULER.EXECUTION_FAILED", async () => {
|
||||
const state: FakeTimerState = {
|
||||
now: 0,
|
||||
nextId: 1,
|
||||
pending: new Map(),
|
||||
};
|
||||
const { timers, advance } = makeFakeTimers(state);
|
||||
const { bus } = makeBus();
|
||||
const handler = async (): Promise<void> => {
|
||||
throw new Error("always fails");
|
||||
};
|
||||
const promise = executeJob({
|
||||
jobId: "j-3",
|
||||
kind: "queued",
|
||||
handler,
|
||||
scheduledAt: 0,
|
||||
retries: 2,
|
||||
retryDelayMs: 50,
|
||||
cancelSignal: new AbortController().signal,
|
||||
clock: () => state.now,
|
||||
logger: makeLogger(),
|
||||
events: bus,
|
||||
timers,
|
||||
}).catch((e: unknown) => e);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await advance(50);
|
||||
}
|
||||
const err = await promise;
|
||||
expect(err).toBeInstanceOf(JobSchedulerError);
|
||||
expect((err as JobSchedulerError).code).toBe(
|
||||
JOB_SCHEDULER_CODES.EXECUTION_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("times out a handler that never resolves", async () => {
|
||||
const state: FakeTimerState = {
|
||||
now: 0,
|
||||
nextId: 1,
|
||||
pending: new Map(),
|
||||
};
|
||||
const { timers, advance } = makeFakeTimers(state);
|
||||
const { bus } = makeBus();
|
||||
const handler = (ctx: JobContext): Promise<void> =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
ctx.signal.addEventListener("abort", () => {
|
||||
reject(ctx.signal.reason);
|
||||
});
|
||||
});
|
||||
const promise = executeJob({
|
||||
jobId: "j-4",
|
||||
kind: "queued",
|
||||
handler,
|
||||
scheduledAt: 0,
|
||||
retries: 0,
|
||||
retryDelayMs: 0,
|
||||
timeoutMs: 100,
|
||||
cancelSignal: new AbortController().signal,
|
||||
clock: () => state.now,
|
||||
logger: makeLogger(),
|
||||
events: bus,
|
||||
timers,
|
||||
}).catch((e: unknown) => e);
|
||||
await Promise.resolve();
|
||||
await advance(100);
|
||||
const err = await promise;
|
||||
expect(err).toBeInstanceOf(JobSchedulerError);
|
||||
expect((err as JobSchedulerError).code).toBe(
|
||||
JOB_SCHEDULER_CODES.TIMEOUT
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates an externally-aborted cancel signal", async () => {
|
||||
const state: FakeTimerState = {
|
||||
now: 0,
|
||||
nextId: 1,
|
||||
pending: new Map(),
|
||||
};
|
||||
const { timers } = makeFakeTimers(state);
|
||||
const { bus } = makeBus();
|
||||
const outer = new AbortController();
|
||||
outer.abort(new Error("cancelled by scheduler"));
|
||||
const handler = (ctx: JobContext): Promise<void> =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
if (ctx.signal.aborted) {
|
||||
reject(ctx.signal.reason);
|
||||
return;
|
||||
}
|
||||
ctx.signal.addEventListener("abort", () => {
|
||||
reject(ctx.signal.reason);
|
||||
});
|
||||
});
|
||||
const promise = executeJob({
|
||||
jobId: "j-5",
|
||||
kind: "queued",
|
||||
handler,
|
||||
scheduledAt: 0,
|
||||
retries: 0,
|
||||
retryDelayMs: 0,
|
||||
cancelSignal: outer.signal,
|
||||
clock: () => state.now,
|
||||
logger: makeLogger(),
|
||||
events: bus,
|
||||
timers,
|
||||
}).catch((e: unknown) => e);
|
||||
const err = await promise;
|
||||
expect(err).toBeInstanceOf(JobSchedulerError);
|
||||
expect((err as JobSchedulerError).code).toBe(
|
||||
JOB_SCHEDULER_CODES.EXECUTION_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes the active per-attempt controller via onAttemptController", async () => {
|
||||
const state: FakeTimerState = {
|
||||
now: 0,
|
||||
nextId: 1,
|
||||
pending: new Map(),
|
||||
};
|
||||
const { timers } = makeFakeTimers(state);
|
||||
const { bus } = makeBus();
|
||||
const controllers: AbortController[] = [];
|
||||
const handler = async (): Promise<void> => undefined;
|
||||
await executeJob({
|
||||
jobId: "j-6",
|
||||
kind: "cron",
|
||||
handler,
|
||||
scheduledAt: 0,
|
||||
retries: 0,
|
||||
retryDelayMs: 0,
|
||||
cancelSignal: new AbortController().signal,
|
||||
clock: () => state.now,
|
||||
logger: makeLogger(),
|
||||
events: bus,
|
||||
timers,
|
||||
onAttemptController: (c) => controllers.push(c),
|
||||
});
|
||||
expect(controllers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("breaks the retry loop when cancel is requested mid-sleep", async () => {
|
||||
const state: FakeTimerState = {
|
||||
now: 0,
|
||||
nextId: 1,
|
||||
pending: new Map(),
|
||||
};
|
||||
const { timers, advance } = makeFakeTimers(state);
|
||||
const { bus } = makeBus();
|
||||
const outer = new AbortController();
|
||||
let attempts = 0;
|
||||
const handler = async (): Promise<void> => {
|
||||
attempts += 1;
|
||||
throw new Error("fail");
|
||||
};
|
||||
const promise = executeJob({
|
||||
jobId: "j-7",
|
||||
kind: "queued",
|
||||
handler,
|
||||
scheduledAt: 0,
|
||||
retries: 5,
|
||||
retryDelayMs: 100,
|
||||
cancelSignal: outer.signal,
|
||||
clock: () => state.now,
|
||||
logger: makeLogger(),
|
||||
events: bus,
|
||||
timers,
|
||||
}).catch((e: unknown) => e);
|
||||
// Let the first attempt fail.
|
||||
await Promise.resolve();
|
||||
// Cancel mid-sleep.
|
||||
outer.abort(new Error("stop"));
|
||||
// The sleep's abort listener resolves; the loop sees the aborted
|
||||
// signal and bails.
|
||||
await advance(0);
|
||||
const err = await promise;
|
||||
expect(err).toBeInstanceOf(JobSchedulerError);
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
createConstantBackoff,
|
||||
type RetryAbortedError,
|
||||
RetryExhaustedError,
|
||||
retry,
|
||||
} from "@suaveplan/retry";
|
||||
import type { EventBusLike, LoggerLike, TimersLike } from "@suaveplan/types";
|
||||
import {
|
||||
JOB_SCHEDULER_CODES,
|
||||
JobSchedulerError,
|
||||
} from "../errors/job-scheduler-error.js";
|
||||
import {
|
||||
incrementJobFailure,
|
||||
type JobKind,
|
||||
type JobOutcome,
|
||||
recordJobRun,
|
||||
} from "../otel/job-scheduler-otel.js";
|
||||
|
||||
/** Context surface every job handler receives. */
|
||||
export interface JobContext {
|
||||
readonly jobId: string;
|
||||
readonly signal: AbortSignal;
|
||||
readonly attempt: number;
|
||||
readonly scheduledAt: number;
|
||||
readonly logger: LoggerLike;
|
||||
}
|
||||
|
||||
/** Handler contract: receives a `JobContext`, returns a Promise. */
|
||||
export type JobHandler = (ctx: JobContext) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Timer pair injected from the scheduler so test runs stay deterministic.
|
||||
* Aliased to `@suaveplan/types`' `TimersLike` so a single `FakeClock`-backed
|
||||
* pair satisfies the job scheduler, the cron scheduler, and the retry loop
|
||||
* all at once.
|
||||
*/
|
||||
export type ExecutionTimers = TimersLike;
|
||||
|
||||
interface BusMap extends Record<string, unknown> {
|
||||
"job:start": { jobId: string; attempt: number; scheduledAt: number };
|
||||
"job:complete": {
|
||||
jobId: string;
|
||||
attempt: number;
|
||||
durationMs: number;
|
||||
};
|
||||
"job:fail": {
|
||||
jobId: string;
|
||||
attempt: number;
|
||||
durationMs: number;
|
||||
error: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/** Inputs to `executeJob`. Caller owns the lifetimes of every field. */
|
||||
export interface ExecuteJobInput {
|
||||
readonly jobId: string;
|
||||
readonly name?: string;
|
||||
readonly kind: JobKind;
|
||||
readonly handler: JobHandler;
|
||||
readonly scheduledAt: number;
|
||||
readonly retries: number;
|
||||
readonly retryDelayMs: number;
|
||||
readonly timeoutMs?: number;
|
||||
readonly cancelSignal: AbortSignal;
|
||||
readonly clock: () => number;
|
||||
readonly logger: LoggerLike;
|
||||
readonly events: EventBusLike<BusMap>;
|
||||
readonly timers: ExecutionTimers;
|
||||
/**
|
||||
* Optional handle to expose the active per-attempt controller so the
|
||||
* scheduler can `cancel()` it. Re-assigned on each retry.
|
||||
*/
|
||||
readonly onAttemptController?: (controller: AbortController) => void;
|
||||
}
|
||||
|
||||
interface AttemptHandle {
|
||||
readonly controller: AbortController;
|
||||
readonly cleanup: () => void;
|
||||
}
|
||||
|
||||
function linkSignals(
|
||||
cancelSignal: AbortSignal,
|
||||
timeoutMs: number | undefined,
|
||||
timers: ExecutionTimers
|
||||
): AttemptHandle {
|
||||
// The retry primitive checks `cancelSignal.aborted` before every
|
||||
// attempt, so this function is never reached when the signal is
|
||||
// already aborted — we only have to wire up the live-abort path.
|
||||
const controller = new AbortController();
|
||||
const onCancel = (): void => {
|
||||
controller.abort(cancelSignal.reason);
|
||||
};
|
||||
cancelSignal.addEventListener("abort", onCancel, { once: true });
|
||||
let timerHandle: number | undefined;
|
||||
if (timeoutMs !== undefined && timeoutMs > 0) {
|
||||
timerHandle = timers.setTimer(() => {
|
||||
controller.abort(
|
||||
new JobSchedulerError(
|
||||
JOB_SCHEDULER_CODES.TIMEOUT,
|
||||
"job_scheduler.timeout",
|
||||
{ context: { timeoutMs } }
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
}
|
||||
const cleanup = (): void => {
|
||||
cancelSignal.removeEventListener("abort", onCancel);
|
||||
if (timerHandle !== undefined) timers.clearTimer(timerHandle);
|
||||
};
|
||||
return { controller, cleanup };
|
||||
}
|
||||
|
||||
function isTimeoutError(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof JobSchedulerError &&
|
||||
err.code === JOB_SCHEDULER_CODES.TIMEOUT
|
||||
);
|
||||
}
|
||||
|
||||
function classifyOutcome(err: unknown): JobOutcome {
|
||||
return isTimeoutError(err) ? "timeout" : "error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single job with retry, timeout, and abort semantics.
|
||||
*
|
||||
* - Emits `job:start` for every attempt (1-based `attempt` count).
|
||||
* - Delegates the retry loop to `@suaveplan/retry.retry()`, threading the
|
||||
* scheduler's injected `timers` pair into the inter-attempt sleep so
|
||||
* FakeClock-driven test runs stay deterministic.
|
||||
* - Emits `job:complete` on success and `job:fail` on every failed attempt
|
||||
* (including the terminal one).
|
||||
* - On timeout, throws `JOB_SCHEDULER.TIMEOUT`. On terminal failure for
|
||||
* any other reason, throws `JOB_SCHEDULER.EXECUTION_FAILED` wrapping
|
||||
* the original error in `cause`.
|
||||
*/
|
||||
export async function executeJob(input: ExecuteJobInput): Promise<void> {
|
||||
const maxAttempts = Math.max(1, input.retries + 1);
|
||||
let attemptCounter = 0;
|
||||
const runAttempt = async (): Promise<void> => {
|
||||
attemptCounter += 1;
|
||||
const attempt = attemptCounter;
|
||||
const handle = linkSignals(
|
||||
input.cancelSignal,
|
||||
input.timeoutMs,
|
||||
input.timers
|
||||
);
|
||||
input.onAttemptController?.(handle.controller);
|
||||
const ctx: JobContext = {
|
||||
jobId: input.jobId,
|
||||
signal: handle.controller.signal,
|
||||
attempt,
|
||||
scheduledAt: input.scheduledAt,
|
||||
logger: input.logger,
|
||||
};
|
||||
const start = input.clock();
|
||||
input.events.emit("job:start", {
|
||||
jobId: input.jobId,
|
||||
attempt,
|
||||
scheduledAt: input.scheduledAt,
|
||||
});
|
||||
try {
|
||||
await input.handler(ctx);
|
||||
const durationMs = input.clock() - start;
|
||||
handle.cleanup();
|
||||
recordJobRun(durationMs, "success", input.kind);
|
||||
input.events.emit("job:complete", {
|
||||
jobId: input.jobId,
|
||||
attempt,
|
||||
durationMs,
|
||||
});
|
||||
} catch (err) {
|
||||
const durationMs = input.clock() - start;
|
||||
handle.cleanup();
|
||||
// If the per-attempt controller was aborted with a TIMEOUT
|
||||
// reason, prefer that error so the outer translator sees the
|
||||
// typed JobSchedulerError(TIMEOUT) instead of the handler's
|
||||
// reject value (which is usually the same object, but may be
|
||||
// the bare ctx.signal.reason).
|
||||
const reason = handle.controller.signal.reason;
|
||||
const failure = isTimeoutError(reason) ? reason : err;
|
||||
const outcome = classifyOutcome(failure);
|
||||
recordJobRun(durationMs, outcome, input.kind);
|
||||
incrementJobFailure(input.kind);
|
||||
input.events.emit("job:fail", {
|
||||
jobId: input.jobId,
|
||||
attempt,
|
||||
durationMs,
|
||||
error: failure,
|
||||
});
|
||||
throw failure;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await retry(runAttempt, {
|
||||
maxAttempts,
|
||||
delay: createConstantBackoff({ delay: input.retryDelayMs }),
|
||||
signal: input.cancelSignal,
|
||||
timers: input.timers,
|
||||
});
|
||||
} catch (err) {
|
||||
// `retry()` only ever throws `RetryExhaustedError` or
|
||||
// `RetryAbortedError`; we translate each to a typed
|
||||
// `JobSchedulerError`.
|
||||
if (err instanceof RetryExhaustedError) {
|
||||
const lastError = err.lastError;
|
||||
if (isTimeoutError(lastError)) {
|
||||
throw lastError;
|
||||
}
|
||||
throw new JobSchedulerError(
|
||||
JOB_SCHEDULER_CODES.EXECUTION_FAILED,
|
||||
"job_scheduler.execution_failed",
|
||||
{
|
||||
context: { jobId: input.jobId, attempts: maxAttempts },
|
||||
cause: lastError,
|
||||
}
|
||||
);
|
||||
}
|
||||
const aborted = err as RetryAbortedError;
|
||||
throw new JobSchedulerError(
|
||||
JOB_SCHEDULER_CODES.EXECUTION_FAILED,
|
||||
"job_scheduler.execution_failed",
|
||||
{
|
||||
context: { jobId: input.jobId, attempts: attemptCounter },
|
||||
cause: aborted.cause,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
70
packages/foundation/flow/job-scheduler/src/index.ts
Normal file
70
packages/foundation/flow/job-scheduler/src/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// Error codes registration (side-effect import)
|
||||
import "./errors/job-scheduler-error.js";
|
||||
|
||||
// Errors
|
||||
export type { JobSchedulerErrorCode } from "./errors/job-scheduler-error.js";
|
||||
export {
|
||||
JOB_SCHEDULER_CODES,
|
||||
JobSchedulerError,
|
||||
registerJobSchedulerErrorCodes,
|
||||
} from "./errors/job-scheduler-error.js";
|
||||
// Event constants
|
||||
export {
|
||||
JOB_SCHEDULER_EVENTS,
|
||||
type JobSchedulerEventName,
|
||||
} from "./events/events.js";
|
||||
// Execution
|
||||
export type {
|
||||
ExecuteJobInput,
|
||||
ExecutionTimers,
|
||||
JobContext,
|
||||
JobHandler,
|
||||
} from "./execution/job-execution.js";
|
||||
export { executeJob } from "./execution/job-execution.js";
|
||||
|
||||
// OTel
|
||||
export type { JobKind, JobOutcome } from "./otel/job-scheduler-otel.js";
|
||||
export {
|
||||
incrementCronExhausted,
|
||||
incrementJobFailure,
|
||||
recordJobDuration,
|
||||
recordJobRun,
|
||||
recordQueueDepth,
|
||||
} from "./otel/job-scheduler-otel.js";
|
||||
|
||||
// Queue
|
||||
export { JobQueue } from "./queue/job-queue.js";
|
||||
export type { QueuedJob } from "./queue/job-queue.types.js";
|
||||
|
||||
// Registry
|
||||
export { JobRegistry } from "./registry/job-registry.js";
|
||||
|
||||
// Scheduler
|
||||
export {
|
||||
applyCatchupAndReenqueue,
|
||||
type CronEnv,
|
||||
type ReenqueueOutcome,
|
||||
reenqueueCron,
|
||||
} from "./scheduler/cron-rescheduler.js";
|
||||
export {
|
||||
type DispatchEnv,
|
||||
dispatch,
|
||||
type ScheduledJob,
|
||||
} from "./scheduler/job-dispatcher.js";
|
||||
export {
|
||||
createJobScheduler,
|
||||
JobScheduler,
|
||||
} from "./scheduler/job-scheduler.js";
|
||||
export {
|
||||
JobOptionsSchema,
|
||||
JobSchedulerOptionsSchema,
|
||||
QueueJobOptionsSchema,
|
||||
} from "./scheduler/job-scheduler.schemas.js";
|
||||
export type {
|
||||
JobOptions,
|
||||
JobSchedulerEventMap,
|
||||
JobSchedulerOptions,
|
||||
JobStatus,
|
||||
QueueJobOptions,
|
||||
} from "./scheduler/job-scheduler.types.js";
|
||||
export { snapshotJob } from "./scheduler/job-snapshot.js";
|
||||
8
packages/foundation/flow/job-scheduler/src/otel/index.ts
Normal file
8
packages/foundation/flow/job-scheduler/src/otel/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { JobKind, JobOutcome } from "./job-scheduler-otel.js";
|
||||
export {
|
||||
incrementCronExhausted,
|
||||
incrementJobFailure,
|
||||
recordJobDuration,
|
||||
recordJobRun,
|
||||
recordQueueDepth,
|
||||
} from "./job-scheduler-otel.js";
|
||||
@@ -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).
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
incrementCronExhausted,
|
||||
incrementJobFailure,
|
||||
type JobKind,
|
||||
type JobOutcome,
|
||||
recordJobDuration,
|
||||
recordJobRun,
|
||||
recordQueueDepth,
|
||||
} from "./job-scheduler-otel.js";
|
||||
|
||||
describe("job-scheduler-otel", () => {
|
||||
it("records a run for every outcome × kind combination", () => {
|
||||
const outcomes: JobOutcome[] = ["success", "error", "timeout"];
|
||||
const kinds: JobKind[] = ["cron", "queued"];
|
||||
for (const outcome of outcomes) {
|
||||
for (const kind of kinds) {
|
||||
expect(() => {
|
||||
recordJobRun(12.5, outcome, kind);
|
||||
}).not.toThrow();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("records duration-only samples", () => {
|
||||
expect(() => {
|
||||
recordJobDuration(7, "success", "cron");
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("increments failure counter for each kind", () => {
|
||||
expect(() => {
|
||||
incrementJobFailure("cron");
|
||||
incrementJobFailure("queued");
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("increments cron-exhausted counter", () => {
|
||||
expect(() => {
|
||||
incrementCronExhausted();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("records queue depth", () => {
|
||||
expect(() => {
|
||||
recordQueueDepth(0);
|
||||
recordQueueDepth(42);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("is idempotent across repeated calls (lazy init cache)", () => {
|
||||
let invocations = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(() => {
|
||||
recordJobRun(1, "success", "queued");
|
||||
recordJobDuration(1, "success", "queued");
|
||||
incrementJobFailure("queued");
|
||||
incrementCronExhausted();
|
||||
recordQueueDepth(i);
|
||||
invocations += 1;
|
||||
}).not.toThrow();
|
||||
}
|
||||
expect(invocations).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/job-scheduler";
|
||||
|
||||
let _durationHistogram: Histogram | undefined;
|
||||
let _runsCounter: Counter | undefined;
|
||||
let _failuresCounter: Counter | undefined;
|
||||
let _cronExhaustedCounter: Counter | undefined;
|
||||
let _queueDepthHistogram: Histogram | undefined;
|
||||
|
||||
function getDurationHistogram(): Histogram {
|
||||
if (!_durationHistogram) {
|
||||
_durationHistogram = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createHistogram("job_scheduler.duration_ms", {
|
||||
description: "Job execution duration in milliseconds",
|
||||
unit: "ms",
|
||||
});
|
||||
}
|
||||
return _durationHistogram;
|
||||
}
|
||||
|
||||
function getRunsCounter(): Counter {
|
||||
if (!_runsCounter) {
|
||||
_runsCounter = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createCounter("job_scheduler.runs_total", {
|
||||
description: "Job runs by outcome and kind",
|
||||
});
|
||||
}
|
||||
return _runsCounter;
|
||||
}
|
||||
|
||||
function getFailuresCounter(): Counter {
|
||||
if (!_failuresCounter) {
|
||||
_failuresCounter = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createCounter("job_scheduler.failures_total", {
|
||||
description: "Job failures by kind",
|
||||
});
|
||||
}
|
||||
return _failuresCounter;
|
||||
}
|
||||
|
||||
function getCronExhaustedCounter(): Counter {
|
||||
if (!_cronExhaustedCounter) {
|
||||
_cronExhaustedCounter = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createCounter("job_scheduler.cron_exhausted_total", {
|
||||
description:
|
||||
"Cron-expression next-run searches that returned no future fire",
|
||||
});
|
||||
}
|
||||
return _cronExhaustedCounter;
|
||||
}
|
||||
|
||||
function getQueueDepthHistogram(): Histogram {
|
||||
if (!_queueDepthHistogram) {
|
||||
_queueDepthHistogram = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createHistogram("job_scheduler.queue_depth", {
|
||||
description: "Queue depth sampled at tick boundaries",
|
||||
});
|
||||
}
|
||||
return _queueDepthHistogram;
|
||||
}
|
||||
|
||||
/** Closed enum so cardinality stays bounded. */
|
||||
export type JobOutcome = "success" | "error" | "timeout";
|
||||
/** Closed enum so cardinality stays bounded. */
|
||||
export type JobKind = "cron" | "queued";
|
||||
|
||||
/** Record a run sample: duration histogram + runs counter. */
|
||||
export function recordJobRun(
|
||||
durationMs: number,
|
||||
outcome: JobOutcome,
|
||||
kind: JobKind
|
||||
): void {
|
||||
getDurationHistogram().record(durationMs, { outcome, kind });
|
||||
getRunsCounter().add(1, { outcome, kind });
|
||||
}
|
||||
|
||||
/** Record a duration sample without bumping the runs counter. */
|
||||
export function recordJobDuration(
|
||||
durationMs: number,
|
||||
outcome: JobOutcome,
|
||||
kind: JobKind
|
||||
): void {
|
||||
getDurationHistogram().record(durationMs, { outcome, kind });
|
||||
}
|
||||
|
||||
/** Increment the failures counter for a job kind. */
|
||||
export function incrementJobFailure(kind: JobKind): void {
|
||||
getFailuresCounter().add(1, { kind });
|
||||
}
|
||||
|
||||
/** Increment the `CRON.NEXT_RUN_NOT_FOUND`-drop counter. */
|
||||
export function incrementCronExhausted(): void {
|
||||
getCronExhaustedCounter().add(1);
|
||||
}
|
||||
|
||||
/** Sample the queue depth at the current tick. */
|
||||
export function recordQueueDepth(depth: number): void {
|
||||
getQueueDepthHistogram().record(depth);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { JobQueue } from "./job-queue.js";
|
||||
export type { QueuedJob } from "./job-queue.types.js";
|
||||
@@ -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,109 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { JobQueue } from "./job-queue.js";
|
||||
|
||||
describe("JobQueue", () => {
|
||||
it("returns undefined on empty peek/dequeue", () => {
|
||||
const q = new JobQueue();
|
||||
expect(q.peek()).toBeUndefined();
|
||||
expect(q.dequeue()).toBeUndefined();
|
||||
expect(q.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("dequeues in strict runAt order", () => {
|
||||
const q = new JobQueue();
|
||||
const inputs = [50, 10, 30, 20, 40, 5, 60];
|
||||
for (const runAt of inputs) {
|
||||
q.enqueue({ jobId: `j-${runAt}`, runAt });
|
||||
}
|
||||
const out: number[] = [];
|
||||
while (q.size() > 0) {
|
||||
const j = q.dequeue();
|
||||
if (j !== undefined) out.push(j.runAt);
|
||||
}
|
||||
expect(out).toEqual([...inputs].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
it("peek does not mutate", () => {
|
||||
const q = new JobQueue();
|
||||
q.enqueue({ jobId: "a", runAt: 5 });
|
||||
q.enqueue({ jobId: "b", runAt: 3 });
|
||||
expect(q.peek()?.jobId).toBe("b");
|
||||
expect(q.size()).toBe(2);
|
||||
expect(q.peek()?.jobId).toBe("b");
|
||||
});
|
||||
|
||||
it("removes the head", () => {
|
||||
const q = new JobQueue();
|
||||
q.enqueue({ jobId: "a", runAt: 1 });
|
||||
q.enqueue({ jobId: "b", runAt: 2 });
|
||||
q.enqueue({ jobId: "c", runAt: 3 });
|
||||
expect(q.remove("a")).toBe(true);
|
||||
expect(q.dequeue()?.jobId).toBe("b");
|
||||
expect(q.dequeue()?.jobId).toBe("c");
|
||||
});
|
||||
|
||||
it("removes the tail", () => {
|
||||
const q = new JobQueue();
|
||||
q.enqueue({ jobId: "a", runAt: 1 });
|
||||
q.enqueue({ jobId: "b", runAt: 2 });
|
||||
q.enqueue({ jobId: "c", runAt: 3 });
|
||||
expect(q.remove("c")).toBe(true);
|
||||
expect(q.size()).toBe(2);
|
||||
expect(q.dequeue()?.jobId).toBe("a");
|
||||
expect(q.dequeue()?.jobId).toBe("b");
|
||||
});
|
||||
|
||||
it("removes mid-heap and preserves order", () => {
|
||||
const q = new JobQueue();
|
||||
for (const runAt of [10, 20, 30, 40, 50, 60, 70]) {
|
||||
q.enqueue({ jobId: `j-${runAt}`, runAt });
|
||||
}
|
||||
expect(q.remove("j-30")).toBe(true);
|
||||
const out: number[] = [];
|
||||
while (q.size() > 0) {
|
||||
const j = q.dequeue();
|
||||
if (j !== undefined) out.push(j.runAt);
|
||||
}
|
||||
expect(out).toEqual([10, 20, 40, 50, 60, 70]);
|
||||
});
|
||||
|
||||
it("returns false when removing unknown jobId", () => {
|
||||
const q = new JobQueue();
|
||||
q.enqueue({ jobId: "a", runAt: 1 });
|
||||
expect(q.remove("missing")).toBe(false);
|
||||
expect(q.size()).toBe(1);
|
||||
});
|
||||
|
||||
it("snapshot returns frozen view", () => {
|
||||
const q = new JobQueue();
|
||||
q.enqueue({ jobId: "a", runAt: 2 });
|
||||
q.enqueue({ jobId: "b", runAt: 1 });
|
||||
const snap = q.snapshot();
|
||||
expect(snap).toHaveLength(2);
|
||||
expect(Object.isFrozen(snap)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles a large randomised sequence", () => {
|
||||
const q = new JobQueue();
|
||||
const inputs: number[] = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const runAt = Math.floor(Math.random() * 1_000_000);
|
||||
inputs.push(runAt);
|
||||
q.enqueue({ jobId: `j-${i}-${runAt}`, runAt });
|
||||
}
|
||||
const sorted = [...inputs].sort((a, b) => a - b);
|
||||
const out: number[] = [];
|
||||
while (q.size() > 0) {
|
||||
const j = q.dequeue();
|
||||
if (j !== undefined) out.push(j.runAt);
|
||||
}
|
||||
expect(out).toEqual(sorted);
|
||||
});
|
||||
|
||||
it("preserves cronExpr on dequeue", () => {
|
||||
const q = new JobQueue();
|
||||
q.enqueue({ jobId: "c", runAt: 1, cronExpr: "* * * * *" });
|
||||
const out = q.dequeue();
|
||||
expect(out?.cronExpr).toBe("* * * * *");
|
||||
});
|
||||
});
|
||||
115
packages/foundation/flow/job-scheduler/src/queue/job-queue.ts
Normal file
115
packages/foundation/flow/job-scheduler/src/queue/job-queue.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { QueuedJob } from "./job-queue.types.js";
|
||||
|
||||
/**
|
||||
* Array-backed binary min-heap keyed by `QueuedJob.runAt`.
|
||||
*
|
||||
* - `enqueue` / `dequeue`: O(log n).
|
||||
* - `peek` / `size`: O(1).
|
||||
* - `remove(jobId)`: O(n) — linear scan, swap-with-last, sift in the
|
||||
* appropriate direction.
|
||||
*
|
||||
* The heap is intentionally minimal — no external dependency, no GC
|
||||
* churn beyond the single backing array, no comparator allocation per
|
||||
* operation. It exists for the scheduler's tick loop, which calls
|
||||
* `peek` once per tick and `dequeue` at most `concurrency` times.
|
||||
*/
|
||||
export class JobQueue {
|
||||
private readonly heap: QueuedJob[] = [];
|
||||
|
||||
/** Add `job` to the heap. Ties on `runAt` are broken by insertion order. */
|
||||
public enqueue(job: QueuedJob): void {
|
||||
this.heap.push(job);
|
||||
this.siftUp(this.heap.length - 1);
|
||||
}
|
||||
|
||||
/** Remove and return the earliest job, or `undefined` if empty. */
|
||||
public dequeue(): QueuedJob | undefined {
|
||||
const n = this.heap.length;
|
||||
if (n === 0) return undefined;
|
||||
const root = this.heap[0] as QueuedJob;
|
||||
const last = this.heap.pop() as QueuedJob;
|
||||
if (n > 1) {
|
||||
this.heap[0] = last;
|
||||
this.siftDown(0);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Earliest job without removing it, or `undefined` if empty. */
|
||||
public peek(): QueuedJob | undefined {
|
||||
return this.heap[0];
|
||||
}
|
||||
|
||||
/** Current heap size. */
|
||||
public size(): number {
|
||||
return this.heap.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the first entry whose `jobId` matches and rebalance.
|
||||
* Returns `true` if an entry was found and removed.
|
||||
*/
|
||||
public remove(jobId: string): boolean {
|
||||
const idx = this.heap.findIndex((j) => j.jobId === jobId);
|
||||
if (idx === -1) return false;
|
||||
const lastIdx = this.heap.length - 1;
|
||||
if (idx === lastIdx) {
|
||||
this.heap.pop();
|
||||
return true;
|
||||
}
|
||||
const last = this.heap.pop() as QueuedJob;
|
||||
this.heap[idx] = last;
|
||||
// The replacement may belong higher OR lower; try both. Only one
|
||||
// direction will do work, the other no-ops.
|
||||
this.siftUp(idx);
|
||||
this.siftDown(idx);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Drain all entries into a snapshot array in heap-order (NOT sorted). */
|
||||
public snapshot(): readonly QueuedJob[] {
|
||||
return Object.freeze([...this.heap]);
|
||||
}
|
||||
|
||||
private siftUp(from: number): void {
|
||||
let i = from;
|
||||
while (i > 0) {
|
||||
const parent = (i - 1) >>> 1;
|
||||
const a = this.heap[i] as QueuedJob;
|
||||
const b = this.heap[parent] as QueuedJob;
|
||||
if (a.runAt >= b.runAt) break;
|
||||
this.heap[i] = b;
|
||||
this.heap[parent] = a;
|
||||
i = parent;
|
||||
}
|
||||
}
|
||||
|
||||
private siftDown(from: number): void {
|
||||
const n = this.heap.length;
|
||||
let i = from;
|
||||
for (;;) {
|
||||
const left = i * 2 + 1;
|
||||
const right = left + 1;
|
||||
let smallest = i;
|
||||
if (
|
||||
left < n &&
|
||||
(this.heap[left] as QueuedJob).runAt <
|
||||
(this.heap[smallest] as QueuedJob).runAt
|
||||
) {
|
||||
smallest = left;
|
||||
}
|
||||
if (
|
||||
right < n &&
|
||||
(this.heap[right] as QueuedJob).runAt <
|
||||
(this.heap[smallest] as QueuedJob).runAt
|
||||
) {
|
||||
smallest = right;
|
||||
}
|
||||
if (smallest === i) break;
|
||||
const tmp = this.heap[i] as QueuedJob;
|
||||
this.heap[i] = this.heap[smallest] as QueuedJob;
|
||||
this.heap[smallest] = tmp;
|
||||
i = smallest;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** A single entry on the priority queue, keyed by `runAt` (epoch ms). */
|
||||
export interface QueuedJob {
|
||||
readonly jobId: string;
|
||||
readonly runAt: number;
|
||||
readonly cronExpr?: string;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { JobRegistry } from "./job-registry.js";
|
||||
@@ -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,62 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
JOB_SCHEDULER_CODES,
|
||||
JobSchedulerError,
|
||||
} from "../errors/job-scheduler-error.js";
|
||||
import { JobRegistry } from "./job-registry.js";
|
||||
|
||||
describe("JobRegistry", () => {
|
||||
it("registers and resolves a name", () => {
|
||||
const r = new JobRegistry();
|
||||
r.register("nightly", "id-1");
|
||||
expect(r.resolve("nightly")).toBe("id-1");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown name", () => {
|
||||
const r = new JobRegistry();
|
||||
expect(r.resolve("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws on duplicate name", () => {
|
||||
const r = new JobRegistry();
|
||||
r.register("nightly", "id-1");
|
||||
let captured: unknown;
|
||||
try {
|
||||
r.register("nightly", "id-2");
|
||||
} catch (err) {
|
||||
captured = err;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(JobSchedulerError);
|
||||
expect((captured as JobSchedulerError).code).toBe(
|
||||
JOB_SCHEDULER_CODES.JOB_NOT_FOUND
|
||||
);
|
||||
});
|
||||
|
||||
it("unregisters", () => {
|
||||
const r = new JobRegistry();
|
||||
r.register("nightly", "id-1");
|
||||
expect(r.unregister("nightly")).toBe(true);
|
||||
expect(r.unregister("nightly")).toBe(false);
|
||||
expect(r.resolve("nightly")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows re-register after unregister", () => {
|
||||
const r = new JobRegistry();
|
||||
r.register("nightly", "id-1");
|
||||
r.unregister("nightly");
|
||||
expect(() => r.register("nightly", "id-2")).not.toThrow();
|
||||
expect(r.resolve("nightly")).toBe("id-2");
|
||||
});
|
||||
|
||||
it("lists all bindings as frozen snapshot", () => {
|
||||
const r = new JobRegistry();
|
||||
r.register("a", "id-a");
|
||||
r.register("b", "id-b");
|
||||
const list = r.list();
|
||||
expect(list).toHaveLength(2);
|
||||
expect(Object.isFrozen(list)).toBe(true);
|
||||
const byName = new Map(list.map((e) => [e.name, e.jobId]));
|
||||
expect(byName.get("a")).toBe("id-a");
|
||||
expect(byName.get("b")).toBe("id-b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
JOB_SCHEDULER_CODES,
|
||||
JobSchedulerError,
|
||||
} from "../errors/job-scheduler-error.js";
|
||||
|
||||
/**
|
||||
* Maps human-friendly names to scheduler-generated job ids so callers
|
||||
* can refer to jobs by name without leaking the scheduler's internal id
|
||||
* scheme.
|
||||
*
|
||||
* The registry is intentionally minimal: duplicate `register(name, ...)`
|
||||
* throws `JOB_SCHEDULER.JOB_NOT_FOUND` with a context payload indicating
|
||||
* the duplicate name — the code is reused because the alternative is
|
||||
* adding a fourth registry entry that exists only for this one branch.
|
||||
* Consumers who need stricter semantics can wrap the registry.
|
||||
*/
|
||||
export class JobRegistry {
|
||||
private readonly names = new Map<string, string>();
|
||||
|
||||
/** Register `name → jobId`. Throws on duplicate `name`. */
|
||||
public register(name: string, jobId: string): void {
|
||||
if (this.names.has(name)) {
|
||||
throw new JobSchedulerError(
|
||||
JOB_SCHEDULER_CODES.JOB_NOT_FOUND,
|
||||
"job_scheduler.registry.duplicate_name",
|
||||
{ context: { name } }
|
||||
);
|
||||
}
|
||||
this.names.set(name, jobId);
|
||||
}
|
||||
|
||||
/** Remove a name binding; returns `true` when something was removed. */
|
||||
public unregister(name: string): boolean {
|
||||
return this.names.delete(name);
|
||||
}
|
||||
|
||||
/** Resolve `name → jobId` or `undefined` if unknown. */
|
||||
public resolve(name: string): string | undefined {
|
||||
return this.names.get(name);
|
||||
}
|
||||
|
||||
/** Frozen snapshot of every `(name, jobId)` pair. */
|
||||
public list(): ReadonlyArray<{ name: string; jobId: string }> {
|
||||
return Object.freeze(
|
||||
Array.from(this.names.entries()).map(([name, jobId]) => ({
|
||||
name,
|
||||
jobId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,187 @@
|
||||
import { parseCron } from "@suaveplan/cron";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
import { JobQueue } from "../queue/job-queue.js";
|
||||
import {
|
||||
applyCatchupAndReenqueue,
|
||||
type CronEnv,
|
||||
reenqueueCron,
|
||||
} from "./cron-rescheduler.js";
|
||||
import type { ScheduledJob } from "./job-dispatcher.js";
|
||||
import type { JobSchedulerEventMap } from "./job-scheduler.types.js";
|
||||
|
||||
function makeEnv(now: number): {
|
||||
env: CronEnv;
|
||||
queue: JobQueue;
|
||||
events: { type: string; payload: unknown }[];
|
||||
} {
|
||||
const queue = new JobQueue();
|
||||
const events: { type: string; payload: unknown }[] = [];
|
||||
const bus: EventBusLike<JobSchedulerEventMap> = {
|
||||
on: () => () => undefined,
|
||||
emit: (type, payload) => {
|
||||
events.push({ type: type as string, payload });
|
||||
},
|
||||
};
|
||||
const expr = parseCron("* * * * *");
|
||||
const env: CronEnv = {
|
||||
clock: () => now,
|
||||
events: bus,
|
||||
queue,
|
||||
expressionOf: () => expr,
|
||||
};
|
||||
return { env, queue, events };
|
||||
}
|
||||
|
||||
function makeJob(id = "j-1"): ScheduledJob {
|
||||
return {
|
||||
id,
|
||||
kind: "cron",
|
||||
cronExpr: "* * * * *",
|
||||
tz: "UTC",
|
||||
handler: async () => undefined,
|
||||
options: {},
|
||||
status: "pending",
|
||||
runs: 0,
|
||||
failures: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("reenqueueCron", () => {
|
||||
it("returns scheduled and pushes the next fire", () => {
|
||||
const { env, queue } = makeEnv(Date.UTC(2026, 4, 12, 10, 0, 0));
|
||||
const job = makeJob();
|
||||
const outcome = reenqueueCron(job, env, "UTC");
|
||||
expect(outcome).toBe("scheduled");
|
||||
expect(queue.size()).toBe(1);
|
||||
});
|
||||
|
||||
it("returns scheduled when expression is unknown (no-op)", () => {
|
||||
const { env } = makeEnv(Date.UTC(2026, 4, 12, 10, 0, 0));
|
||||
const job = makeJob();
|
||||
const unknownEnv: CronEnv = { ...env, expressionOf: () => undefined };
|
||||
expect(reenqueueCron(job, unknownEnv, "UTC")).toBe("scheduled");
|
||||
});
|
||||
|
||||
it("marks exhausted when nextRun throws CRON.NEXT_RUN_NOT_FOUND", () => {
|
||||
const { env, events } = makeEnv(Date.UTC(2026, 4, 12, 10, 0, 0));
|
||||
const job = makeJob();
|
||||
const fakeExpr = {
|
||||
expr: "x",
|
||||
fields: {} as never,
|
||||
nextRun: () => {
|
||||
throw Object.assign(new Error("no future fire"), {
|
||||
code: "CRON.NEXT_RUN_NOT_FOUND",
|
||||
name: "CronError",
|
||||
});
|
||||
},
|
||||
nextRuns: () => [],
|
||||
previousRun: () => 0,
|
||||
matches: () => false,
|
||||
};
|
||||
// Provide an env whose expressionOf returns a real CronError via parseCron-impossible
|
||||
// We can't easily synthesize a CronError without importing — use a fake expression
|
||||
// that throws an actual CronError by importing the class.
|
||||
const exhaustingEnv: CronEnv = {
|
||||
...env,
|
||||
expressionOf: () => fakeExpr as never,
|
||||
};
|
||||
expect(() => reenqueueCron(job, exhaustingEnv, "UTC")).toThrow();
|
||||
// Above: the fake error is not a real CronError, so it rethrows.
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCatchupAndReenqueue", () => {
|
||||
it("no-ops the catchup branch when lastRun is missing", () => {
|
||||
const { env, queue } = makeEnv(Date.UTC(2026, 4, 12, 10, 0, 0));
|
||||
const job = makeJob();
|
||||
applyCatchupAndReenqueue(job, env, "UTC", true, 3);
|
||||
// Only the future fire is enqueued.
|
||||
expect(queue.size()).toBe(1);
|
||||
});
|
||||
|
||||
it("enqueues missed fires before the future fire", () => {
|
||||
const now = Date.UTC(2026, 4, 12, 10, 5, 0);
|
||||
const { env, queue } = makeEnv(now);
|
||||
const job = makeJob();
|
||||
// Pretend the job last ran 3 minutes ago.
|
||||
job.lastRun = now - 3 * 60_000;
|
||||
applyCatchupAndReenqueue(job, env, "UTC", true, 2);
|
||||
expect(queue.size()).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("breaks the catchup loop when a missed fire reaches `now`", () => {
|
||||
const now = Date.UTC(2026, 4, 12, 10, 0, 0);
|
||||
const { env, queue } = makeEnv(now);
|
||||
const job = makeJob();
|
||||
// lastRun is so recent that the first `nextRuns` result is already
|
||||
// at-or-after `now`, exercising the `at >= now` break inside
|
||||
// applyCatchupAndReenqueue.
|
||||
job.lastRun = now - 30_000;
|
||||
applyCatchupAndReenqueue(job, env, "UTC", true, 5);
|
||||
// Only the future fire from reenqueueCron landed.
|
||||
expect(queue.size()).toBe(1);
|
||||
});
|
||||
|
||||
it("skips catchup when option is false", () => {
|
||||
const { env, queue } = makeEnv(Date.UTC(2026, 4, 12, 10, 0, 0));
|
||||
const job = makeJob();
|
||||
job.lastRun = Date.UTC(2026, 4, 12, 9, 50, 0);
|
||||
applyCatchupAndReenqueue(job, env, "UTC", false, 99);
|
||||
expect(queue.size()).toBe(1);
|
||||
});
|
||||
|
||||
it("is a no-op when expression is unknown", () => {
|
||||
const { env, queue } = makeEnv(Date.UTC(2026, 4, 12, 10, 0, 0));
|
||||
const job = makeJob();
|
||||
const noExprEnv: CronEnv = { ...env, expressionOf: () => undefined };
|
||||
applyCatchupAndReenqueue(job, noExprEnv, "UTC", true, 3);
|
||||
expect(queue.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reenqueueCron — without cronExpr field on job", () => {
|
||||
it("enqueues without the cronExpr passthrough", () => {
|
||||
const { env, queue } = makeEnv(Date.UTC(2026, 4, 12, 10, 0, 0));
|
||||
const job = makeJob();
|
||||
// Strip cronExpr to exercise the conditional-spread else branch.
|
||||
delete (job as { cronExpr?: string }).cronExpr;
|
||||
const outcome = reenqueueCron(job, env, "UTC");
|
||||
expect(outcome).toBe("scheduled");
|
||||
const head = queue.peek();
|
||||
expect(head?.cronExpr).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCatchupAndReenqueue — without cronExpr field on job", () => {
|
||||
it("enqueues catchup fires without cronExpr passthrough", () => {
|
||||
const now = Date.UTC(2026, 4, 12, 10, 5, 0);
|
||||
const { env, queue } = makeEnv(now);
|
||||
const job = makeJob();
|
||||
delete (job as { cronExpr?: string }).cronExpr;
|
||||
job.lastRun = now - 3 * 60_000;
|
||||
applyCatchupAndReenqueue(job, env, "UTC", true, 2);
|
||||
const peek = queue.peek();
|
||||
expect(peek?.cronExpr).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reenqueueCron — real CronError exhaustion path", () => {
|
||||
it("emits job:scheduling_exhausted via the typed error class", () => {
|
||||
// Use a cron expression that genuinely exhausts: months=2 day=30 →
|
||||
// impossible in February. parseCron accepts this syntactically, but
|
||||
// nextRun will throw NEXT_RUN_NOT_FOUND when the search horizon is
|
||||
// exceeded.
|
||||
const { env, events } = makeEnv(Date.UTC(2026, 1, 28, 0, 0, 0));
|
||||
const job = makeJob();
|
||||
const expr = parseCron("0 0 30 2 *"); // Feb 30 — impossible
|
||||
const exhaustEnv: CronEnv = { ...env, expressionOf: () => expr };
|
||||
const outcome = reenqueueCron(job, exhaustEnv, "UTC");
|
||||
expect(outcome).toBe("exhausted");
|
||||
expect(events.some((e) => e.type === "job:scheduling_exhausted")).toBe(
|
||||
true
|
||||
);
|
||||
expect(job.status).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { CronExpression } from "@suaveplan/cron";
|
||||
import { CRON_CODES, CronError } from "@suaveplan/cron/errors/cron-error";
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
import { incrementCronExhausted } from "../otel/job-scheduler-otel.js";
|
||||
import type { JobQueue } from "../queue/job-queue.js";
|
||||
import type { ScheduledJob } from "./job-dispatcher.js";
|
||||
import type { JobSchedulerEventMap } from "./job-scheduler.types.js";
|
||||
|
||||
/** Discriminator: was the rescheduling successful or exhausted? */
|
||||
export type ReenqueueOutcome = "scheduled" | "exhausted";
|
||||
|
||||
/** Inputs every cron-reschedule decision needs from the scheduler. */
|
||||
export interface CronEnv {
|
||||
readonly clock: () => number;
|
||||
readonly events: EventBusLike<JobSchedulerEventMap>;
|
||||
readonly queue: JobQueue;
|
||||
readonly expressionOf: (jobId: string) => CronExpression | undefined;
|
||||
}
|
||||
|
||||
function isCronExhausted(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof CronError && err.code === CRON_CODES.NEXT_RUN_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next fire for a cron job and push it onto the queue.
|
||||
* Returns `"exhausted"` when the cron expression no longer has any
|
||||
* future fire, in which case the job is marked `cancelled` and the
|
||||
* `job:scheduling_exhausted` event is emitted exactly once.
|
||||
*/
|
||||
export function reenqueueCron(
|
||||
job: ScheduledJob,
|
||||
env: CronEnv,
|
||||
tz: string
|
||||
): ReenqueueOutcome {
|
||||
const expression = env.expressionOf(job.id);
|
||||
if (expression === undefined) return "scheduled";
|
||||
try {
|
||||
const next = expression.nextRun(env.clock(), tz);
|
||||
job.nextRun = next;
|
||||
env.queue.enqueue({
|
||||
jobId: job.id,
|
||||
runAt: next,
|
||||
...(job.cronExpr !== undefined ? { cronExpr: job.cronExpr } : {}),
|
||||
});
|
||||
return "scheduled";
|
||||
} catch (err) {
|
||||
if (isCronExhausted(err)) {
|
||||
incrementCronExhausted();
|
||||
job.status = "cancelled";
|
||||
env.events.emit("job:scheduling_exhausted", { jobId: job.id });
|
||||
return "exhausted";
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply optional catchup-missed-fires logic, then enqueue the next
|
||||
* scheduled fire via `reenqueueCron`.
|
||||
*/
|
||||
export function applyCatchupAndReenqueue(
|
||||
job: ScheduledJob,
|
||||
env: CronEnv,
|
||||
tz: string,
|
||||
catchupMissed: boolean,
|
||||
maxMissedRuns: number
|
||||
): ReenqueueOutcome {
|
||||
const expression = env.expressionOf(job.id);
|
||||
if (expression === undefined) return "scheduled";
|
||||
if (catchupMissed && job.lastRun !== undefined) {
|
||||
const missed = expression.nextRuns(maxMissedRuns, job.lastRun, tz);
|
||||
const now = env.clock();
|
||||
for (const at of missed) {
|
||||
if (at >= now) break;
|
||||
env.queue.enqueue({
|
||||
jobId: job.id,
|
||||
runAt: at,
|
||||
...(job.cronExpr !== undefined
|
||||
? { cronExpr: job.cronExpr }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return reenqueueCron(job, env, tz);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export {
|
||||
applyCatchupAndReenqueue,
|
||||
type CronEnv,
|
||||
type ReenqueueOutcome,
|
||||
reenqueueCron,
|
||||
} from "./cron-rescheduler.js";
|
||||
export {
|
||||
type DispatchEnv,
|
||||
dispatch,
|
||||
type ScheduledJob,
|
||||
} from "./job-dispatcher.js";
|
||||
export { createJobScheduler, JobScheduler } from "./job-scheduler.js";
|
||||
export {
|
||||
JobOptionsSchema,
|
||||
JobSchedulerOptionsSchema,
|
||||
QueueJobOptionsSchema,
|
||||
} from "./job-scheduler.schemas.js";
|
||||
export type {
|
||||
JobHandler,
|
||||
JobOptions,
|
||||
JobSchedulerEventMap,
|
||||
JobSchedulerOptions,
|
||||
JobStatus,
|
||||
QueueJobOptions,
|
||||
} from "./job-scheduler.types.js";
|
||||
export { snapshotJob } from "./job-snapshot.js";
|
||||
@@ -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,146 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
import type { ExecutionTimers } from "../execution/job-execution.js";
|
||||
import {
|
||||
type DispatchEnv,
|
||||
dispatch,
|
||||
type ScheduledJob,
|
||||
} from "./job-dispatcher.js";
|
||||
import type { JobSchedulerEventMap } from "./job-scheduler.types.js";
|
||||
|
||||
function makeTimers(): ExecutionTimers {
|
||||
return {
|
||||
setTimer: (cb, ms) =>
|
||||
globalThis.setTimeout(cb, ms) as unknown as number,
|
||||
clearTimer: (h) => globalThis.clearTimeout(h as number),
|
||||
};
|
||||
}
|
||||
|
||||
function makeBus(): EventBusLike<JobSchedulerEventMap> {
|
||||
return { on: () => () => undefined, emit: () => undefined };
|
||||
}
|
||||
|
||||
function makeLogger(): LoggerLike {
|
||||
return {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("dispatch", () => {
|
||||
it("runs a successful handler and updates counters", async () => {
|
||||
const job: ScheduledJob = {
|
||||
id: "j",
|
||||
kind: "queued",
|
||||
handler: async () => undefined,
|
||||
options: {},
|
||||
status: "pending",
|
||||
runs: 0,
|
||||
failures: 0,
|
||||
};
|
||||
let calledAfter: ScheduledJob | undefined;
|
||||
const inFlight = new Set<Promise<void>>();
|
||||
const env: DispatchEnv = {
|
||||
clock: () => Date.now(),
|
||||
logger: makeLogger(),
|
||||
events: makeBus(),
|
||||
timers: makeTimers(),
|
||||
inFlight,
|
||||
runningSet: new Set(),
|
||||
afterRun: (j) => {
|
||||
calledAfter = j;
|
||||
},
|
||||
};
|
||||
dispatch(job, Date.now(), env);
|
||||
expect(inFlight.size).toBe(1);
|
||||
await Promise.all(Array.from(inFlight));
|
||||
expect(job.runs).toBe(1);
|
||||
expect(job.failures).toBe(0);
|
||||
expect(calledAfter?.id).toBe("j");
|
||||
});
|
||||
|
||||
it("records a failure and still calls afterRun", async () => {
|
||||
const job: ScheduledJob = {
|
||||
id: "j2",
|
||||
kind: "queued",
|
||||
handler: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
options: { retries: 0 },
|
||||
status: "pending",
|
||||
runs: 0,
|
||||
failures: 0,
|
||||
};
|
||||
let afterRunCalled = false;
|
||||
const inFlight = new Set<Promise<void>>();
|
||||
const env: DispatchEnv = {
|
||||
clock: () => Date.now(),
|
||||
logger: makeLogger(),
|
||||
events: makeBus(),
|
||||
timers: makeTimers(),
|
||||
inFlight,
|
||||
runningSet: new Set(),
|
||||
afterRun: () => {
|
||||
afterRunCalled = true;
|
||||
},
|
||||
};
|
||||
dispatch(job, Date.now(), env);
|
||||
await Promise.all(Array.from(inFlight));
|
||||
expect(job.failures).toBe(1);
|
||||
expect(afterRunCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("passes through job.name when present", async () => {
|
||||
const job: ScheduledJob = {
|
||||
id: "named",
|
||||
name: "report",
|
||||
kind: "queued",
|
||||
handler: async () => undefined,
|
||||
options: { timeout: 5_000 },
|
||||
status: "pending",
|
||||
runs: 0,
|
||||
failures: 0,
|
||||
};
|
||||
const inFlight = new Set<Promise<void>>();
|
||||
const env: DispatchEnv = {
|
||||
clock: () => Date.now(),
|
||||
logger: makeLogger(),
|
||||
events: makeBus(),
|
||||
timers: makeTimers(),
|
||||
inFlight,
|
||||
runningSet: new Set(),
|
||||
afterRun: () => undefined,
|
||||
};
|
||||
dispatch(job, Date.now(), env);
|
||||
await Promise.all(Array.from(inFlight));
|
||||
expect(job.runs).toBe(1);
|
||||
});
|
||||
|
||||
it("clears currentAbort after settle", async () => {
|
||||
const job: ScheduledJob = {
|
||||
id: "j3",
|
||||
kind: "cron",
|
||||
handler: async () => undefined,
|
||||
options: {},
|
||||
status: "pending",
|
||||
runs: 0,
|
||||
failures: 0,
|
||||
};
|
||||
const inFlight = new Set<Promise<void>>();
|
||||
const env: DispatchEnv = {
|
||||
clock: () => Date.now(),
|
||||
logger: makeLogger(),
|
||||
events: makeBus(),
|
||||
timers: makeTimers(),
|
||||
inFlight,
|
||||
runningSet: new Set(),
|
||||
afterRun: () => undefined,
|
||||
};
|
||||
dispatch(job, Date.now(), env);
|
||||
expect(job.currentAbort).toBeDefined();
|
||||
await Promise.all(Array.from(inFlight));
|
||||
expect(job.currentAbort).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
import {
|
||||
type ExecutionTimers,
|
||||
executeJob,
|
||||
type JobHandler,
|
||||
} from "../execution/job-execution.js";
|
||||
import type { JobKind } from "../otel/job-scheduler-otel.js";
|
||||
import type {
|
||||
JobOptions,
|
||||
JobSchedulerEventMap,
|
||||
} from "./job-scheduler.types.js";
|
||||
|
||||
const DEFAULT_RETRY_DELAY_MS = 1_000;
|
||||
|
||||
/** Internal scheduled-job record. Mutable on purpose — every field is
|
||||
* owned by the scheduler. */
|
||||
export interface ScheduledJob {
|
||||
id: string;
|
||||
name?: string;
|
||||
kind: JobKind;
|
||||
cronExpr?: string;
|
||||
tz?: string;
|
||||
handler: JobHandler;
|
||||
options: JobOptions;
|
||||
status: "pending" | "running" | "paused" | "cancelled";
|
||||
runs: number;
|
||||
failures: number;
|
||||
lastRun?: number;
|
||||
nextRun?: number;
|
||||
currentAbort?: AbortController;
|
||||
}
|
||||
|
||||
/** Inputs the scheduler hands to `dispatch` on every fire. */
|
||||
export interface DispatchEnv {
|
||||
readonly clock: () => number;
|
||||
readonly logger: LoggerLike;
|
||||
readonly events: EventBusLike<JobSchedulerEventMap>;
|
||||
readonly timers: ExecutionTimers;
|
||||
readonly inFlight: Set<Promise<void>>;
|
||||
readonly runningSet: Set<string>;
|
||||
readonly afterRun: (job: ScheduledJob) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a single job to `executeJob` with the scheduler's environment.
|
||||
* The scheduler delegates to this function so its core stays focused
|
||||
* on ordering and concurrency bookkeeping.
|
||||
*/
|
||||
export function dispatch(
|
||||
job: ScheduledJob,
|
||||
scheduledAt: number,
|
||||
env: DispatchEnv
|
||||
): void {
|
||||
job.status = "running";
|
||||
env.runningSet.add(job.id);
|
||||
const controller = new AbortController();
|
||||
job.currentAbort = controller;
|
||||
const promise = executeJob({
|
||||
jobId: job.id,
|
||||
...(job.name !== undefined ? { name: job.name } : {}),
|
||||
kind: job.kind,
|
||||
handler: job.handler,
|
||||
scheduledAt,
|
||||
retries: job.options.retries ?? 0,
|
||||
retryDelayMs: job.options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS,
|
||||
...(job.options.timeout !== undefined
|
||||
? { timeoutMs: job.options.timeout }
|
||||
: {}),
|
||||
cancelSignal: controller.signal,
|
||||
clock: env.clock,
|
||||
logger: env.logger,
|
||||
events: env.events,
|
||||
timers: env.timers,
|
||||
})
|
||||
.then(() => {
|
||||
job.runs += 1;
|
||||
job.lastRun = scheduledAt;
|
||||
})
|
||||
.catch(() => {
|
||||
job.runs += 1;
|
||||
job.failures += 1;
|
||||
job.lastRun = scheduledAt;
|
||||
})
|
||||
.finally(() => {
|
||||
env.runningSet.delete(job.id);
|
||||
env.inFlight.delete(promise);
|
||||
delete job.currentAbort;
|
||||
env.afterRun(job);
|
||||
});
|
||||
env.inFlight.add(promise);
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
import type { ExecutionTimers } from "../execution/job-execution.js";
|
||||
import { JobScheduler } from "./job-scheduler.js";
|
||||
import type { JobSchedulerEventMap } from "./job-scheduler.types.js";
|
||||
|
||||
interface TimerState {
|
||||
now: number;
|
||||
nextId: number;
|
||||
pending: Map<number, { fireAt: number; cb: () => void }>;
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
state: TimerState;
|
||||
timers: ExecutionTimers;
|
||||
clock: () => number;
|
||||
advance: (delta: number) => Promise<void>;
|
||||
events: { type: string; payload: unknown }[];
|
||||
bus: EventBusLike<JobSchedulerEventMap>;
|
||||
logger: LoggerLike;
|
||||
}
|
||||
|
||||
function makeHarness(start = Date.UTC(2026, 4, 12, 10, 0, 0)): Harness {
|
||||
const state: TimerState = { now: start, nextId: 1, pending: new Map() };
|
||||
const timers: ExecutionTimers = {
|
||||
setTimer: (cb, ms) => {
|
||||
const id = state.nextId++;
|
||||
state.pending.set(id, { fireAt: state.now + ms, cb });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (h) => {
|
||||
state.pending.delete(h as number);
|
||||
},
|
||||
};
|
||||
const advance = async (delta: number): Promise<void> => {
|
||||
const target = state.now + delta;
|
||||
for (;;) {
|
||||
let id: number | undefined;
|
||||
let at = Number.POSITIVE_INFINITY;
|
||||
for (const [k, v] of state.pending) {
|
||||
if (v.fireAt <= target && v.fireAt < at) {
|
||||
id = k;
|
||||
at = v.fireAt;
|
||||
}
|
||||
}
|
||||
if (id === undefined) break;
|
||||
const entry = state.pending.get(id);
|
||||
if (entry === undefined) break;
|
||||
state.pending.delete(id);
|
||||
state.now = entry.fireAt;
|
||||
entry.cb();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
state.now = target;
|
||||
};
|
||||
const events: { type: string; payload: unknown }[] = [];
|
||||
const bus: EventBusLike<JobSchedulerEventMap> = {
|
||||
on: () => () => undefined,
|
||||
emit: (type, payload) => {
|
||||
events.push({ type: type as string, payload });
|
||||
},
|
||||
};
|
||||
const logger: LoggerLike = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
timers,
|
||||
clock: () => state.now,
|
||||
advance,
|
||||
events,
|
||||
bus,
|
||||
logger,
|
||||
};
|
||||
}
|
||||
|
||||
function makeScheduler(
|
||||
h: Harness,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): JobScheduler {
|
||||
let counter = 0;
|
||||
return new JobScheduler({
|
||||
clock: h.clock,
|
||||
timers: h.timers,
|
||||
events: h.bus,
|
||||
logger: h.logger,
|
||||
tickIntervalMs: 100,
|
||||
generateId: () => `id-${++counter}`,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("JobScheduler — defaults", () => {
|
||||
it("constructs with zero options", () => {
|
||||
const s = new JobScheduler();
|
||||
expect(s).toBeInstanceOf(JobScheduler);
|
||||
const id = s.queue(async () => undefined, {
|
||||
runAt: Date.now() + 1_000_000,
|
||||
});
|
||||
expect(s.jobs().some((j) => j.id === id)).toBe(true);
|
||||
s.cancel(id);
|
||||
});
|
||||
|
||||
it("uses Date.now-style default clock when none is injected", () => {
|
||||
const s = new JobScheduler();
|
||||
const before = Date.now();
|
||||
const id = s.queue(async () => undefined, { delayMs: 1_000 });
|
||||
const job = s.jobs().find((j) => j.id === id);
|
||||
expect(job?.nextRun).toBeGreaterThanOrEqual(before);
|
||||
s.cancel(id);
|
||||
});
|
||||
|
||||
it("generates a uuid when no generateId is supplied", () => {
|
||||
const s = new JobScheduler();
|
||||
const id = s.queue(async () => undefined, { delayMs: 1_000 });
|
||||
expect(id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
);
|
||||
s.cancel(id);
|
||||
});
|
||||
|
||||
it("runs a queued job through default timers + logger + bus", async () => {
|
||||
const s = new JobScheduler({ tickIntervalMs: 5 });
|
||||
const internalBus = (
|
||||
s as unknown as {
|
||||
events: {
|
||||
on: (type: string, listener: () => void) => () => void;
|
||||
};
|
||||
}
|
||||
).events;
|
||||
const unsub = internalBus.on("job:start", () => undefined);
|
||||
unsub();
|
||||
let ran = false;
|
||||
s.queue(
|
||||
async ({ logger }) => {
|
||||
logger.debug("d");
|
||||
logger.info("i");
|
||||
logger.warn("w");
|
||||
logger.error("e");
|
||||
ran = true;
|
||||
},
|
||||
{ delayMs: 1 }
|
||||
);
|
||||
s.start();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await s.stop();
|
||||
expect(ran).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JobScheduler — resume after pause", () => {
|
||||
it("resumes a paused cron job and re-enqueues the next fire", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
let runs = 0;
|
||||
const id = s.schedule("* * * * *", async () => {
|
||||
runs += 1;
|
||||
});
|
||||
s.pause(id);
|
||||
s.start();
|
||||
await h.advance(2 * 60_000);
|
||||
expect(runs).toBe(0);
|
||||
s.resume(id);
|
||||
await h.advance(2 * 60_000 + 500);
|
||||
await s.stop();
|
||||
expect(runs).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("resumes a paused queued job and runs it", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
let ran = false;
|
||||
const id = s.queue(
|
||||
async () => {
|
||||
ran = true;
|
||||
},
|
||||
{ delayMs: 100 }
|
||||
);
|
||||
s.pause(id);
|
||||
s.start();
|
||||
await h.advance(500);
|
||||
expect(ran).toBe(false);
|
||||
s.resume(id);
|
||||
await h.advance(500);
|
||||
await s.stop();
|
||||
expect(ran).toBe(true);
|
||||
});
|
||||
|
||||
it("applies catchupMissed on resume of a cron job", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h, {
|
||||
catchupMissed: true,
|
||||
maxMissedRuns: 3,
|
||||
});
|
||||
let runs = 0;
|
||||
const id = s.schedule("* * * * *", async () => {
|
||||
runs += 1;
|
||||
});
|
||||
s.start();
|
||||
await h.advance(70_000);
|
||||
const after = runs;
|
||||
s.pause(id);
|
||||
await h.advance(3 * 60_000);
|
||||
s.resume(id);
|
||||
await h.advance(200);
|
||||
await s.stop();
|
||||
expect(runs).toBeGreaterThan(after);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JobScheduler — edge cases for full coverage", () => {
|
||||
it("queue() accepts handler-only call with default options", () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
const id = s.queue(async () => undefined);
|
||||
expect(id).toBeDefined();
|
||||
expect(s.jobs().some((j) => j.id === id)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts queue() with explicit id + name + zero delay default", () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
const id = s.queue(async () => undefined, {
|
||||
id: "custom-id",
|
||||
name: "named-queued",
|
||||
});
|
||||
expect(id).toBe("custom-id");
|
||||
const status = s.jobs().find((j) => j.id === "custom-id");
|
||||
expect(status?.name).toBe("named-queued");
|
||||
});
|
||||
|
||||
it("accepts schedule() without explicit tz (falls back to UTC)", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
let runs = 0;
|
||||
const id = s.schedule("* * * * *", async () => {
|
||||
runs += 1;
|
||||
});
|
||||
const internal = (
|
||||
s as unknown as { store: Map<string, { tz?: string }> }
|
||||
).store.get(id);
|
||||
if (internal !== undefined) delete internal.tz;
|
||||
s.start();
|
||||
await h.advance(60_500);
|
||||
await s.stop();
|
||||
expect(runs).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("pause + resume a running job", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
let resolver: () => void = () => undefined;
|
||||
const id = s.queue(
|
||||
() =>
|
||||
new Promise<void>((res) => {
|
||||
resolver = res;
|
||||
}),
|
||||
{ delayMs: 0 }
|
||||
);
|
||||
s.start();
|
||||
await h.advance(150);
|
||||
s.pause(id);
|
||||
const status = s.jobs().find((j) => j.id === id);
|
||||
expect(status?.status).toBe("paused");
|
||||
resolver();
|
||||
s.resume(id);
|
||||
await s.stop();
|
||||
});
|
||||
|
||||
it("resume of a non-paused job is a no-op (still emits event)", () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
const id = s.schedule("* * * * *", async () => undefined);
|
||||
s.resume(id);
|
||||
const status = s.jobs().find((j) => j.id === id);
|
||||
expect(status?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("queues with options.runAt skipping delayMs branch", () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
const id = s.queue(async () => undefined, {
|
||||
runAt: h.clock() + 500,
|
||||
});
|
||||
expect(s.jobs().find((j) => j.id === id)).toBeDefined();
|
||||
});
|
||||
|
||||
it("tick callback does not re-arm after stop()", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
s.start();
|
||||
await s.stop();
|
||||
expect(h.state.pending.size).toBe(0);
|
||||
});
|
||||
|
||||
it("resumes a cron job whose tz field is undefined", () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
const id = s.schedule("* * * * *", async () => undefined);
|
||||
const internal = (
|
||||
s as unknown as { store: Map<string, { tz?: string }> }
|
||||
).store.get(id);
|
||||
if (internal !== undefined) delete internal.tz;
|
||||
s.pause(id);
|
||||
s.resume(id);
|
||||
expect(s.jobs().find((j) => j.id === id)?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("pause on a cancelled job is a soft no-op (branch fallthrough)", () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
const id = s.queue(async () => undefined, { delayMs: 1_000 });
|
||||
s.cancel(id);
|
||||
s.pause(id);
|
||||
expect(s.jobs().find((j) => j.id === id)?.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("tick callback does not re-arm after stop set running to false", () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h, { tickIntervalMs: 100 });
|
||||
s.start();
|
||||
const internal = s as unknown as { running: boolean };
|
||||
const pending = Array.from(h.state.pending.entries());
|
||||
const first = pending[0];
|
||||
if (first !== undefined) {
|
||||
const [tid, entry] = first;
|
||||
h.state.pending.delete(tid);
|
||||
internal.running = false;
|
||||
entry.cb();
|
||||
}
|
||||
expect(h.state.pending.size).toBe(0);
|
||||
});
|
||||
|
||||
it("dequeues entries whose job has been removed from store", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
const id = s.queue(async () => undefined, { delayMs: 100 });
|
||||
(s as unknown as { store: Map<string, unknown> }).store.delete(id);
|
||||
s.start();
|
||||
await h.advance(300);
|
||||
await s.stop();
|
||||
expect(s.jobs()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JobScheduler — cancel during run", () => {
|
||||
it("aborts the in-flight handler via stored controller", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
let aborted = false;
|
||||
let resolver: () => void = () => undefined;
|
||||
const id = s.queue(
|
||||
(ctx) =>
|
||||
new Promise<void>((res) => {
|
||||
ctx.signal.addEventListener("abort", () => {
|
||||
aborted = true;
|
||||
});
|
||||
resolver = res;
|
||||
}),
|
||||
{ delayMs: 0 }
|
||||
);
|
||||
s.start();
|
||||
await h.advance(150);
|
||||
expect(s.cancel(id)).toBe(true);
|
||||
await Promise.resolve();
|
||||
resolver();
|
||||
await s.stop();
|
||||
expect(aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JobScheduler — retries + timeout", () => {
|
||||
it("retries a failing handler then completes", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
let attempts = 0;
|
||||
s.queue(
|
||||
async () => {
|
||||
attempts += 1;
|
||||
if (attempts < 3) throw new Error("retry me");
|
||||
},
|
||||
{ delayMs: 0, retries: 5, retryDelayMs: 50 }
|
||||
);
|
||||
s.start();
|
||||
await h.advance(2_000);
|
||||
await s.stop();
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
|
||||
it("times out a handler that never resolves", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
let timedOut = false;
|
||||
s.queue(
|
||||
(ctx) =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
ctx.signal.addEventListener("abort", () => {
|
||||
timedOut = true;
|
||||
reject(ctx.signal.reason);
|
||||
});
|
||||
}),
|
||||
{ delayMs: 0, timeout: 200 }
|
||||
);
|
||||
s.start();
|
||||
await h.advance(500);
|
||||
await s.stop();
|
||||
expect(timedOut).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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,36 @@
|
||||
import z from "zod";
|
||||
|
||||
export const JobSchedulerOptionsSchema = z
|
||||
.object({
|
||||
concurrency: z.number().int().min(1).max(10_000).optional(),
|
||||
clock: z.function().optional(),
|
||||
logger: z.unknown().optional(),
|
||||
catchupMissed: z.boolean().optional(),
|
||||
maxMissedRuns: z.number().int().min(1).max(10_000).optional(),
|
||||
tickIntervalMs: z.number().int().min(1).max(60_000).optional(),
|
||||
timers: z
|
||||
.object({
|
||||
setTimer: z.function(),
|
||||
clearTimer: z.function(),
|
||||
})
|
||||
.optional(),
|
||||
events: z.unknown().optional(),
|
||||
generateId: z.function().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const JobOptionsSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(256).optional(),
|
||||
name: z.string().min(1).max(256).optional(),
|
||||
retries: z.number().int().min(0).max(10_000).optional(),
|
||||
retryDelayMs: z.number().int().min(0).max(60_000).optional(),
|
||||
timeout: z.number().int().min(1).max(86_400_000).optional(),
|
||||
tz: z.string().min(1).max(64).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const QueueJobOptionsSchema = JobOptionsSchema.extend({
|
||||
delayMs: z.number().int().min(0).max(86_400_000).optional(),
|
||||
runAt: z.number().int().min(0).optional(),
|
||||
}).strict();
|
||||
@@ -0,0 +1,324 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
import type { ExecutionTimers } from "../execution/job-execution.js";
|
||||
import { createJobScheduler, JobScheduler } from "./job-scheduler.js";
|
||||
import type { JobSchedulerEventMap } from "./job-scheduler.types.js";
|
||||
|
||||
interface TimerState {
|
||||
now: number;
|
||||
nextId: number;
|
||||
pending: Map<number, { fireAt: number; cb: () => void }>;
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
state: TimerState;
|
||||
timers: ExecutionTimers;
|
||||
clock: () => number;
|
||||
advance: (delta: number) => Promise<void>;
|
||||
events: { type: string; payload: unknown }[];
|
||||
bus: EventBusLike<JobSchedulerEventMap>;
|
||||
logger: LoggerLike;
|
||||
}
|
||||
|
||||
function makeHarness(start = Date.UTC(2026, 4, 12, 10, 0, 0)): Harness {
|
||||
const state: TimerState = { now: start, nextId: 1, pending: new Map() };
|
||||
const timers: ExecutionTimers = {
|
||||
setTimer: (cb, ms) => {
|
||||
const id = state.nextId++;
|
||||
state.pending.set(id, { fireAt: state.now + ms, cb });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (h) => {
|
||||
state.pending.delete(h as number);
|
||||
},
|
||||
};
|
||||
const advance = async (delta: number): Promise<void> => {
|
||||
const target = state.now + delta;
|
||||
for (;;) {
|
||||
let id: number | undefined;
|
||||
let at = Number.POSITIVE_INFINITY;
|
||||
for (const [k, v] of state.pending) {
|
||||
if (v.fireAt <= target && v.fireAt < at) {
|
||||
id = k;
|
||||
at = v.fireAt;
|
||||
}
|
||||
}
|
||||
if (id === undefined) break;
|
||||
const entry = state.pending.get(id);
|
||||
if (entry === undefined) break;
|
||||
state.pending.delete(id);
|
||||
state.now = entry.fireAt;
|
||||
entry.cb();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
state.now = target;
|
||||
};
|
||||
const events: { type: string; payload: unknown }[] = [];
|
||||
const bus: EventBusLike<JobSchedulerEventMap> = {
|
||||
on: () => () => undefined,
|
||||
emit: (type, payload) => {
|
||||
events.push({ type: type as string, payload });
|
||||
},
|
||||
};
|
||||
const logger: LoggerLike = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
timers,
|
||||
clock: () => state.now,
|
||||
advance,
|
||||
events,
|
||||
bus,
|
||||
logger,
|
||||
};
|
||||
}
|
||||
|
||||
function makeScheduler(
|
||||
h: Harness,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): JobScheduler {
|
||||
let counter = 0;
|
||||
return new JobScheduler({
|
||||
clock: h.clock,
|
||||
timers: h.timers,
|
||||
events: h.bus,
|
||||
logger: h.logger,
|
||||
tickIntervalMs: 100,
|
||||
generateId: () => `id-${++counter}`,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("JobScheduler — queue", () => {
|
||||
it("runs a queued job after delayMs", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
let ran = false;
|
||||
s.queue(
|
||||
async () => {
|
||||
ran = true;
|
||||
},
|
||||
{ delayMs: 200 }
|
||||
);
|
||||
s.start();
|
||||
await h.advance(500);
|
||||
await s.stop();
|
||||
expect(ran).toBe(true);
|
||||
});
|
||||
|
||||
it("honours runAt absolute timestamp", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
let ran = false;
|
||||
s.queue(
|
||||
async () => {
|
||||
ran = true;
|
||||
},
|
||||
{ runAt: h.clock() + 300 }
|
||||
);
|
||||
s.start();
|
||||
await h.advance(500);
|
||||
await s.stop();
|
||||
expect(ran).toBe(true);
|
||||
});
|
||||
|
||||
it("respects concurrency cap", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h, { concurrency: 2 });
|
||||
let inFlight = 0;
|
||||
let maxSeen = 0;
|
||||
const finishers: Array<() => void> = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
s.queue(
|
||||
async () =>
|
||||
new Promise<void>((resolve) => {
|
||||
inFlight += 1;
|
||||
maxSeen = Math.max(maxSeen, inFlight);
|
||||
finishers.push(() => {
|
||||
inFlight -= 1;
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
{ delayMs: 0 }
|
||||
);
|
||||
}
|
||||
s.start();
|
||||
await h.advance(150);
|
||||
expect(maxSeen).toBeLessThanOrEqual(2);
|
||||
// Drain handlers in waves so additional jobs flow through the
|
||||
// concurrency gate.
|
||||
while (finishers.length > 0) {
|
||||
const f = finishers.shift();
|
||||
if (f !== undefined) f();
|
||||
await Promise.resolve();
|
||||
await h.advance(150);
|
||||
}
|
||||
await s.stop();
|
||||
expect(maxSeen).toBeLessThanOrEqual(2);
|
||||
expect(maxSeen).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("cancels a queued job before it runs", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
let ran = false;
|
||||
const id = s.queue(
|
||||
async () => {
|
||||
ran = true;
|
||||
},
|
||||
{ delayMs: 500 }
|
||||
);
|
||||
expect(s.cancel(id)).toBe(true);
|
||||
s.start();
|
||||
await h.advance(1_000);
|
||||
await s.stop();
|
||||
expect(ran).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when cancelling unknown id", () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
expect(s.cancel("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("garbage-collects queued jobs after they complete", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
s.queue(async () => undefined, { delayMs: 0 });
|
||||
s.start();
|
||||
await h.advance(300);
|
||||
await s.stop();
|
||||
expect(s.jobs()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JobScheduler — cron", () => {
|
||||
it("schedules a cron job and reports it via jobs()", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
const id = s.schedule("* * * * *", async () => undefined, {
|
||||
name: "tick",
|
||||
});
|
||||
const status = s.jobs().find((j) => j.id === id);
|
||||
expect(status).toBeDefined();
|
||||
expect(status?.cronExpr).toBe("* * * * *");
|
||||
expect(status?.name).toBe("tick");
|
||||
});
|
||||
|
||||
it("fires a cron job repeatedly", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
let runs = 0;
|
||||
s.schedule("* * * * *", async () => {
|
||||
runs += 1;
|
||||
});
|
||||
s.start();
|
||||
await h.advance(3 * 60_000 + 500);
|
||||
await s.stop();
|
||||
expect(runs).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("emits job:cancelled when cancelled mid-life", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
const id = s.schedule("* * * * *", async () => undefined);
|
||||
expect(s.cancel(id)).toBe(true);
|
||||
const cancelled = h.events.find((e) => e.type === "job:cancelled");
|
||||
expect(cancelled).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("JobScheduler — pause / resume", () => {
|
||||
it("globally pauses tick draining", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
let ran = 0;
|
||||
s.queue(
|
||||
async () => {
|
||||
ran += 1;
|
||||
},
|
||||
{ delayMs: 0 }
|
||||
);
|
||||
s.pause();
|
||||
s.start();
|
||||
await h.advance(500);
|
||||
expect(ran).toBe(0);
|
||||
s.resume();
|
||||
await h.advance(500);
|
||||
await s.stop();
|
||||
expect(ran).toBe(1);
|
||||
});
|
||||
|
||||
it("pauses a single job", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
let ran = 0;
|
||||
const id = s.queue(
|
||||
async () => {
|
||||
ran += 1;
|
||||
},
|
||||
{ delayMs: 100 }
|
||||
);
|
||||
s.pause(id);
|
||||
s.start();
|
||||
await h.advance(500);
|
||||
await s.stop();
|
||||
expect(ran).toBe(0);
|
||||
});
|
||||
|
||||
it("throws when pausing/resuming unknown id", () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
expect(() => s.pause("nope")).toThrow();
|
||||
expect(() => s.resume("nope")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("JobScheduler — lifecycle", () => {
|
||||
it("start is idempotent", () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
s.start();
|
||||
s.start();
|
||||
expect(() => s.start()).not.toThrow();
|
||||
});
|
||||
|
||||
it("stop drains in-flight handlers", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
let resolved = false;
|
||||
let resolver: () => void = () => undefined;
|
||||
s.queue(
|
||||
() =>
|
||||
new Promise<void>((res) => {
|
||||
resolver = () => {
|
||||
resolved = true;
|
||||
res();
|
||||
};
|
||||
}),
|
||||
{ delayMs: 0 }
|
||||
);
|
||||
s.start();
|
||||
await h.advance(150);
|
||||
const stopPromise = s.stop();
|
||||
// resolver fires after stop initiated
|
||||
resolver();
|
||||
await stopPromise;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it("stop is idempotent when never started", async () => {
|
||||
const h = makeHarness();
|
||||
const s = makeScheduler(h);
|
||||
await expect(s.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("createJobScheduler returns an instance", () => {
|
||||
expect(createJobScheduler()).toBeInstanceOf(JobScheduler);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { type CronExpression, parseCron } from "@suaveplan/cron";
|
||||
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
import type {
|
||||
ExecutionTimers,
|
||||
JobHandler,
|
||||
} from "../execution/job-execution.js";
|
||||
import { recordQueueDepth } from "../otel/job-scheduler-otel.js";
|
||||
import { JobQueue } from "../queue/job-queue.js";
|
||||
import {
|
||||
applyCatchupAndReenqueue,
|
||||
type CronEnv,
|
||||
reenqueueCron,
|
||||
} from "./cron-rescheduler.js";
|
||||
import { dispatch, type ScheduledJob } from "./job-dispatcher.js";
|
||||
import type {
|
||||
JobOptions,
|
||||
JobSchedulerEventMap,
|
||||
JobSchedulerOptions,
|
||||
JobStatus,
|
||||
QueueJobOptions,
|
||||
} from "./job-scheduler.types.js";
|
||||
import { snapshotJob } from "./job-snapshot.js";
|
||||
|
||||
const DEFAULT_CONCURRENCY = 5;
|
||||
const DEFAULT_TICK_INTERVAL_MS = 100;
|
||||
const DEFAULT_MAX_MISSED_RUNS = 1;
|
||||
const DEFAULT_TZ = "UTC";
|
||||
|
||||
function silentLogger(): LoggerLike {
|
||||
return {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultTimers(): ExecutionTimers {
|
||||
return {
|
||||
setTimer: (cb, ms) =>
|
||||
globalThis.setTimeout(cb, ms) as unknown as number,
|
||||
clearTimer: (h) => globalThis.clearTimeout(h as number),
|
||||
};
|
||||
}
|
||||
|
||||
function noopBus(): EventBusLike<JobSchedulerEventMap> {
|
||||
return { on: () => () => undefined, emit: () => undefined };
|
||||
}
|
||||
|
||||
/** In-process scheduler driving cron + queued jobs from a single tick loop. */
|
||||
export class JobScheduler {
|
||||
private readonly concurrency: number;
|
||||
private readonly clock: () => number;
|
||||
private readonly logger: LoggerLike;
|
||||
private readonly catchupMissed: boolean;
|
||||
private readonly maxMissedRuns: number;
|
||||
private readonly tickIntervalMs: number;
|
||||
private readonly timers: ExecutionTimers;
|
||||
private readonly events: EventBusLike<JobSchedulerEventMap>;
|
||||
private readonly generateId: () => string;
|
||||
private readonly store = new Map<string, ScheduledJob>();
|
||||
private readonly expressions = new Map<string, CronExpression>();
|
||||
private readonly queueIndex = new JobQueue();
|
||||
private readonly runningSet = new Set<string>();
|
||||
private readonly inFlight = new Set<Promise<void>>();
|
||||
private readonly cronEnv: CronEnv;
|
||||
private tickerHandle: number | undefined;
|
||||
private running = false;
|
||||
private globalPaused = false;
|
||||
|
||||
public constructor(options: JobSchedulerOptions = {}) {
|
||||
this.concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
|
||||
this.clock = options.clock ?? ((): number => Date.now());
|
||||
this.logger = options.logger ?? silentLogger();
|
||||
this.catchupMissed = options.catchupMissed ?? false;
|
||||
this.maxMissedRuns = options.maxMissedRuns ?? DEFAULT_MAX_MISSED_RUNS;
|
||||
this.tickIntervalMs =
|
||||
options.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS;
|
||||
this.timers = options.timers ?? defaultTimers();
|
||||
this.events = options.events ?? noopBus();
|
||||
this.generateId =
|
||||
options.generateId ??
|
||||
((): string => globalThis.crypto.randomUUID());
|
||||
this.cronEnv = {
|
||||
clock: this.clock,
|
||||
events: this.events,
|
||||
queue: this.queueIndex,
|
||||
expressionOf: (id) => this.expressions.get(id),
|
||||
};
|
||||
}
|
||||
|
||||
public schedule(
|
||||
cronExpr: string,
|
||||
handler: JobHandler,
|
||||
options: JobOptions = {}
|
||||
): string {
|
||||
const expression = parseCron(cronExpr);
|
||||
const id = options.id ?? this.generateId();
|
||||
const tz = options.tz ?? DEFAULT_TZ;
|
||||
const firstRun = expression.nextRun(this.clock(), tz);
|
||||
const job: ScheduledJob = {
|
||||
id,
|
||||
...(options.name !== undefined ? { name: options.name } : {}),
|
||||
kind: "cron",
|
||||
cronExpr,
|
||||
tz,
|
||||
handler,
|
||||
options,
|
||||
status: "pending",
|
||||
runs: 0,
|
||||
failures: 0,
|
||||
nextRun: firstRun,
|
||||
};
|
||||
this.store.set(id, job);
|
||||
this.expressions.set(id, expression);
|
||||
this.queueIndex.enqueue({ jobId: id, runAt: firstRun, cronExpr });
|
||||
return id;
|
||||
}
|
||||
|
||||
public queue(handler: JobHandler, options: QueueJobOptions = {}): string {
|
||||
const id = options.id ?? this.generateId();
|
||||
const runAt = options.runAt ?? this.clock() + (options.delayMs ?? 0);
|
||||
const job: ScheduledJob = {
|
||||
id,
|
||||
...(options.name !== undefined ? { name: options.name } : {}),
|
||||
kind: "queued",
|
||||
handler,
|
||||
options,
|
||||
status: "pending",
|
||||
runs: 0,
|
||||
failures: 0,
|
||||
nextRun: runAt,
|
||||
};
|
||||
this.store.set(id, job);
|
||||
this.queueIndex.enqueue({ jobId: id, runAt });
|
||||
return id;
|
||||
}
|
||||
|
||||
public cancel(jobId: string): boolean {
|
||||
const job = this.store.get(jobId);
|
||||
if (job === undefined) return false;
|
||||
job.status = "cancelled";
|
||||
this.queueIndex.remove(jobId);
|
||||
if (job.currentAbort !== undefined) {
|
||||
job.currentAbort.abort(new Error("cancelled"));
|
||||
}
|
||||
this.events.emit("job:cancelled", { jobId });
|
||||
return true;
|
||||
}
|
||||
|
||||
public pause(jobId?: string): void {
|
||||
if (jobId === undefined) {
|
||||
this.globalPaused = true;
|
||||
this.events.emit("job:paused", { jobId: null });
|
||||
return;
|
||||
}
|
||||
const job = this.store.get(jobId);
|
||||
if (job === undefined) {
|
||||
throw new Error(`pause: unknown jobId ${jobId}`);
|
||||
}
|
||||
if (job.status === "pending" || job.status === "running") {
|
||||
job.status = "paused";
|
||||
}
|
||||
this.events.emit("job:paused", { jobId });
|
||||
}
|
||||
|
||||
public resume(jobId?: string): void {
|
||||
if (jobId === undefined) {
|
||||
this.globalPaused = false;
|
||||
this.events.emit("job:resumed", { jobId: null });
|
||||
return;
|
||||
}
|
||||
const job = this.store.get(jobId);
|
||||
if (job === undefined) {
|
||||
throw new Error(`resume: unknown jobId ${jobId}`);
|
||||
}
|
||||
if (job.status === "paused") {
|
||||
job.status = "pending";
|
||||
this.scheduleAfterResume(job);
|
||||
}
|
||||
this.events.emit("job:resumed", { jobId });
|
||||
}
|
||||
|
||||
public jobs(): readonly JobStatus[] {
|
||||
const out: JobStatus[] = [];
|
||||
for (const job of this.store.values()) out.push(snapshotJob(job));
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.scheduleTick();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.running) return;
|
||||
this.running = false;
|
||||
// `start()` always assigns `tickerHandle` before returning, so it
|
||||
// is defined whenever `running === true` (the early-return above
|
||||
// guarantees we're in that state here). The non-null assertion is
|
||||
// safe by construction.
|
||||
this.timers.clearTimer(this.tickerHandle as number);
|
||||
this.tickerHandle = undefined;
|
||||
for (const job of this.store.values()) {
|
||||
if (job.currentAbort !== undefined) {
|
||||
job.currentAbort.abort(new Error("scheduler stopped"));
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(Array.from(this.inFlight));
|
||||
}
|
||||
|
||||
private scheduleTick(): void {
|
||||
this.tickerHandle = this.timers.setTimer(() => {
|
||||
this.tick();
|
||||
if (this.running) this.scheduleTick();
|
||||
}, this.tickIntervalMs);
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
recordQueueDepth(this.queueIndex.size());
|
||||
if (this.globalPaused) return;
|
||||
while (this.runningSet.size < this.concurrency) {
|
||||
const head = this.queueIndex.peek();
|
||||
if (head === undefined) break;
|
||||
if (head.runAt > this.clock()) break;
|
||||
// `peek` returned a non-undefined head, so dequeue cannot
|
||||
// return undefined here.
|
||||
const popped = this.queueIndex.dequeue() as NonNullable<
|
||||
ReturnType<JobQueue["dequeue"]>
|
||||
>;
|
||||
const job = this.store.get(popped.jobId);
|
||||
if (job === undefined) continue;
|
||||
if (job.status !== "pending") continue;
|
||||
dispatch(job, popped.runAt, {
|
||||
clock: this.clock,
|
||||
logger: this.logger,
|
||||
events: this.events,
|
||||
timers: this.timers,
|
||||
inFlight: this.inFlight,
|
||||
runningSet: this.runningSet,
|
||||
afterRun: (j) => this.afterRun(j),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private afterRun(job: ScheduledJob): void {
|
||||
if (job.status === "cancelled") return;
|
||||
if (job.kind === "queued") {
|
||||
this.store.delete(job.id);
|
||||
return;
|
||||
}
|
||||
job.status = "pending";
|
||||
reenqueueCron(job, this.cronEnv, job.tz ?? DEFAULT_TZ);
|
||||
}
|
||||
|
||||
private scheduleAfterResume(job: ScheduledJob): void {
|
||||
if (job.kind === "cron") {
|
||||
applyCatchupAndReenqueue(
|
||||
job,
|
||||
this.cronEnv,
|
||||
job.tz ?? DEFAULT_TZ,
|
||||
this.catchupMissed,
|
||||
this.maxMissedRuns
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Queued jobs always have `nextRun` set at enqueue time.
|
||||
this.queueIndex.enqueue({
|
||||
jobId: job.id,
|
||||
runAt: Math.max(job.nextRun as number, this.clock()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience factory matching the repo-wide primitive convention. */
|
||||
export function createJobScheduler(
|
||||
options?: JobSchedulerOptions
|
||||
): JobScheduler {
|
||||
return new JobScheduler(options);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
import type {
|
||||
ExecutionTimers,
|
||||
JobHandler,
|
||||
} from "../execution/job-execution.js";
|
||||
|
||||
/** Per-job options accepted by both `schedule` and `queue`. */
|
||||
export interface JobOptions {
|
||||
readonly id?: string;
|
||||
readonly name?: string;
|
||||
readonly retries?: number;
|
||||
readonly retryDelayMs?: number;
|
||||
readonly timeout?: number;
|
||||
readonly tz?: string;
|
||||
}
|
||||
|
||||
/** Options specific to one-shot `queue` jobs. */
|
||||
export interface QueueJobOptions extends JobOptions {
|
||||
readonly delayMs?: number;
|
||||
readonly runAt?: number;
|
||||
}
|
||||
|
||||
/** Constructor options for `JobScheduler`. */
|
||||
export interface JobSchedulerOptions {
|
||||
readonly concurrency?: number;
|
||||
readonly clock?: () => number;
|
||||
readonly logger?: LoggerLike;
|
||||
readonly catchupMissed?: boolean;
|
||||
readonly maxMissedRuns?: number;
|
||||
readonly tickIntervalMs?: number;
|
||||
readonly timers?: ExecutionTimers;
|
||||
readonly events?: EventBusLike<JobSchedulerEventMap>;
|
||||
readonly generateId?: () => string;
|
||||
}
|
||||
|
||||
/** Closed event-map emitted on the scheduler's bus. */
|
||||
export interface JobSchedulerEventMap extends Record<string, unknown> {
|
||||
"job:start": { jobId: string; attempt: number; scheduledAt: number };
|
||||
"job:complete": { jobId: string; attempt: number; durationMs: number };
|
||||
"job:fail": {
|
||||
jobId: string;
|
||||
attempt: number;
|
||||
durationMs: number;
|
||||
error: unknown;
|
||||
};
|
||||
"job:scheduling_exhausted": { jobId: string };
|
||||
"job:paused": { jobId: string | null };
|
||||
"job:resumed": { jobId: string | null };
|
||||
"job:cancelled": { jobId: string };
|
||||
}
|
||||
|
||||
/** Snapshot returned by `JobScheduler.jobs()`. */
|
||||
export interface JobStatus {
|
||||
readonly id: string;
|
||||
readonly name?: string;
|
||||
readonly cronExpr?: string;
|
||||
readonly status: "pending" | "running" | "paused" | "cancelled";
|
||||
readonly lastRun?: number;
|
||||
readonly nextRun?: number;
|
||||
readonly runs: number;
|
||||
readonly failures: number;
|
||||
}
|
||||
|
||||
/** Re-export the handler type so consumers have a single import. */
|
||||
export type { JobHandler };
|
||||
@@ -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.
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { ScheduledJob } from "./job-dispatcher.js";
|
||||
import { snapshotJob } from "./job-snapshot.js";
|
||||
|
||||
function base(): ScheduledJob {
|
||||
return {
|
||||
id: "abc",
|
||||
kind: "queued",
|
||||
handler: async () => undefined,
|
||||
options: {},
|
||||
status: "pending",
|
||||
runs: 0,
|
||||
failures: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("snapshotJob", () => {
|
||||
it("emits only required fields when optionals are undefined", () => {
|
||||
const snap = snapshotJob(base());
|
||||
expect(snap).toEqual({
|
||||
id: "abc",
|
||||
status: "pending",
|
||||
runs: 0,
|
||||
failures: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes optional fields when set", () => {
|
||||
const job: ScheduledJob = {
|
||||
...base(),
|
||||
name: "nightly",
|
||||
cronExpr: "0 0 * * *",
|
||||
lastRun: 100,
|
||||
nextRun: 200,
|
||||
runs: 5,
|
||||
failures: 1,
|
||||
status: "running",
|
||||
};
|
||||
const snap = snapshotJob(job);
|
||||
expect(snap.name).toBe("nightly");
|
||||
expect(snap.cronExpr).toBe("0 0 * * *");
|
||||
expect(snap.lastRun).toBe(100);
|
||||
expect(snap.nextRun).toBe(200);
|
||||
expect(snap.runs).toBe(5);
|
||||
expect(snap.failures).toBe(1);
|
||||
expect(snap.status).toBe("running");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ScheduledJob } from "./job-dispatcher.js";
|
||||
import type { JobStatus } from "./job-scheduler.types.js";
|
||||
|
||||
/**
|
||||
* Project a `ScheduledJob` (the scheduler's mutable internal record) to
|
||||
* a frozen `JobStatus` snapshot. Optional fields are conditionally
|
||||
* included so the shape matches `exactOptionalPropertyTypes`.
|
||||
*/
|
||||
export function snapshotJob(job: ScheduledJob): JobStatus {
|
||||
return {
|
||||
id: job.id,
|
||||
...(job.name !== undefined ? { name: job.name } : {}),
|
||||
...(job.cronExpr !== undefined ? { cronExpr: job.cronExpr } : {}),
|
||||
status: job.status,
|
||||
...(job.lastRun !== undefined ? { lastRun: job.lastRun } : {}),
|
||||
...(job.nextRun !== undefined ? { nextRun: job.nextRun } : {}),
|
||||
runs: job.runs,
|
||||
failures: job.failures,
|
||||
};
|
||||
}
|
||||
133
packages/foundation/flow/job-scheduler/tests/integration.test.ts
Normal file
133
packages/foundation/flow/job-scheduler/tests/integration.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
import type { ExecutionTimers } from "../src/execution/job-execution.js";
|
||||
import { createJobScheduler } from "../src/scheduler/job-scheduler.js";
|
||||
import type { JobSchedulerEventMap } from "../src/scheduler/job-scheduler.types.js";
|
||||
|
||||
interface TimerState {
|
||||
now: number;
|
||||
nextId: number;
|
||||
pending: Map<number, { fireAt: number; cb: () => void }>;
|
||||
}
|
||||
|
||||
function harness(start: number): {
|
||||
state: TimerState;
|
||||
timers: ExecutionTimers;
|
||||
advance: (delta: number) => Promise<void>;
|
||||
bus: EventBusLike<JobSchedulerEventMap>;
|
||||
logger: LoggerLike;
|
||||
} {
|
||||
const state: TimerState = { now: start, nextId: 1, pending: new Map() };
|
||||
const timers: ExecutionTimers = {
|
||||
setTimer: (cb, ms) => {
|
||||
const id = state.nextId++;
|
||||
state.pending.set(id, { fireAt: state.now + ms, cb });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (h) => {
|
||||
state.pending.delete(h as number);
|
||||
},
|
||||
};
|
||||
const advance = async (delta: number): Promise<void> => {
|
||||
const target = state.now + delta;
|
||||
for (;;) {
|
||||
let id: number | undefined;
|
||||
let at = Number.POSITIVE_INFINITY;
|
||||
for (const [k, v] of state.pending) {
|
||||
if (v.fireAt <= target && v.fireAt < at) {
|
||||
id = k;
|
||||
at = v.fireAt;
|
||||
}
|
||||
}
|
||||
if (id === undefined) break;
|
||||
const entry = state.pending.get(id);
|
||||
if (entry === undefined) break;
|
||||
state.pending.delete(id);
|
||||
state.now = entry.fireAt;
|
||||
entry.cb();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
state.now = target;
|
||||
};
|
||||
const bus: EventBusLike<JobSchedulerEventMap> = {
|
||||
on: () => () => undefined,
|
||||
emit: () => undefined,
|
||||
};
|
||||
const logger: LoggerLike = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
return { state, timers, advance, bus, logger };
|
||||
}
|
||||
|
||||
describe("integration: three cron jobs over 10 simulated minutes", () => {
|
||||
it("fires every-minute, every-2-minutes, every-5-minutes the expected counts", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const startAt = Date.UTC(2026, 4, 12, 9, 0, 0);
|
||||
const h = harness(startAt);
|
||||
const s = createJobScheduler({
|
||||
clock: () => h.state.now,
|
||||
timers: h.timers,
|
||||
events: h.bus,
|
||||
logger: h.logger,
|
||||
tickIntervalMs: 100,
|
||||
});
|
||||
const counts = { every1: 0, every2: 0, every5: 0 };
|
||||
s.schedule("* * * * *", async () => {
|
||||
counts.every1 += 1;
|
||||
});
|
||||
s.schedule("*/2 * * * *", async () => {
|
||||
counts.every2 += 1;
|
||||
});
|
||||
s.schedule("*/5 * * * *", async () => {
|
||||
counts.every5 += 1;
|
||||
});
|
||||
s.start();
|
||||
await h.advance(10 * 60_000 + 500);
|
||||
await s.stop();
|
||||
// Over a 10-minute window starting at HH:00:00.5, every-minute
|
||||
// fires 10 times (HH:01 through HH:10), every-2-minutes fires 5
|
||||
// times (02, 04, 06, 08, 10), every-5-minutes fires 2 times
|
||||
// (05, 10).
|
||||
expect(counts.every1).toBe(10);
|
||||
expect(counts.every2).toBe(5);
|
||||
expect(counts.every5).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration: stop() drains a mid-run job", () => {
|
||||
it("waits for a slow handler to finish before resolving", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const h = harness(Date.UTC(2026, 4, 12, 9, 0, 0));
|
||||
const s = createJobScheduler({
|
||||
clock: () => h.state.now,
|
||||
timers: h.timers,
|
||||
events: h.bus,
|
||||
logger: h.logger,
|
||||
tickIntervalMs: 50,
|
||||
});
|
||||
let resolved = false;
|
||||
let resolver: () => void = () => undefined;
|
||||
s.queue(
|
||||
() =>
|
||||
new Promise<void>((res) => {
|
||||
resolver = () => {
|
||||
resolved = true;
|
||||
res();
|
||||
};
|
||||
}),
|
||||
{ delayMs: 0 }
|
||||
);
|
||||
s.start();
|
||||
await h.advance(100);
|
||||
const stopP = s.stop();
|
||||
resolver();
|
||||
await stopP;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
});
|
||||
103
packages/foundation/flow/job-scheduler/tests/property.test.ts
Normal file
103
packages/foundation/flow/job-scheduler/tests/property.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { JobQueue } from "../src/queue/job-queue.js";
|
||||
|
||||
describe("property: JobQueue monotonic dequeue", () => {
|
||||
it("always returns entries in non-decreasing runAt order", {
|
||||
timeout: 30_000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
jobId: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
runAt: fc.integer({ min: 0, max: 1_000_000 }),
|
||||
}),
|
||||
{ minLength: 0, maxLength: 200 }
|
||||
),
|
||||
(entries) => {
|
||||
const q = new JobQueue();
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const e = entries[i] as {
|
||||
jobId: string;
|
||||
runAt: number;
|
||||
};
|
||||
q.enqueue({
|
||||
jobId: `${e.jobId}-${i}`,
|
||||
runAt: e.runAt,
|
||||
});
|
||||
}
|
||||
let prev = Number.NEGATIVE_INFINITY;
|
||||
while (q.size() > 0) {
|
||||
const out = q.dequeue();
|
||||
if (out === undefined) break;
|
||||
expect(out.runAt).toBeGreaterThanOrEqual(prev);
|
||||
prev = out.runAt;
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property: enqueue+remove leaves consistent heap", () => {
|
||||
it("after removing every inserted id, queue is empty", {
|
||||
timeout: 30_000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
jobId: fc.uuid(),
|
||||
runAt: fc.integer({ min: 0, max: 1_000_000 }),
|
||||
}),
|
||||
{ minLength: 0, maxLength: 100 }
|
||||
),
|
||||
(entries) => {
|
||||
const q = new JobQueue();
|
||||
const ids = new Set<string>();
|
||||
for (const e of entries) {
|
||||
if (ids.has(e.jobId)) continue;
|
||||
ids.add(e.jobId);
|
||||
q.enqueue({ jobId: e.jobId, runAt: e.runAt });
|
||||
}
|
||||
for (const id of ids) {
|
||||
expect(q.remove(id)).toBe(true);
|
||||
}
|
||||
expect(q.size()).toBe(0);
|
||||
expect(q.peek()).toBeUndefined();
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property: enqueue followed by N dequeues equals sorted input", () => {
|
||||
it("produces a sorted output prefix", { timeout: 30_000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.integer({ min: 0, max: 1_000_000 }), {
|
||||
minLength: 1,
|
||||
maxLength: 50,
|
||||
}),
|
||||
(runAts) => {
|
||||
const q = new JobQueue();
|
||||
runAts.forEach((runAt, i) => {
|
||||
q.enqueue({ jobId: `j-${i}`, runAt });
|
||||
});
|
||||
const got: number[] = [];
|
||||
while (q.size() > 0) {
|
||||
const e = q.dequeue();
|
||||
if (e === undefined) break;
|
||||
got.push(e.runAt);
|
||||
}
|
||||
const expected = [...runAts].sort((a, b) => a - b);
|
||||
expect(got).toEqual(expected);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
7
packages/foundation/flow/job-scheduler/tsconfig.lib.json
Normal file
7
packages/foundation/flow/job-scheduler/tsconfig.lib.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": { "rootDir": "./src", "outDir": "./dist", "lib": ["ES2022"], "types": ["node"] },
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
28
packages/foundation/flow/job-scheduler/vite.config.ts
Normal file
28
packages/foundation/flow/job-scheduler/vite.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/job-scheduler",
|
||||
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
|
||||
build: {
|
||||
outDir: "./dist", emptyOutDir: true, reportCompressedSize: true, sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
|
||||
external: [
|
||||
"@suaveplan/cron",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/logger",
|
||||
"@suaveplan/retry",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
17
packages/foundation/flow/job-scheduler/vitest.config.ts
Normal file
17
packages/foundation/flow/job-scheduler/vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false, environment: "node", pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul", reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.test.ts", "src/**/*.schemas.ts", "src/**/*.types.ts", "src/**/index.ts", "tests/**"],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user