Skip to content

Commit 83fcd7b

Browse files
committed
fix(runner): retry cancellation version races
1 parent f4d32ca commit 83fcd7b

2 files changed

Lines changed: 73 additions & 26 deletions

File tree

‎packages/session-core/src/runner-jobs.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,26 @@ class StartRaceRunnerJobRepository extends MemoryRunnerJobRepository {
183183
}
184184
}
185185

186+
class CancelRaceRunnerJobRepository extends MemoryRunnerJobRepository {
187+
private injectEventUpdate = true;
188+
189+
override async save(job: RunnerJob, expectedVersion: number): Promise<void> {
190+
if (this.injectEventUpdate && job.status === "CANCELLED") {
191+
this.injectEventUpdate = false;
192+
const current = this.jobs.get(job.jobId);
193+
if (current === undefined) throw new Error("runner job disappeared");
194+
this.jobs.set(job.jobId, {
195+
...current,
196+
eventCursor: current.eventCursor + 1,
197+
jobVersion: current.jobVersion + 1,
198+
updatedAt: "2026-07-15T00:00:02.000Z",
199+
});
200+
throw new ConcurrentRunnerJobUpdateError(job.jobId);
201+
}
202+
await super.save(job, expectedVersion);
203+
}
204+
}
205+
186206
const HASH_A = "a".repeat(64);
187207
const HASH_B = "b".repeat(64);
188208
const HASH_C = "c".repeat(64);
@@ -334,6 +354,29 @@ describe("RunnerJobService", () => {
334354
);
335355
});
336356

357+
it("retries cancellation after a concurrent active job update", async () => {
358+
const harness = service(new CancelRaceRunnerJobRepository());
359+
const queued = await harness.service.createJob(jobInput());
360+
const starting = await harness.service.transition(
361+
queued.jobId,
362+
queued.jobVersion,
363+
"STARTING",
364+
{ runnerIdentity: "runner-container-test" },
365+
);
366+
367+
const cancelled = await harness.service.cancelJob(starting.jobId);
368+
369+
expect(cancelled).toMatchObject({
370+
status: "CANCELLED",
371+
eventCursor: 1,
372+
jobVersion: 4,
373+
error: {
374+
code: "RUNNER_JOB_CANCELLED",
375+
retryable: true,
376+
},
377+
});
378+
});
379+
337380
it("expires a stalled non-terminal job from its start deadline", async () => {
338381
const repository = new MemoryRunnerJobRepository();
339382
let now = new Date("2026-07-15T00:00:00.000Z");

‎packages/session-core/src/runner-jobs.ts‎

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ const REUSABLE_STARTED_JOB_STATUSES = new Set<RunnerJobStatus>([
195195
"REPAIRING",
196196
]);
197197
const START_CONFLICT_RETRY_LIMIT = 3;
198+
const CANCEL_CONFLICT_RETRY_LIMIT = 3;
198199
const CALLBACK_SETTLEMENT_GRACE_MS = 60_000;
199200

200201
function jobDeadline(job: RunnerJob): number {
@@ -358,34 +359,37 @@ export class RunnerJobService {
358359
}
359360

360361
async cancelJob(jobId: string): Promise<RunnerJob> {
361-
const current = await this.getJob(jobId);
362-
if (current.status === "CANCELLED") return current;
363-
if (TERMINAL_JOB_STATUSES.has(current.status)) {
364-
throw new RunnerCallbackStateError(
365-
`Runner job ${jobId} is already terminal with status ${current.status}`,
366-
);
367-
}
368-
try {
369-
return await this.transition(
370-
current.jobId,
371-
current.jobVersion,
372-
"CANCELLED",
373-
{
374-
runnerIdentity:
375-
current.runnerIdentity ?? "counterlab-control-plane-cancel",
376-
error: {
377-
code: "RUNNER_JOB_CANCELLED",
378-
message: "The learner cancelled this runner job.",
379-
retryable: true,
362+
let current = await this.getJob(jobId);
363+
let lastConflict: ConcurrentRunnerJobUpdateError | undefined;
364+
for (let attempt = 0; attempt < CANCEL_CONFLICT_RETRY_LIMIT; attempt += 1) {
365+
if (current.status === "CANCELLED") return current;
366+
if (TERMINAL_JOB_STATUSES.has(current.status)) {
367+
throw new RunnerCallbackStateError(
368+
`Runner job ${jobId} is already terminal with status ${current.status}`,
369+
);
370+
}
371+
try {
372+
return await this.transition(
373+
current.jobId,
374+
current.jobVersion,
375+
"CANCELLED",
376+
{
377+
runnerIdentity:
378+
current.runnerIdentity ?? "counterlab-control-plane-cancel",
379+
error: {
380+
code: "RUNNER_JOB_CANCELLED",
381+
message: "The learner cancelled this runner job.",
382+
retryable: true,
383+
},
380384
},
381-
},
382-
);
383-
} catch (error) {
384-
if (!(error instanceof ConcurrentRunnerJobUpdateError)) throw error;
385-
const updated = await this.getJob(jobId);
386-
if (updated.status === "CANCELLED") return updated;
387-
throw error;
385+
);
386+
} catch (error) {
387+
if (!(error instanceof ConcurrentRunnerJobUpdateError)) throw error;
388+
lastConflict = error;
389+
current = await this.getJob(jobId);
390+
}
388391
}
392+
throw lastConflict ?? new ConcurrentRunnerJobUpdateError(jobId);
389393
}
390394

391395
async expireIfTimedOut(jobId: string): Promise<RunnerJob> {

0 commit comments

Comments
 (0)