Skip to content

Commit 94fe6e2

Browse files
authored
feat: inline and no-fork execution modes (#180)
* feat: add inline execution mode (runner: "inline") (#178) Adds a `runner` engine config option: - "thread" (default): the existing piscina worker-thread pool. - "inline": runs jobs in the current process/thread, with no pool. Introduces a `JobRunner` interface implemented by both the existing `RunnerPool` and a new `InlineRunner`; `ExecutorManager` picks the impl from config. The `runner()` function gains an `inline` flag to skip the redundant `Sidequest.configure` injection when the job runs in the host process. Inline mode trades CPU isolation and forcible timeout/cancellation (best-effort only) for single-process execution. Groundwork for the no-fork mode and the NestJS integration; also useful for serverless, tests, and SQLite. * feat: add no-fork execution mode (fork: false) (#179) Adds a `fork` engine config option: - true (default): the engine runs in a child_process.fork, as today. - false: the engine runs in the host process, no fork. Extracts the worker runtime (dispatcher loop + stale/cleanup cron routines) out of MainWorker into a shared WorkerRuntime, used both by the forked worker and by the in-process path. WorkerRuntime now also stops its cron tasks on shutdown, which the forked worker previously relied on process exit for. Engine.start() runs the runtime in-process when fork is false; Engine.close() tears it down. No-fork trades crash isolation (a job crash can take down the host) for in-process execution. Groundwork for the NestJS integration; also useful for serverless and tests. * feat: deliver timeout/cancel to jobs via this.abortSignal (#182) * feat: deliver timeout/cancel to jobs via this.abortSignal Adds an AbortSignal to the Job base class, available inside run() as `this.abortSignal`. The engine aborts it on timeout (reason: JobTimeout) and on cancellation (reason: JobCanceled), so jobs can stop cooperatively (`fetch(url, { signal: this.abortSignal })`, `throwIfAborted()`, etc.). In inline mode this is the only way a running job can be stopped, so timeout and cancel now work there cooperatively (superseding the previous approach of disabling cancel inline). On cancel the in-memory job is marked canceled so the terminal transition is skipped by shouldRun and does not overwrite the state. The executor now uses an AbortController instead of an EventEmitter and detects aborts via signal.aborted rather than matching the rejection message. Thread mode is unchanged: piscina still terminates the worker on abort. * refactor(engine): simplify abort handling in executor Use a single const AbortController (drop the | undefined and the alias) and guard the terminal transition with !controller.signal.aborted instead of mutating the in-memory job state, so a job that ignores the abort signal and runs to completion cannot overwrite the canceled/retry state. Adds a regression test for the ignored-signal case. * feat: cooperative abort in thread mode + abortGracePeriodMs (#183) * feat: cooperative abort in thread mode with configurable hard-kill grace Thread-mode jobs can now honor this.abortSignal too. On timeout/cancel the engine transfers a MessagePort to the worker and posts the abort (with its JobTimeout/JobCanceled reason); the worker turns that into the job's abortSignal. A new abortGracePeriodMs config controls how long to wait for the job to stop cooperatively before forcibly terminating the worker thread. abortGracePeriodMs defaults to 0, which preserves the current behavior: the worker is terminated immediately with no cooperative window and no port overhead. The port machinery is only set up when the grace period is positive. Inline is unaffected (no thread to terminate; grace does not apply). * test: cover the worker-side abort port path Adds a test that drives run() through the abortPort branch (thread mode): a real MessageChannel delivers an abort message, signalFromAbortPort rebuilds the JobTimeout reason, and the job observes it via this.abortSignal. Closes the gap where only the engine-side port posting and the inline signal path were tested. * style: wrap long assertion in executor-manager test (prettier) * fix: unify timeout/cancel handling; decide terminal state when the run settles (#184) * fix: in inline mode, let the job's result decide on timeout/cancel An inline run cannot be force-stopped, so eagerly applying a RetryTransition when the timeout fires (while the job keeps running) re-queued the job and could run a second copy concurrently. In inline mode the timeout/cancel now only signal via this.abortSignal; the terminal state is decided when the job actually settles: a job that honors the signal returns its own result, one that ignores it simply completes. Thread mode is unchanged (the worker is terminated, so the abort still decides). Also capture the timeout handle and clearTimeout on settle so a job that finishes before its timeout no longer leaves a dangling timer (it kept the event loop alive and retained job/backend refs until it fired). * refactor: address code-review follow-ups (robustness + cleanup) - executor-manager: guard the cancellation poll against a missing job row (watchedJob?.state) and attach a .catch so the polling loop can't die on an unhandled rejection; log the underlying rejection when a job is aborted so a real post-abort error is not silently lost. - core: move the abort-reason wire format and its (de)serialization into @sidequest/core (AbortReasonMessage + serializeAbortReason/deserializeAbortReason) so the encode (runner-pool) and decode (runner) no longer duplicate the mapping. - runner: replace the nested ternary for picking the abort signal with an explicit if-chain (the two inputs are mutually exclusive by construction). * refactor: unify timeout/cancel handling around the run's settle Replace the inline-vs-thread special-casing with a single rule that holds for every runner/grace combination: - The terminal transition is only applied once the run actually ends, so a still-running job is never re-queued underneath itself (fixes the double execution in inline and in thread with abortGracePeriodMs > 0). - If the job returned a state (resolved), respect it and transition to that state, even if a timeout/cancel was signaled. - If the run was hard-killed (rejected, no result), the abort reason decides: timeout -> retry, cancellation -> canceled. - A real error -> retry, as before. The timeout timer now only signals the abort; it no longer eagerly applies a retry. Removes the `inline` branch in execute(). * fix: abort a running job when its row is gone, not just when canceled (#185) The cancellation watcher polls the job row to detect cancellation. If the row no longer exists (deleted or truncated while the job runs), it could never observe "canceled", so a long-running, non-cooperative job was never aborted and blocked engine shutdown (executorManager.destroy waits for active jobs). Treat a missing row the same as a cancellation: abort the run so it stops and shutdown can proceed. Fixes the integration suite hang where a test truncates the DB and then stops Sidequest while a long TimeoutJob is still running. * docs: execution modes + cooperative timeout/cancellation (#186) * docs: document execution modes and cooperative timeout/cancellation Adds a new "Execution Modes" page covering the fork and runner options, the four fork/runner combinations and when to use each (serverless, tests, SQLite, framework integrations), abortGracePeriodMs, and the cooperative timeout/cancellation model via this.abortSignal. Includes disclaimers for the sharp edges: no-fork removes crash isolation; inline jobs block the event loop and cannot be force-stopped (so timeouts and cancellation only work if the job honors this.abortSignal); grace=0 gives no cooperative window in thread mode; canceling a running inline job is best-effort. Also documents this.abortSignal in the job control page, the new options in the configuration reference, and cross-links from how-it-works and the lifecycle page. * docs: drop references to the not-yet-released @sidequest/nestjs package * fix: tolerate a deleted job row when recording the terminal state (#187) When a job's row is deleted while it runs (cleanup routine, explicit delete, or a test truncating the table), the final transition's updateJob throws "Cannot update job, not found." Since execute() is fire-and-forget, that surfaced as an unhandled rejection that could crash and restart the engine fork (which in turn made scheduled jobs re-fire). Apply the terminal transition through a helper that swallows and logs such failures (recording the state of a job that no longer exists is safe to skip), and guard the dispatcher's fire-and-forget execute() with a .catch so a single job can never crash the engine. * fix: address PR #180 review feedback (#188) - executor: stop treating a missing job row as a cancellation in the watcher. Jobs are not deleted in normal operation; the integration test's mid-run truncate is handled by the resilient terminal transition instead. The test's long job is shortened so it cannot outlive teardown. - executor: on a hard-kill, default to a retry and only map to canceled when the abort reason is clearly a JobCanceled (failsafe for unknown abort reasons). - runner-pool: if the signal is already aborted on entry, reject without submitting the job to the pool instead of running it anyway. - docs: remove the duplicated crash-isolation and inline-abort danger callouts from the execution-modes page (the tables already convey them). * docs: document new run() params and JSDoc convention
1 parent 0f8b773 commit 94fe6e2

32 files changed

Lines changed: 1393 additions & 254 deletions

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ These are the things that aren't visually loud in either code or docs:
116116
- **Don't wrap things "just in case."** No backwards-compat shims, no feature flags for hypothetical use cases, no validation at internal boundaries.
117117
- **Match existing structure.** New engine concerns go under `packages/engine/src/<area>`; cross-cutting types belong in `@sidequest/core`. Don't create a new package for a small piece.
118118
- **Tests live next to the code.** `foo.ts` + `foo.test.ts` in the same folder; integration tests under `tests/integration/`. Backend changes must keep `@sidequest/backend-test` green for every driver.
119+
- **JSDoc every exported entity.** `CONTRIBUTING.md` requires JSDoc-style docstrings on all exports; match that when adding public API.
119120
- **Commits follow Conventional Commits** (commitlint + Husky enforce it). semantic-release publishes from `master`.
120121

121122
## Scope guard
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from "vitest";
2+
import { deserializeAbortReason, JobCanceled, JobTimeout, serializeAbortReason } from "./abort-reason";
3+
4+
describe("abort-reason", () => {
5+
it("round-trips a JobTimeout reason through the wire form", () => {
6+
const message = serializeAbortReason(new JobTimeout(1500));
7+
expect(message).toEqual({ kind: "timeout", timeoutMs: 1500 });
8+
9+
const reason = deserializeAbortReason(message);
10+
expect(reason).toBeInstanceOf(JobTimeout);
11+
expect((reason as JobTimeout).timeoutMs).toBe(1500);
12+
});
13+
14+
it("round-trips a JobCanceled reason through the wire form", () => {
15+
const message = serializeAbortReason(new JobCanceled());
16+
expect(message).toEqual({ kind: "canceled" });
17+
expect(deserializeAbortReason(message)).toBeInstanceOf(JobCanceled);
18+
});
19+
20+
it("treats any non-timeout reason as canceled", () => {
21+
expect(serializeAbortReason(new Error("boom"))).toEqual({ kind: "canceled" });
22+
expect(serializeAbortReason(undefined)).toEqual({ kind: "canceled" });
23+
});
24+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Reason set on a job's `abortSignal` when the engine aborts a running job.
3+
*
4+
* Inspect `job.abortSignal.reason` inside `run` to tell why the job is being aborted, e.g. to log
5+
* or clean up differently for a timeout vs an explicit cancellation.
6+
*/
7+
export type AbortReason = JobTimeout | JobCanceled;
8+
9+
/**
10+
* Set as the `abortSignal.reason` when a job is aborted because it exceeded its `timeout`.
11+
*/
12+
export class JobTimeout extends Error {
13+
/** The timeout, in milliseconds, that was exceeded. */
14+
readonly timeoutMs: number;
15+
16+
constructor(timeoutMs: number) {
17+
super(`Job timed out after ${timeoutMs}ms`);
18+
this.name = "JobTimeout";
19+
this.timeoutMs = timeoutMs;
20+
}
21+
}
22+
23+
/**
24+
* Set as the `abortSignal.reason` when a running job is aborted because it was canceled.
25+
*/
26+
export class JobCanceled extends Error {
27+
constructor() {
28+
super("Job was canceled");
29+
this.name = "JobCanceled";
30+
}
31+
}
32+
33+
/**
34+
* Structured-clone-safe wire form of an {@link AbortReason}, used to convey the reason to a job
35+
* running in a worker thread (a live {@link AbortSignal} cannot cross the thread boundary).
36+
*/
37+
export type AbortReasonMessage = { kind: "timeout"; timeoutMs: number } | { kind: "canceled" };
38+
39+
/**
40+
* Encodes an abort reason into its wire form. Anything that is not a {@link JobTimeout} is treated
41+
* as a cancellation.
42+
* @param reason The abort reason (typically `signal.reason`).
43+
*/
44+
export function serializeAbortReason(reason: unknown): AbortReasonMessage {
45+
return reason instanceof JobTimeout ? { kind: "timeout", timeoutMs: reason.timeoutMs } : { kind: "canceled" };
46+
}
47+
48+
/**
49+
* Rebuilds the proper {@link AbortReason} instance from its wire form.
50+
* @param message The wire-form message.
51+
*/
52+
export function deserializeAbortReason(message: AbortReasonMessage): AbortReason {
53+
return message.kind === "timeout" ? new JobTimeout(message.timeoutMs) : new JobCanceled();
54+
}

packages/core/src/job/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export * from "./abort-reason";
12
export * from "./job";

packages/core/src/job/job.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,21 @@ describe("job.ts", () => {
3737
expect(transition.result).toBe("foo bar");
3838
});
3939

40+
it("exposes a non-aborting abortSignal by default", () => {
41+
const job = new DummyJob();
42+
expect(job.abortSignal).toBeInstanceOf(AbortSignal);
43+
expect(job.abortSignal.aborted).toBe(false);
44+
});
45+
46+
it("injects the abort signal at runtime", () => {
47+
const job = new DummyJob();
48+
const controller = new AbortController();
49+
job.injectAbortSignal(controller.signal);
50+
expect(job.abortSignal).toBe(controller.signal);
51+
controller.abort();
52+
expect(job.abortSignal.aborted).toBe(true);
53+
});
54+
4055
it("creates a fail transition", () => {
4156
const job = new DummyJob();
4257
const transition = job.fail("error");

packages/core/src/job/job.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ export abstract class Job implements JobData {
7979
readonly backoff_strategy!: BackoffStrategy;
8080
readonly retry_delay!: number | null;
8181

82+
/**
83+
* Signal that fires when the engine aborts this run (timeout or cancellation). Available inside
84+
* `run`. Pass it to abort-aware APIs (e.g. `fetch(url, { signal: this.abortSignal })`) or check it
85+
* cooperatively (`this.abortSignal.throwIfAborted()`, `this.abortSignal.aborted`). The reason is a
86+
* `JobTimeout` or `JobCanceled` (see `abortSignal.reason`).
87+
*
88+
* In `runner: "inline"` mode this is the only way a running job can be stopped. In `"thread"` mode
89+
* the worker is also terminated, so honoring the signal is optional (but enables graceful cleanup).
90+
* Defaults to a signal that never aborts when no run is in progress.
91+
*/
92+
readonly abortSignal: AbortSignal = new AbortController().signal;
93+
8294
/**
8395
* Initializes the job and resolves its script path.
8496
*/
@@ -102,6 +114,14 @@ export abstract class Job implements JobData {
102114
Object.assign(this, jobData);
103115
}
104116

117+
/**
118+
* Injects the abort signal for this run into the job instance at runtime.
119+
* @param signal The abort signal the engine controls for this execution.
120+
*/
121+
injectAbortSignal(signal: AbortSignal): void {
122+
Object.assign(this, { abortSignal: signal });
123+
}
124+
105125
/**
106126
* The class name of this job.
107127
*/

packages/docs/.vitepress/config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export default defineConfig({
9999
collapsed: false,
100100
items: [
101101
{ text: "Backends", link: "/backends" },
102+
{ text: "Execution Modes", link: "/execution-modes" },
102103
{ text: "Graceful Shutdown", link: "/graceful-shutdown" },
103104
{ text: "Cleanup", link: "/cleanup" },
104105
{ text: "Manual Job Resolution", link: "/manual-resolution" },

0 commit comments

Comments
 (0)