Skip to content

Commit a8a71b4

Browse files
authored
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().
1 parent 8b75656 commit a8a71b4

7 files changed

Lines changed: 192 additions & 68 deletions

File tree

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+
});

packages/core/src/job/abort-reason.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,26 @@ export class JobCanceled extends Error {
2929
this.name = "JobCanceled";
3030
}
3131
}
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/engine/src/execution/executor-manager.test.ts

Lines changed: 99 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { sidequestTest, SidequestTestFixture } from "@/tests/fixture";
22
import { Backend } from "@sidequest/backend";
3-
import { CompletedResult, CompleteTransition, JobData, RetryTransition, RunTransition } from "@sidequest/core";
3+
import {
4+
CancelTransition,
5+
CompletedResult,
6+
CompleteTransition,
7+
JobData,
8+
RetryTransition,
9+
RunTransition,
10+
} from "@sidequest/core";
411
import { JobTransitioner } from "../job/job-transitioner";
512
import { grantQueueConfig } from "../queue/grant-queue-config";
613
import { DummyJob } from "../test-jobs/dummy-job";
@@ -94,6 +101,54 @@ describe("ExecutorManager", () => {
94101
inlineRunMock.mockReset();
95102
});
96103

104+
sidequestTest(
105+
"inline: a job that ignores the timeout completes instead of being retried",
106+
async ({ backend, config }) => {
107+
jobData = await backend.updateJob({ ...jobData, state: "claimed", claimed_at: new Date(), timeout: 20 });
108+
const queryConfig = await grantQueueConfig(backend, { name: "default", concurrency: 1 });
109+
const executorManager = new ExecutorManager(backend, { ...config, runner: "inline" });
110+
111+
// The job ignores the abort signal and completes after the timeout has already fired.
112+
inlineRunMock.mockImplementationOnce(
113+
() =>
114+
new Promise((resolve) =>
115+
setTimeout(() => resolve({ __is_job_transition__: true, type: "completed", result: "done" }), 60),
116+
),
117+
);
118+
119+
await executorManager.execute(queryConfig, jobData);
120+
121+
// Timeout fired (signal aborted) but inline applies the job's own result: completed, not a retry.
122+
// eslint-disable-next-line @typescript-eslint/unbound-method
123+
expect(JobTransitioner.apply).toHaveBeenCalledWith(backend, jobData, expect.any(CompleteTransition));
124+
// eslint-disable-next-line @typescript-eslint/unbound-method
125+
expect(JobTransitioner.apply).not.toHaveBeenCalledWith(backend, jobData, expect.any(RetryTransition));
126+
await executorManager.destroy();
127+
inlineRunMock.mockReset();
128+
},
129+
);
130+
131+
sidequestTest(
132+
"inline: a job that ignores cancellation completes (its result wins)",
133+
async ({ backend, config }) => {
134+
// Pre-cancel in the DB so the first cancellation poll observes it immediately. JobTransitioner is
135+
// mocked here, so the RunTransition does not overwrite the persisted state.
136+
jobData = await backend.updateJob({ ...jobData, state: "canceled" });
137+
const queryConfig = await grantQueueConfig(backend, { name: "default", concurrency: 1 });
138+
const executorManager = new ExecutorManager(backend, { ...config, runner: "inline" });
139+
140+
inlineRunMock.mockResolvedValue({ __is_job_transition__: true, type: "completed", result: "done" });
141+
142+
await executorManager.execute(queryConfig, jobData);
143+
144+
// Inline cannot force-stop the job; it completed, so its result is applied.
145+
// eslint-disable-next-line @typescript-eslint/unbound-method
146+
expect(JobTransitioner.apply).toHaveBeenCalledWith(backend, jobData, expect.any(CompleteTransition));
147+
await executorManager.destroy();
148+
inlineRunMock.mockReset();
149+
},
150+
);
151+
97152
sidequestTest("should abort job execution on job cancel", async ({ backend, config }) => {
98153
await backend.updateJob({ ...jobData, state: "claimed", claimed_at: new Date() });
99154

@@ -132,36 +187,54 @@ describe("ExecutorManager", () => {
132187
await executorManager.destroy();
133188
});
134189

135-
sidequestTest(
136-
"does not overwrite the canceled state when an aborted job ignores the signal",
137-
async ({ backend, config }) => {
138-
await backend.updateJob({ ...jobData, state: "claimed", claimed_at: new Date() });
190+
sidequestTest("respects a job's returned result even after a cancel was signaled", async ({ backend, config }) => {
191+
await backend.updateJob({ ...jobData, state: "claimed", claimed_at: new Date() });
139192

140-
const queryConfig = await grantQueueConfig(backend, { name: "default", concurrency: 1 });
141-
const executorManager = new ExecutorManager(backend, config);
142-
143-
// The job is canceled mid-run but ignores the abort signal and runs to completion.
144-
runMock.mockImplementationOnce(async (job: JobData, signal: AbortSignal) => {
145-
await backend.updateJob({ ...job, state: "canceled" });
146-
while (!signal.aborted) {
147-
await new Promise((r) => setTimeout(r, 50));
148-
}
149-
return { __is_job_transition__: true, type: "completed", result: "result" } as CompletedResult;
193+
const queryConfig = await grantQueueConfig(backend, { name: "default", concurrency: 1 });
194+
const executorManager = new ExecutorManager(backend, config);
195+
196+
// The job is canceled mid-run but ignores the abort signal and returns a completed result.
197+
runMock.mockImplementationOnce(async (job: JobData, signal: AbortSignal) => {
198+
await backend.updateJob({ ...job, state: "canceled" });
199+
while (!signal.aborted) {
200+
await new Promise((r) => setTimeout(r, 50));
201+
}
202+
return { __is_job_transition__: true, type: "completed", result: "result" } as CompletedResult;
203+
});
204+
205+
await executorManager.execute(queryConfig, jobData);
206+
207+
// The job returned a state, so it is respected: the completion is applied.
208+
// eslint-disable-next-line @typescript-eslint/unbound-method
209+
expect(JobTransitioner.apply).toHaveBeenCalledWith(backend, jobData, expect.any(CompleteTransition));
210+
211+
await executorManager.destroy();
212+
});
213+
214+
sidequestTest("a hard-killed canceled job transitions to canceled", async ({ backend, config }) => {
215+
await backend.updateJob({ ...jobData, state: "claimed", claimed_at: new Date() });
216+
217+
const queryConfig = await grantQueueConfig(backend, { name: "default", concurrency: 1 });
218+
const executorManager = new ExecutorManager(backend, config);
219+
220+
// The job is canceled and never produces a result (the worker is terminated -> the run rejects).
221+
runMock.mockImplementationOnce(async (job: JobData, signal: AbortSignal) => {
222+
await backend.updateJob({ ...job, state: "canceled" });
223+
return new Promise((_, reject) => {
224+
signal.addEventListener("abort", () => reject(new Error("The task has been aborted")));
150225
});
226+
});
151227

152-
await executorManager.execute(queryConfig, jobData);
228+
await executorManager.execute(queryConfig, jobData);
153229

154-
// The terminal completion transition must be skipped so the canceled state is preserved.
155-
// eslint-disable-next-line @typescript-eslint/unbound-method
156-
expect(JobTransitioner.apply).not.toHaveBeenCalledWith(
157-
backend,
158-
expect.anything(),
159-
expect.any(CompleteTransition),
160-
);
230+
// No result: the abort reason (canceled) decides the terminal state.
231+
// eslint-disable-next-line @typescript-eslint/unbound-method
232+
expect(JobTransitioner.apply).toHaveBeenCalledWith(backend, jobData, expect.any(CancelTransition));
233+
// eslint-disable-next-line @typescript-eslint/unbound-method
234+
expect(JobTransitioner.apply).not.toHaveBeenCalledWith(backend, jobData, expect.any(CompleteTransition));
161235

162-
await executorManager.destroy();
163-
},
164-
);
236+
await executorManager.destroy();
237+
});
165238

166239
sidequestTest("should abort job execution on timeout", async ({ backend, config }) => {
167240
jobData = await backend.updateJob({ ...jobData, state: "claimed", claimed_at: new Date(), timeout: 100 });

packages/engine/src/execution/executor-manager.ts

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Backend } from "@sidequest/backend";
22
import {
3+
CancelTransition,
34
JobCanceled,
45
JobData,
56
JobTimeout,
@@ -100,6 +101,7 @@ export class ExecutorManager {
100101
async execute(queueConfig: QueueConfig, job: JobData): Promise<void> {
101102
let isRunning = false;
102103
const controller = new AbortController();
104+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
103105
try {
104106
logger("Executor Manager").debug(`Submitting job ${job.id} for execution in queue ${queueConfig.name}`);
105107
// We call prepareJob here again to make sure the jobs are in the queues.
@@ -111,8 +113,10 @@ export class ExecutorManager {
111113
isRunning = true;
112114
const cancellationCheck = async () => {
113115
while (isRunning) {
116+
// The row can be missing transiently or if it was deleted; treat that as "not canceled"
117+
// rather than dereferencing undefined and crashing the polling loop.
114118
const watchedJob = await this.backend.getJob(job.id);
115-
if (watchedJob!.state === "canceled") {
119+
if (watchedJob?.state === "canceled") {
116120
logger("Executor Manager").debug(`Aborting job ${job.id}: canceled`);
117121
controller.abort(new JobCanceled());
118122
isRunning = false;
@@ -121,47 +125,50 @@ export class ExecutorManager {
121125
await new Promise((r) => setTimeout(r, 1000));
122126
}
123127
};
124-
void cancellationCheck();
128+
void cancellationCheck().catch((error) => {
129+
logger("Executor Manager").error(`Cancellation watcher for job ${job.id} failed:`, error);
130+
});
125131

126132
logger("Executor Manager").debug(`Running job ${job.id} in queue ${queueConfig.name}`);
127133

128134
const runPromise = this.jobRunner.run(job, controller.signal);
129135

130136
if (job.timeout) {
131-
void new Promise(() => {
132-
setTimeout(() => {
133-
logger("Executor Manager").debug(`Job ${job.id} timed out after ${job.timeout}ms, aborting.`);
134-
controller.abort(new JobTimeout(job.timeout!));
135-
void JobTransitioner.apply(this.backend, job, new RetryTransition(`Job timed out after ${job.timeout}ms`));
136-
}, job.timeout!);
137-
});
137+
// Only signal the abort here. The terminal transition is decided when the run actually ends
138+
// (resolve or reject) so a still-running job is never re-queued underneath itself.
139+
timeoutHandle = setTimeout(() => {
140+
logger("Executor Manager").debug(`Job ${job.id} timed out after ${job.timeout}ms, aborting.`);
141+
controller.abort(new JobTimeout(job.timeout!));
142+
}, job.timeout);
138143
}
139144

140145
const result = await runPromise;
141146

142147
isRunning = false;
143-
// If the job was aborted (canceled or timed out) the terminal state was already decided by that
144-
// path. Skip the terminal transition so a job that ignored the signal and ran to completion does
145-
// not overwrite the canceled/retry state.
146-
if (!controller.signal.aborted) {
147-
logger("Executor Manager").debug(`Job ${job.id} completed with result: ${inspect(result)}`);
148-
const transition = JobTransitionFactory.create(result);
149-
await JobTransitioner.apply(this.backend, job, transition);
150-
}
148+
// The job ran to a conclusion and returned a state (even if a timeout/cancel was signaled);
149+
// respect it and transition accordingly.
150+
logger("Executor Manager").debug(`Job ${job.id} settled with result: ${inspect(result)}`);
151+
await JobTransitioner.apply(this.backend, job, JobTransitionFactory.create(result));
151152
} catch (error: unknown) {
152153
isRunning = false;
153154
const err = error as Error;
154-
// The thread runner rejects the run when its worker is aborted. Detect that via the signal
155-
// rather than the rejection message (which varies). The terminal state was already set by the
156-
// timeout (retry) or cancellation (canceled) path, so there is nothing more to do here.
157155
if (controller.signal.aborted) {
158-
logger("Executor Manager").debug(`Job ${job.id} was aborted: ${String(controller.signal.reason)}`);
156+
// The run produced no result because the worker was hard-killed (thread). The abort reason
157+
// decides the terminal state: a timeout becomes a retry, anything else (cancellation) becomes
158+
// canceled. The rejection is logged so a real error during the abort is not lost.
159+
const reason: unknown = controller.signal.reason;
160+
logger("Executor Manager").debug(`Job ${job.id} was hard-killed (${String(reason)}): ${err.message}`);
161+
const transition = reason instanceof JobTimeout ? new RetryTransition(reason) : new CancelTransition();
162+
await JobTransitioner.apply(this.backend, job, transition);
159163
} else {
160164
logger("Executor Manager").error(`Unhandled error while executing job ${job.id}: ${err.message}`);
161165
await JobTransitioner.apply(this.backend, job, new RetryTransition(err));
162166
}
163167
} finally {
164168
isRunning = false;
169+
if (timeoutHandle) {
170+
clearTimeout(timeoutHandle);
171+
}
165172
this.activeByQueue[queueConfig.name].delete(job.id);
166173
this.activeJobs.delete(job.id);
167174
}

packages/engine/src/shared-runner/runner-pool.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { sidequestTest, SidequestTestFixture } from "@/tests/fixture";
2-
import { JobData, JobTimeout } from "@sidequest/core";
2+
import { AbortReasonMessage, JobData, JobTimeout } from "@sidequest/core";
33
import { MessagePort } from "node:worker_threads";
44
import { beforeEach, describe, expect, vi } from "vitest";
55
import { DummyJob } from "../test-jobs/dummy-job";
6-
import { AbortPortMessage } from "./runner";
76
import { RunnerPool } from "./runner-pool";
87

98
const piscinaMockInstance = {
@@ -71,8 +70,8 @@ describe("RunnerPool", () => {
7170
const controller = new AbortController();
7271
const runPromise = gracePool.run(jobData, controller.signal);
7372

74-
const message = new Promise<AbortPortMessage>((resolve) =>
75-
capturedPort!.once("message", (m: AbortPortMessage) => resolve(m)),
73+
const message = new Promise<AbortReasonMessage>((resolve) =>
74+
capturedPort!.once("message", (m: AbortReasonMessage) => resolve(m)),
7675
);
7776

7877
controller.abort(new JobTimeout(5000));

packages/engine/src/shared-runner/runner-pool.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { JobData, JobResult, JobTimeout, logger } from "@sidequest/core";
1+
import { JobData, JobResult, logger, serializeAbortReason } from "@sidequest/core";
22
import { MessageChannel } from "node:worker_threads";
33
import Piscina from "piscina";
44
import { DEFAULT_RUNNER_PATH } from "../constants";
55
import { NonNullableEngineConfig } from "../engine";
66
import { JobRunner } from "./job-runner";
7-
import type { AbortPortMessage } from "./runner";
87

98
/**
109
* A pool of worker threads for running jobs in parallel using Piscina.
@@ -56,10 +55,7 @@ export class RunnerPool implements JobRunner {
5655
let graceTimer: ReturnType<typeof setTimeout> | undefined;
5756

5857
const onAbort = () => {
59-
const reason: unknown = signal.reason;
60-
const message: AbortPortMessage =
61-
reason instanceof JobTimeout ? { kind: "timeout", timeoutMs: reason.timeoutMs } : { kind: "canceled" };
62-
channel.port1.postMessage(message);
58+
channel.port1.postMessage(serializeAbortReason(signal.reason));
6359
graceTimer = setTimeout(() => hardKill.abort(), grace);
6460
};
6561

0 commit comments

Comments
 (0)