Skip to content

Commit 7bc9e79

Browse files
baozhoutaoclaude
andauthored
fix(rest): make async import-job cancellation actually stop the worker (#2824) (#3069)
On a synchronous storage driver (better-sqlite3 / wasm fallback) every await in the import row loop resolves as a microtask, so a 50k-row import starved the Node event loop for minutes: the cancel route's HTTP handler (and every progress poll) never ran, the in-memory flag `shouldCancel` polls was never set, and the job finished `succeeded` with all rows written despite the user's cancel. - runImport now yields one macrotask at every progress boundary so pending I/O (cancel requests, progress polls) is serviced mid-import. - The worker's shouldCancel also reads the durable job row as a fallback, covering cancels accepted by another process or after a restart. - A late cancel wins the terminal state: the worker's final patch no longer overwrites the durable 'cancelled' with 'succeeded', and a job cancelled while still pending doesn't start at all. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1ab8752 commit 7bc9e79

5 files changed

Lines changed: 280 additions & 20 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(import): make async import-job cancellation actually stop the worker (#2824)
6+
7+
Cancelling a running async import used to have no effect on a synchronous
8+
storage driver (better-sqlite3 / wasm fallback): every `await` in the row
9+
loop resolved as a microtask, so a 50k-row import monopolized the Node event
10+
loop for minutes — the cancel route's HTTP handler (and every progress poll)
11+
could never run, so the in-memory flag `shouldCancel` polls was never set.
12+
The job then finished `succeeded` with all rows written despite the user's
13+
cancel.
14+
15+
Three-part fix:
16+
17+
- **`runImport` yields one macrotask at every progress boundary** (every
18+
`progressEvery` rows), so pending I/O — the cancel request, progress
19+
polls, any other traffic — gets serviced during a large import. This is
20+
the root-cause fix; it also unblocks progress polling for the wizard.
21+
- **The worker's `shouldCancel` now also reads the durable job row** as a
22+
fallback: a cancel accepted by another process (or after a restart
23+
dropped the in-memory flag) still stops the worker.
24+
- **A late cancel wins the terminal state**: the worker's final patch no
25+
longer overwrites the cancel route's durable `cancelled` with
26+
`succeeded`, and a job cancelled while still `pending` doesn't start at
27+
all. Counts stay truthful — they reflect what was actually written.

packages/rest/src/import-job-integration.test.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,9 @@ function makeRes() {
120120
return res;
121121
}
122122

123-
async function boot() {
123+
async function boot(decorateDriver?: (driver: any) => void) {
124124
const { driver } = makeMemoryDriver();
125+
decorateDriver?.(driver);
125126
const engine = new ObjectQL();
126127
engine.registerDriver(driver, true);
127128
await engine.init();
@@ -234,6 +235,96 @@ describe('async import job — real engine + protocol integration', () => {
234235
expect(c._status).toBe(404);
235236
});
236237

238+
// framework#2824 — cancelling a running job must actually stop the worker.
239+
it('cancels a running job mid-flight: the worker stops at the next checkpoint', async () => {
240+
const rows = Array.from({ length: 1000 }, (_, i) => ({ id: `mc${i}`, title: `t${i}` }));
241+
const created = await callCreate(ctx.create, { format: 'json', rows });
242+
const jobId = created._json.jobId;
243+
244+
const c = await callJob(ctx.cancel, jobId);
245+
expect(c._json).toMatchObject({ success: true });
246+
247+
const done = await waitForTerminal(ctx.progress, jobId);
248+
expect(done.status).toBe('cancelled');
249+
250+
// Let the background worker settle (progress stops moving), then prove it
251+
// really stopped early instead of importing all 1000 rows (#2824's bug).
252+
let settled = -1;
253+
for (let i = 0; i < 100; i++) {
254+
const r = await callJob(ctx.progress, jobId);
255+
const p = Number(r._json?.processed ?? 0);
256+
if (p === settled) break;
257+
settled = p;
258+
await new Promise((rr) => setTimeout(rr, 10));
259+
}
260+
expect(settled).toBeLessThan(1000);
261+
const written = await ctx.engine.find('task', { where: {} });
262+
expect(written.length).toBeLessThan(1000);
263+
});
264+
265+
// framework#2824 — a durable 'cancelled' written by another process (no
266+
// in-memory flag on this node) must stop the worker too.
267+
it('stops the worker when the job row is marked cancelled out-of-band', async () => {
268+
const rows = Array.from({ length: 1000 }, (_, i) => ({ id: `oc${i}`, title: `t${i}` }));
269+
const created = await callCreate(ctx.create, { format: 'json', rows });
270+
const jobId = created._json.jobId;
271+
272+
// Simulate a cancel accepted by a different node: write the durable row
273+
// directly, bypassing this server's cancel route and in-memory flag.
274+
await ctx.protocol.updateData({ object: 'sys_import_job', id: jobId, data: { status: 'cancelled' } });
275+
276+
const done = await waitForTerminal(ctx.progress, jobId);
277+
expect(done.status).toBe('cancelled');
278+
let settled = -1;
279+
for (let i = 0; i < 100; i++) {
280+
const r = await callJob(ctx.progress, jobId);
281+
const p = Number(r._json?.processed ?? 0);
282+
if (p === settled) break;
283+
settled = p;
284+
await new Promise((rr) => setTimeout(rr, 10));
285+
}
286+
expect(settled).toBeLessThan(1000);
287+
});
288+
289+
// framework#2824 — a cancel that lands too late to stop the loop must still
290+
// win the terminal state: the worker's final patch may not overwrite the
291+
// durable 'cancelled' with 'succeeded'.
292+
it('keeps the terminal state cancelled when the cancel lands after the last row', async () => {
293+
// Slow the task writes so the cancel deterministically arrives while the
294+
// job is running — but the 3-row job has no mid-loop checkpoint, so the
295+
// loop still completes every row before noticing.
296+
const slowCtx = await boot((driver) => {
297+
const bulkCreate = driver.bulkCreate.bind(driver);
298+
driver.bulkCreate = async (o: string, rows2: any[]) => {
299+
if (o === 'task') await new Promise((r) => setImmediate(r));
300+
return bulkCreate(o, rows2);
301+
};
302+
const create = driver.create.bind(driver);
303+
driver.create = async (o: string, data: any) => {
304+
if (o === 'task') await new Promise((r) => setImmediate(r));
305+
return create(o, data);
306+
};
307+
});
308+
const created = await callCreate(slowCtx.create, {
309+
format: 'json',
310+
rows: [{ id: 'lc1', title: 'a' }, { id: 'lc2', title: 'b' }, { id: 'lc3', title: 'c' }],
311+
});
312+
const jobId = created._json.jobId;
313+
const c = await callJob(slowCtx.cancel, jobId);
314+
expect(c._json).toMatchObject({ success: true });
315+
316+
const done = await waitForTerminal(slowCtx.progress, jobId);
317+
// The counts stay truthful (rows were written), but the user's cancel wins
318+
// the status — before the fix this flipped back to 'succeeded'.
319+
expect(done.status).toBe('cancelled');
320+
// Give the worker's final patch time to land, then re-check it did not
321+
// overwrite the status.
322+
await new Promise((r) => setTimeout(r, 50));
323+
const after = await callJob(slowCtx.progress, jobId);
324+
expect(after._json.status).toBe('cancelled');
325+
expect(after._json.processed).toBe(3);
326+
});
327+
237328
it('cancel on an already-finished job is a no-op success', async () => {
238329
const created = await callCreate(ctx.create, { format: 'json', rows: [{ id: 'k', title: 'x' }] });
239330
const jobId = created._json.jobId;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* runImport() cooperative cancellation (framework#2824).
5+
*
6+
* With a synchronous storage driver every `await` in the row loop resolves as
7+
* a microtask, so the loop used to monopolize the event loop for the whole
8+
* import — the HTTP cancel handler could never run, so the flag `shouldCancel`
9+
* polls was never set. The runner now yields one macrotask at every progress
10+
* boundary; these tests pin that behaviour by scheduling the cancel signal on
11+
* the macrotask queue (exactly where an HTTP handler lives) and asserting the
12+
* loop actually stops.
13+
*/
14+
15+
import { describe, it, expect, vi } from 'vitest';
16+
import { runImport, type ImportProtocolLike } from './import-runner';
17+
import type { ExportFieldMeta } from './export-format.js';
18+
19+
const metaMap = new Map<string, ExportFieldMeta>([
20+
['name', { name: 'name', type: 'text' }],
21+
]);
22+
23+
const baseOpts = {
24+
objectName: 'task',
25+
metaMap,
26+
writeMode: 'insert' as const,
27+
matchFields: [] as string[],
28+
dryRun: false,
29+
runAutomations: false,
30+
trimWhitespace: true,
31+
createMissingOptions: false,
32+
skipBlankMatchKey: false,
33+
};
34+
35+
function rowsOf(n: number): Array<Record<string, any>> {
36+
return Array.from({ length: n }, (_, i) => ({ name: `r${i}` }));
37+
}
38+
39+
/** Protocol whose writes resolve synchronously (microtask-only), like better-sqlite3. */
40+
function syncProtocol(): ImportProtocolLike {
41+
return {
42+
findData: vi.fn(async () => []),
43+
createData: vi.fn(async (args: { data: { name: string } }) => ({ id: `id_${args.data.name}` })),
44+
updateData: vi.fn(async () => ({})),
45+
createManyData: vi.fn(async (args: { records: any[] }) => ({
46+
records: args.records.map((r) => ({ id: `id_${r.name}`, ...r })),
47+
})),
48+
};
49+
}
50+
51+
describe('runImport — cooperative cancellation (framework#2824)', () => {
52+
it('yields the event loop at progress boundaries so a macrotask can set the cancel flag', async () => {
53+
let cancelRequested = false;
54+
// Simulates the cancel route: it can only run when the loop yields a
55+
// macrotask. Before the fix this callback fired after the import finished.
56+
setImmediate(() => { cancelRequested = true; });
57+
58+
const summary = await runImport({
59+
...baseOpts, p: syncProtocol(), rows: rowsOf(1000), progressEvery: 200,
60+
shouldCancel: () => cancelRequested,
61+
});
62+
63+
expect(summary.cancelled).toBe(true);
64+
expect(summary.processed).toBe(200); // stopped at the first checkpoint
65+
expect(summary.created).toBe(200); // rows written so far are reported truthfully
66+
});
67+
68+
it('runs to completion when nobody cancels', async () => {
69+
const summary = await runImport({
70+
...baseOpts, p: syncProtocol(), rows: rowsOf(450), progressEvery: 200,
71+
shouldCancel: () => false,
72+
});
73+
expect(summary.cancelled).toBe(false);
74+
expect(summary.processed).toBe(450);
75+
expect(summary.created).toBe(450);
76+
});
77+
78+
it('yields at progress boundaries even without a shouldCancel callback', async () => {
79+
// The synchronous import route passes no shouldCancel; the yield must still
80+
// happen so concurrent HTTP requests are serviced during a large import.
81+
let macrotaskRan = false;
82+
setImmediate(() => { macrotaskRan = true; });
83+
let observedMidLoop = false;
84+
85+
await runImport({
86+
...baseOpts, p: syncProtocol(), rows: rowsOf(400), progressEvery: 200,
87+
onProgress: (pr) => { if (pr.processed === 400) observedMidLoop = macrotaskRan; },
88+
});
89+
90+
// By the final progress report the pre-scheduled macrotask has run,
91+
// proving the loop reached the event loop's timer/check phases mid-import.
92+
expect(observedMidLoop).toBe(true);
93+
});
94+
});

packages/rest/src/import-runner.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,20 @@ function toFailedResult(rowNo: number, err: unknown): ImportRowResult {
137137
/** Upper bound on rows in one createManyData batch (framework#2678 suggests 100-500). */
138138
const MAX_CREATE_BATCH_SIZE = 200;
139139

140+
/**
141+
* Yield one macrotask so the host's event loop can service pending I/O.
142+
* With a synchronous storage driver (better-sqlite3 and the wasm fallback)
143+
* every `await` in the row loop resolves as a microtask, so a large import
144+
* otherwise monopolizes the event loop for its whole duration: HTTP cancel
145+
* and progress requests sit unserviced, and the cooperative `shouldCancel`
146+
* flag has nobody able to set it (framework#2824).
147+
*/
148+
const yieldToEventLoop = (): Promise<void> =>
149+
new Promise<void>((resolve) => {
150+
if (typeof setImmediate === 'function') setImmediate(resolve);
151+
else setTimeout(resolve, 0);
152+
});
153+
140154
export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
141155
const {
142156
p, objectName, environmentId, context, rows, metaMap,
@@ -367,8 +381,11 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
367381
await flushPendingCreates();
368382
if (onProgress) await onProgress(snapshot(processed));
369383
}
370-
if (shouldCancel && processed < rows.length && (processed % progressEvery === 0)) {
371-
if (await shouldCancel()) { cancelled = true; break; }
384+
if (processed < rows.length && processed % progressEvery === 0) {
385+
// Yield BEFORE polling the flag: a cancel request can only set it
386+
// once its HTTP handler gets event-loop time (framework#2824).
387+
await yieldToEventLoop();
388+
if (shouldCancel && (await shouldCancel())) { cancelled = true; break; }
372389
}
373390
}
374391

packages/rest/src/rest-server.ts

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3658,6 +3658,22 @@ export class RestServer {
36583658
// are registered inside registerDataActionEndpoints (before the greedy
36593659
// CRUD `:object/:id`), so the literal `import/jobs` segments win.
36603660

3661+
// Shared loader: fetch one job row by id. Used by the read routes, the
3662+
// cancel route, and the background worker's durable cancellation checks.
3663+
const loadImportJob = async (p: any, jobId: string, environmentId?: string, context?: any): Promise<any | undefined> => {
3664+
const r = await p.findData({
3665+
object: IMPORT_JOB_OBJECT,
3666+
query: { $filter: { id: jobId }, $top: 1 },
3667+
...(environmentId ? { environmentId } : {}),
3668+
...(context ? { context } : {}),
3669+
});
3670+
const rows = Array.isArray(r?.records) ? r.records
3671+
: Array.isArray(r?.data) ? r.data
3672+
: Array.isArray(r?.rows) ? r.rows
3673+
: Array.isArray(r) ? r : [];
3674+
return rows[0];
3675+
};
3676+
36613677
// POST /data/:object/import/jobs — create an async import job.
36623678
this.routeManager.register({
36633679
method: 'POST',
@@ -3731,6 +3747,13 @@ export class RestServer {
37313747
// import can be logically rolled back later.
37323748
const captureUndo = !prepared.dryRun && prepared.rows.length <= IMPORT_JOB_UNDO_MAX_ROWS;
37333749
void (async () => {
3750+
// Cancelled while still pending? Don't start (and don't let
3751+
// the 'running' patch below overwrite the durable 'cancelled').
3752+
if (this.cancelledImportJobs.has(jobId)) {
3753+
this.cancelledImportJobs.delete(jobId);
3754+
await patch({ status: 'cancelled', completed_at: new Date().toISOString() });
3755+
return;
3756+
}
37343757
await patch({ status: 'running', started_at: new Date().toISOString() });
37353758
try {
37363759
const summary = await runImport({
@@ -3744,10 +3767,33 @@ export class RestServer {
37443767
skipped_count: pr.skipped,
37453768
error_count: pr.errors,
37463769
}),
3747-
shouldCancel: () => this.cancelledImportJobs.has(jobId),
3770+
shouldCancel: async () => {
3771+
if (this.cancelledImportJobs.has(jobId)) return true;
3772+
// Durable fallback: the cancel route also writes
3773+
// status='cancelled' to the job row, so a cancel
3774+
// accepted by another process (or after a restart
3775+
// dropped the in-memory flag) still stops the worker.
3776+
try {
3777+
const row = await loadImportJob(p, jobId, environmentId, context);
3778+
return String(row?.status ?? '') === 'cancelled';
3779+
} catch { return false; }
3780+
},
37483781
});
3782+
// A cancel that lands after the last checkpoint must still
3783+
// win the terminal state: the cancel route already marked
3784+
// the durable row 'cancelled', and a late 'succeeded' here
3785+
// would silently overwrite it (framework#2824). Counts stay
3786+
// truthful either way — they reflect what was written.
3787+
let finalStatus = summary.cancelled ? 'cancelled' : 'succeeded';
3788+
if (finalStatus === 'succeeded' && this.cancelledImportJobs.has(jobId)) finalStatus = 'cancelled';
3789+
if (finalStatus === 'succeeded') {
3790+
try {
3791+
const row = await loadImportJob(p, jobId, environmentId, context);
3792+
if (String(row?.status ?? '') === 'cancelled') finalStatus = 'cancelled';
3793+
} catch { /* keep succeeded */ }
3794+
}
37493795
await patch({
3750-
status: summary.cancelled ? 'cancelled' : 'succeeded',
3796+
status: finalStatus,
37513797
processed_rows: summary.processed,
37523798
created_count: summary.created,
37533799
updated_count: summary.updated,
@@ -3778,21 +3824,6 @@ export class RestServer {
37783824
},
37793825
});
37803826

3781-
// Shared loader for the read routes: fetch one job row by id.
3782-
const loadImportJob = async (p: any, jobId: string, environmentId?: string, context?: any): Promise<any | undefined> => {
3783-
const r = await p.findData({
3784-
object: IMPORT_JOB_OBJECT,
3785-
query: { $filter: { id: jobId }, $top: 1 },
3786-
...(environmentId ? { environmentId } : {}),
3787-
...(context ? { context } : {}),
3788-
});
3789-
const rows = Array.isArray(r?.records) ? r.records
3790-
: Array.isArray(r?.data) ? r.data
3791-
: Array.isArray(r?.rows) ? r.rows
3792-
: Array.isArray(r) ? r : [];
3793-
return rows[0];
3794-
};
3795-
37963827
// POST /data/import/jobs/:jobId/cancel — request cancellation.
37973828
this.routeManager.register({
37983829
method: 'POST',

0 commit comments

Comments
 (0)