Skip to content

Commit 3029248

Browse files
committed
Fix task budget leak on critical error
1 parent 640a297 commit 3029248

2 files changed

Lines changed: 105 additions & 5 deletions

File tree

‎packages/core/src/autoscaling/autoscaled_pool.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -409,9 +409,10 @@ export class AutoscaledPool {
409409
return done();
410410
}
411411

412+
this.ownConcurrency++;
413+
412414
try {
413415
// Everything's fine. Run task.
414-
this.ownConcurrency++;
415416
// Try to run next task to build up concurrency,
416417
// but defer it so it doesn't create a cycle.
417418
setImmediate(this.maybeRunTask);
@@ -433,9 +434,8 @@ export class AutoscaledPool {
433434
}
434435

435436
this.log.perf('Task finished.');
436-
this.concurrencySystem.registerTaskEnd();
437-
this.ownConcurrency--;
438-
// Run task after the previous one finished.
437+
// Run task after the previous one finished. Only on success: a failed task rejects the pool, and
438+
// nudging the loop afterwards could start work on an already destroyed pool.
439439
setImmediate(this.maybeRunTask);
440440
} catch (e) {
441441
const err = e as Error;
@@ -451,6 +451,9 @@ export class AutoscaledPool {
451451
}
452452
this.reject(err);
453453
}
454+
} finally {
455+
this.concurrencySystem.registerTaskEnd();
456+
this.ownConcurrency--;
454457
}
455458

456459
return undefined;

‎test/core/autoscaling/autoscaled_pool.test.ts‎

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { AutoscaledPoolTaskLoopOptions, ConcurrencySystemOptions, LoadSignal, LoadSnapshot } from '@crawlee/core';
2-
import { AutoscaledPool, ConcurrencySystem } from '@crawlee/core';
2+
import { AutoscaledPool, ConcurrencySystem, CriticalError } from '@crawlee/core';
33
import { sleep } from '@crawlee/utils';
44

55
import log from '@apify/log';
@@ -345,6 +345,103 @@ describe('AutoscaledPool', () => {
345345
await expect(pool.run()).rejects.toThrow('some-runtask-error');
346346
});
347347

348+
test('and still returns the failed task’s slot to the governor', async () => {
349+
const pool = await makePool(
350+
{
351+
runTaskFunction: async () => {
352+
await sleep(1);
353+
throw new Error('some-runtask-error');
354+
},
355+
isFinishedFunction: async () => false,
356+
isTaskReadyFunction: async () => true,
357+
},
358+
{ minConcurrency: 1, maxConcurrency: 1, desiredConcurrency: 1 },
359+
);
360+
361+
await expect(pool.run()).rejects.toThrow('some-runtask-error');
362+
363+
// A slot leaked on the error path is never recovered - with a shared ConcurrencySystem it permanently
364+
// shrinks the budget of every other pool borrowing it.
365+
expect(systemOf(pool).currentConcurrency).toBe(0);
366+
expect(systemOf(pool).hasCapacityForTask()).toBe(true);
367+
});
368+
369+
test('and still returns the slot when the task throws a CriticalError', async () => {
370+
// CriticalError takes a different branch out of the task loop (it is deliberately not logged), which is
371+
// exactly where the slot used to leak.
372+
const pool = await makePool(
373+
{
374+
runTaskFunction: async () => {
375+
await sleep(1);
376+
throw new CriticalError('some-critical-error');
377+
},
378+
isFinishedFunction: async () => false,
379+
isTaskReadyFunction: async () => true,
380+
},
381+
{ minConcurrency: 1, maxConcurrency: 1, desiredConcurrency: 1 },
382+
);
383+
384+
await expect(pool.run()).rejects.toThrow('some-critical-error');
385+
386+
expect(pool.currentConcurrency).toBe(0);
387+
expect(systemOf(pool).currentConcurrency).toBe(0);
388+
});
389+
390+
test('and still returns the slot when the task times out', async () => {
391+
const pool = await makePool(
392+
{
393+
runTaskFunction: async () => sleep(1e3),
394+
taskTimeoutSecs: 0.05,
395+
isFinishedFunction: async () => false,
396+
isTaskReadyFunction: async () => true,
397+
},
398+
{ minConcurrency: 1, maxConcurrency: 1, desiredConcurrency: 1 },
399+
);
400+
401+
await expect(pool.run()).rejects.toThrow('runTaskFunction timed out');
402+
403+
// The abandoned task is left to its own devices, but its slot is not.
404+
expect(systemOf(pool).currentConcurrency).toBe(0);
405+
});
406+
407+
test('and a failing pool does not shrink a shared budget', async () => {
408+
const system = new ConcurrencySystem({ minConcurrency: 2, maxConcurrency: 2, desiredConcurrency: 2 });
409+
await system.start();
410+
onTestFinished(async () => system.stop());
411+
412+
const failing = new AutoscaledPool({
413+
concurrencySystem: system,
414+
runTaskFunction: async () => {
415+
await sleep(1);
416+
throw new Error('some-runtask-error');
417+
},
418+
isFinishedFunction: async () => false,
419+
isTaskReadyFunction: async () => true,
420+
});
421+
422+
await expect(failing.run()).rejects.toThrow('some-runtask-error');
423+
424+
// The survivor must still be able to reach the full shared concurrency of 2.
425+
let done = 0;
426+
let peak = 0;
427+
let current = 0;
428+
const survivor = new AutoscaledPool({
429+
concurrencySystem: system,
430+
runTaskFunction: async () => {
431+
current++;
432+
peak = Math.max(peak, current);
433+
await sleep(5);
434+
done++;
435+
current--;
436+
},
437+
isFinishedFunction: async () => done >= 10,
438+
isTaskReadyFunction: async () => done < 10,
439+
});
440+
441+
await survivor.run();
442+
expect(peak).toBe(2);
443+
});
444+
348445
test('when isFinishedFunction throws', async () => {
349446
let count = 0;
350447
const pool = await makePool(

0 commit comments

Comments
 (0)