From 187354e589ef3ec5d55934de4eae9850fc5309de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Fri, 25 Sep 2026 16:06:22 +0200 Subject: [PATCH 01/31] feat: resumable `start` and `call` implementions --- src/actor.ts | 120 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 111 insertions(+), 9 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 358549edfc..421546896a 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -18,6 +18,7 @@ import { rejectOperationInTransaction, RequestQueue, serviceLocator, + withDirectStorageAccess, } from '@crawlee/core'; import type { Awaitable, Dictionary, StorageBackend } from '@crawlee/types'; import { sleep } from '@crawlee/utils'; @@ -325,9 +326,18 @@ export interface Timeout { timeout?: number | 'inherit'; } -export interface CallOptions extends Omit, Token, Timeout {} -export interface StartOptions extends Omit, Token, Timeout {} -export interface CallTaskOptions extends Omit, Token, Timeout {} +export interface ChildRunOptions { + /** + * Local name for the child run. This serves as a way to identify the child run in case of a migration. + * The name must be unique across all Actors and tasks started by this run. + */ + runName?: string; +} + +export interface CallOptions extends Omit, Token, Timeout, ChildRunOptions {} +export interface StartOptions + extends Omit, Token, Timeout, ChildRunOptions {} +export interface CallTaskOptions extends Omit, Token, Timeout, ChildRunOptions {} export interface AbortOptions extends RunAbortOptions, Token { /** Exit with given status message */ @@ -438,6 +448,39 @@ export const EXIT_CODES = { ERROR_UNKNOWN: 92, }; +class ChildRunTracker { + private CHILD_RUN_TRACKER_KVS_KEY = 'CHILD_RUN_IDS'; + private childRunIds?: Promise>; + private lastWrite: Promise = Promise.resolve(); + + async set(runName: string, runId: string) { + const childRunIds = await this.load(); + childRunIds[runName] = runId; + + const write = this.lastWrite.then(async () => + withDirectStorageAccess(async () => { + const defaultStore = await KeyValueStore.open(); + await defaultStore.setValue(this.CHILD_RUN_TRACKER_KVS_KEY, childRunIds); + }), + ); + this.lastWrite = write.catch(() => {}); + await write; + } + + async get(runName: string) { + const childRunIds = await this.load(); + return childRunIds[runName]; + } + + private async load() { + this.childRunIds ??= KeyValueStore.open() + .then(async (defaultStore) => defaultStore.getValue>(this.CHILD_RUN_TRACKER_KVS_KEY)) + .then((storedChildIds) => storedChildIds ?? {}); + + return this.childRunIds; + } +} + /** * `Actor` class serves as an alternative approach to the static helpers exported from the package. It allows to pass configuration * that will be used on the instance methods. Environment variables will have precedence over this configuration. @@ -447,6 +490,11 @@ export class Actor { /** @internal */ static #instance?: Actor; + /** + * Tracks child runs of this Actor instance. + */ + #childRunTracker: ChildRunTracker; + /** * Configuration of this SDK instance (provided to its constructor). See {@apilink Configuration} for details. * @internal @@ -532,6 +580,7 @@ export class Actor { this.apifyClient = this.newClient(); this.eventManager = new PlatformEventManager(this.configuration); this.#chargingManager = new ChargingManager(this.configuration, this.apifyClient); + this.#childRunTracker = new ChildRunTracker(); } /** @@ -862,9 +911,24 @@ export class Actor { */ async call(actorId: string, input?: ActorInput, options: CallOptions = {}): Promise { const runTimeoutSecs = options.timeout === 'inherit' ? this.getRemainingTimeSecs() : options.timeout; - const { token, timeout: _timeout, ...rest } = options; + const { token, timeout: _timeout, runName, ...rest } = options; const client = token ? this.newClient({ token }) : this.apifyClient; - return client.actor(actorId).call(input, { ...rest, runTimeoutSecs }); + + if (!runName) return client.actor(actorId).call(input, { ...rest, runTimeoutSecs }); + + const { waitSecs, log, ...startOptions } = rest; + const { run, resumed } = await this.#startOrResumeChildRun(client, runName, async () => + client.actor(actorId).start(input, { ...startOptions, runTimeoutSecs }), + ); + + // The earlier part of a resumed run's log was already redirected before the migration. + const streamedLog = await client.run(run.id).getStreamedLog({ toLog: log, fromStart: !resumed }); + streamedLog?.start(); + try { + return await client.run(run.id).waitForFinish({ waitSecs }); + } finally { + await streamedLog?.stop(); + } } /** @@ -892,10 +956,15 @@ export class Actor { */ async start(actorId: string, input?: ActorInput, options: StartOptions = {}): Promise { const runTimeoutSecs = options.timeout === 'inherit' ? this.getRemainingTimeSecs() : options.timeout; - const { token, timeout: _timeout, ...rest } = options; + const { token, timeout: _timeout, runName, ...rest } = options; const client = token ? this.newClient({ token }) : this.apifyClient; - return client.actor(actorId).start(input, { ...rest, runTimeoutSecs }); + if (!runName) return client.actor(actorId).start(input, { ...rest, runTimeoutSecs }); + + const { run } = await this.#startOrResumeChildRun(client, runName, async () => + client.actor(actorId).start(input, { ...rest, runTimeoutSecs }), + ); + return run; } /** @@ -954,10 +1023,43 @@ export class Actor { */ async callTask(taskId: string, input?: Dictionary, options: CallTaskOptions = {}): Promise { const runTimeoutSecs = options.timeout === 'inherit' ? this.getRemainingTimeSecs() : options.timeout; - const { token, timeout: _timeout, ...rest } = options; + const { token, timeout: _timeout, runName, ...rest } = options; const client = token ? this.newClient({ token }) : this.apifyClient; - return client.task(taskId).call(input, { ...rest, runTimeoutSecs }); + if (!runName) return client.task(taskId).call(input, { ...rest, runTimeoutSecs }); + + const { waitSecs, ...startOptions } = rest; + const { run } = await this.#startOrResumeChildRun(client, runName, async () => + client.task(taskId).start(input, { ...startOptions, runTimeoutSecs }), + ); + return client.run(run.id).waitForFinish({ waitSecs }); + } + + /** + * Returns the child run tracked under `runName` if it is still in progress or has succeeded, + * otherwise starts a new one using `start` and tracks it under `runName`. + */ + async #startOrResumeChildRun( + client: ApifyClient, + runName: string, + start: () => Promise, + ): Promise<{ run: ClientActorRun; resumed: boolean }> { + const trackedRunId = await this.#childRunTracker.get(runName); + const trackedRun = trackedRunId ? await client.run(trackedRunId).get() : undefined; + + switch (trackedRun?.status) { + case 'SUCCEEDED': + case 'READY': + case 'RUNNING': + case 'TIMING-OUT': + case 'ABORTING': + return { run: trackedRun, resumed: true }; + default: { + const run = await start(); + await this.#childRunTracker.set(runName, run.id); + return { run, resumed: false }; + } + } } /** From 30114ede787593dc9d5d4d5addbd67478d814385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Wed, 30 Sep 2026 10:14:03 +0200 Subject: [PATCH 02/31] fix: do not resume child runs that are aborting or timing out --- src/actor.ts | 2 - test/apify/actor.test.ts | 165 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 2 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 421546896a..bddbc3490c 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -1051,8 +1051,6 @@ export class Actor { case 'SUCCEEDED': case 'READY': case 'RUNNING': - case 'TIMING-OUT': - case 'ABORTING': return { run: trackedRun, resumed: true }; default: { const run = await start(); diff --git a/test/apify/actor.test.ts b/test/apify/actor.test.ts index 47a3e1b78a..c7940de3fe 100644 --- a/test/apify/actor.test.ts +++ b/test/apify/actor.test.ts @@ -11,6 +11,7 @@ import { Actor, ActorInputError, Configuration, Dataset, KeyValueStore, ProxyCon import type { ActorRun, WebhookUpdateData } from 'apify-client'; import { ActorClient, ApifyClient, RunClient, TaskClient } from 'apify-client'; import { BasicCrawler } from 'crawlee'; +import type { MockInstance } from 'vitest'; import { ACT_JOB_STATUSES, @@ -1061,6 +1062,170 @@ describe('Actor', () => { }); }); + describe('child run tracking with `runName`', () => { + const { input, actId, taskId } = globalOptions; + const runName = 'child'; + const startedRun = { id: 'started-run', status: 'RUNNING' } as ActorRun; + const trackedRun = { id: 'tracked-run', status: 'RUNNING' } as ActorRun; + const finishedRun = { id: 'tracked-run', status: 'SUCCEEDED' } as ActorRun; + + let storage: MemoryStorageBackend; + let startSpy: MockInstance; + let taskStartSpy: MockInstance; + let getSpy: MockInstance; + let waitForFinishSpy: MockInstance; + let getStreamedLogSpy: MockInstance; + + const newActor = () => createIsolatedActor({ storageClient: storage }).actor; + + beforeEach(() => { + storage = new MemoryStorageBackend(); + startSpy = vitest.spyOn(ActorClient.prototype, 'start').mockResolvedValue(startedRun); + taskStartSpy = vitest.spyOn(TaskClient.prototype, 'start').mockResolvedValue(startedRun); + getSpy = vitest.spyOn(RunClient.prototype, 'get').mockResolvedValue(trackedRun); + waitForFinishSpy = vitest.spyOn(RunClient.prototype, 'waitForFinish').mockResolvedValue(finishedRun); + getStreamedLogSpy = vitest.spyOn(RunClient.prototype, 'getStreamedLog').mockResolvedValue(undefined); + }); + + test('start() without a run name does not track the run', async () => { + const actor = newActor(); + + await actor.start(actId, input); + + expect(startSpy).toBeCalledTimes(1); + expect(await (await actor.openKeyValueStore()).getValue('CHILD_RUN_IDS')).toBeNull(); + }); + + test('start() starts a new run and tracks it', async () => { + const actor = newActor(); + + const run = await actor.start(actId, input, { runName }); + + expect(run).toEqual(startedRun); + expect(startSpy).toBeCalledTimes(1); + expect(await (await actor.openKeyValueStore()).getValue('CHILD_RUN_IDS')).toEqual({ + [runName]: startedRun.id, + }); + }); + + test.each(['READY', 'RUNNING', 'SUCCEEDED'] as const)( + 'start() resumes a tracked run in the %s status after a restart', + async (status) => { + await newActor().start(actId, input, { runName }); + startSpy.mockClear(); + getSpy.mockResolvedValue({ ...trackedRun, status }); + + const run = await newActor().start(actId, input, { runName }); + + expect(startSpy).not.toBeCalled(); + expect(run).toEqual({ ...trackedRun, status }); + expect(getSpy).toBeCalledTimes(1); + }, + ); + + test.each(['FAILED', 'ABORTING', 'ABORTED', 'TIMING-OUT', 'TIMED-OUT'] as const)( + 'start() starts a new run when the tracked run is in the %s status', + async (status) => { + await newActor().start(actId, input, { runName }); + startSpy.mockClear(); + getSpy.mockResolvedValue({ ...trackedRun, status }); + const replacement = { id: 'replacement-run', status: 'RUNNING' } as ActorRun; + startSpy.mockResolvedValue(replacement); + + const actor = newActor(); + const run = await actor.start(actId, input, { runName }); + + expect(startSpy).toBeCalledTimes(1); + expect(run).toEqual(replacement); + expect(await (await actor.openKeyValueStore()).getValue('CHILD_RUN_IDS')).toEqual({ + [runName]: replacement.id, + }); + }, + ); + + test('start() starts a new run when the tracked run no longer exists', async () => { + await newActor().start(actId, input, { runName }); + startSpy.mockClear(); + getSpy.mockResolvedValue(undefined); + + await newActor().start(actId, input, { runName }); + + expect(startSpy).toBeCalledTimes(1); + }); + + test('start() tracks different run names independently', async () => { + const actor = newActor(); + startSpy.mockResolvedValueOnce({ ...startedRun, id: 'first' }).mockResolvedValueOnce({ + ...startedRun, + id: 'second', + }); + + await Promise.all([ + actor.start(actId, input, { runName: 'a' }), + actor.start(actId, input, { runName: 'b' }), + ]); + + expect(await (await actor.openKeyValueStore()).getValue('CHILD_RUN_IDS')).toEqual({ + a: 'first', + b: 'second', + }); + }); + + test('call() waits for a new run started with a run name', async () => { + const actor = newActor(); + + const run = await actor.call(actId, input, { runName, waitSecs: 10 }); + + expect(run).toEqual(finishedRun); + expect(startSpy).toBeCalledTimes(1); + expect(waitForFinishSpy).toBeCalledWith({ waitSecs: 10 }); + expect(getStreamedLogSpy).toBeCalledWith({ toLog: undefined, fromStart: true }); + }); + + test('call() waits for the tracked run instead of starting a new one', async () => { + await newActor().start(actId, input, { runName }); + startSpy.mockClear(); + + const run = await newActor().call(actId, input, { runName }); + + expect(run).toEqual(finishedRun); + expect(startSpy).not.toBeCalled(); + expect(getStreamedLogSpy).toBeCalledWith({ toLog: undefined, fromStart: false }); + }); + + test('call() stops the log stream when waiting for the run fails', async () => { + const streamedLog = { start: vitest.fn(), stop: vitest.fn().mockResolvedValue(undefined) }; + getStreamedLogSpy.mockResolvedValue(streamedLog as any); + waitForFinishSpy.mockRejectedValue(new Error('boom')); + + await expect(newActor().call(actId, input, { runName })).rejects.toThrow('boom'); + + expect(streamedLog.start).toBeCalledTimes(1); + expect(streamedLog.stop).toBeCalledTimes(1); + }); + + test('call() without a run name uses `ActorClient.call`', async () => { + const callSpy = vitest.spyOn(ActorClient.prototype, 'call').mockResolvedValue(finishedRun); + + await newActor().call(actId, input); + + expect(callSpy).toBeCalledTimes(1); + expect(startSpy).not.toBeCalled(); + }); + + test('callTask() resumes the tracked run instead of starting a new one', async () => { + await newActor().callTask(taskId, input, { runName }); + expect(taskStartSpy).toBeCalledTimes(1); + taskStartSpy.mockClear(); + + const run = await newActor().callTask(taskId, input, { runName, waitSecs: 5 }); + + expect(run).toEqual(finishedRun); + expect(taskStartSpy).not.toBeCalled(); + expect(waitForFinishSpy).toHaveBeenLastCalledWith({ waitSecs: 5 }); + }); + }); + // TODO we should remove the duplication if possible describe('Actor.metamorph()', () => { const { actId, runId, targetActorId, input, contentType, build } = globalOptions; From 075b9f75846d6e45f566ae10e1140cdc8fbfb107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Wed, 30 Sep 2026 10:18:27 +0200 Subject: [PATCH 03/31] refactor: move ChildRunTracker to its own file --- src/actor.ts | 35 +---------------------------------- src/child_run_tracker.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 34 deletions(-) create mode 100644 src/child_run_tracker.ts diff --git a/src/actor.ts b/src/actor.ts index bddbc3490c..36fc365d4b 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -18,7 +18,6 @@ import { rejectOperationInTransaction, RequestQueue, serviceLocator, - withDirectStorageAccess, } from '@crawlee/core'; import type { Awaitable, Dictionary, StorageBackend } from '@crawlee/types'; import { sleep } from '@crawlee/utils'; @@ -51,6 +50,7 @@ import { ApifyFileSystemStorageBackend } from './apify_file_system_storage_backe import type { RequestQueueAccessMode } from './apify_request_queue_backend.js'; import { ApifyStorageBackend } from './apify_storage_backend.js'; import type { ChargeOptions, ChargeResult } from './charging.js'; +import { ChildRunTracker } from './child_run_tracker.js'; import { ChargingManager, DEFAULT_DATASET_ITEM_EVENT } from './charging.js'; import { ChargingStorageBackend } from './charging_storage_backend.js'; import type { ConfigurationOptions } from './configuration.js'; @@ -448,39 +448,6 @@ export const EXIT_CODES = { ERROR_UNKNOWN: 92, }; -class ChildRunTracker { - private CHILD_RUN_TRACKER_KVS_KEY = 'CHILD_RUN_IDS'; - private childRunIds?: Promise>; - private lastWrite: Promise = Promise.resolve(); - - async set(runName: string, runId: string) { - const childRunIds = await this.load(); - childRunIds[runName] = runId; - - const write = this.lastWrite.then(async () => - withDirectStorageAccess(async () => { - const defaultStore = await KeyValueStore.open(); - await defaultStore.setValue(this.CHILD_RUN_TRACKER_KVS_KEY, childRunIds); - }), - ); - this.lastWrite = write.catch(() => {}); - await write; - } - - async get(runName: string) { - const childRunIds = await this.load(); - return childRunIds[runName]; - } - - private async load() { - this.childRunIds ??= KeyValueStore.open() - .then(async (defaultStore) => defaultStore.getValue>(this.CHILD_RUN_TRACKER_KVS_KEY)) - .then((storedChildIds) => storedChildIds ?? {}); - - return this.childRunIds; - } -} - /** * `Actor` class serves as an alternative approach to the static helpers exported from the package. It allows to pass configuration * that will be used on the instance methods. Environment variables will have precedence over this configuration. diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts new file mode 100644 index 0000000000..a9ddf99d23 --- /dev/null +++ b/src/child_run_tracker.ts @@ -0,0 +1,34 @@ +import { KeyValueStore, withDirectStorageAccess } from '@crawlee/core'; + +export class ChildRunTracker { + private CHILD_RUN_TRACKER_KVS_KEY = 'CHILD_RUN_IDS'; + private childRunIds?: Promise>; + private lastWrite: Promise = Promise.resolve(); + + async set(runName: string, runId: string) { + const childRunIds = await this.load(); + childRunIds[runName] = runId; + + const write = this.lastWrite.then(async () => + withDirectStorageAccess(async () => { + const defaultStore = await KeyValueStore.open(); + await defaultStore.setValue(this.CHILD_RUN_TRACKER_KVS_KEY, childRunIds); + }), + ); + this.lastWrite = write.catch(() => {}); + await write; + } + + async get(runName: string) { + const childRunIds = await this.load(); + return childRunIds[runName]; + } + + private async load() { + this.childRunIds ??= KeyValueStore.open() + .then(async (defaultStore) => defaultStore.getValue>(this.CHILD_RUN_TRACKER_KVS_KEY)) + .then((storedChildIds) => storedChildIds ?? {}); + + return this.childRunIds; + } +} From c3c0718b55b12bb8e990ebf8b30181ba70335a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Wed, 30 Sep 2026 10:35:09 +0200 Subject: [PATCH 04/31] feat: reject reusing a child run name for a different request in one process --- src/actor.ts | 27 ++++++++++++++++------- src/child_run_tracker.ts | 46 +++++++++++++++++++++++++++++++++++++++- test/apify/actor.test.ts | 43 +++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 9 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 36fc365d4b..798041016d 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -50,7 +50,7 @@ import { ApifyFileSystemStorageBackend } from './apify_file_system_storage_backe import type { RequestQueueAccessMode } from './apify_request_queue_backend.js'; import { ApifyStorageBackend } from './apify_storage_backend.js'; import type { ChargeOptions, ChargeResult } from './charging.js'; -import { ChildRunTracker } from './child_run_tracker.js'; +import { type ChildRunRequest, ChildRunTracker } from './child_run_tracker.js'; import { ChargingManager, DEFAULT_DATASET_ITEM_EVENT } from './charging.js'; import { ChargingStorageBackend } from './charging_storage_backend.js'; import type { ConfigurationOptions } from './configuration.js'; @@ -884,8 +884,11 @@ export class Actor { if (!runName) return client.actor(actorId).call(input, { ...rest, runTimeoutSecs }); const { waitSecs, log, ...startOptions } = rest; - const { run, resumed } = await this.#startOrResumeChildRun(client, runName, async () => - client.actor(actorId).start(input, { ...startOptions, runTimeoutSecs }), + const { run, resumed } = await this.#startOrResumeChildRun( + client, + runName, + { type: 'actor', id: actorId, input }, + async () => client.actor(actorId).start(input, { ...startOptions, runTimeoutSecs }), ); // The earlier part of a resumed run's log was already redirected before the migration. @@ -928,8 +931,11 @@ export class Actor { if (!runName) return client.actor(actorId).start(input, { ...rest, runTimeoutSecs }); - const { run } = await this.#startOrResumeChildRun(client, runName, async () => - client.actor(actorId).start(input, { ...rest, runTimeoutSecs }), + const { run } = await this.#startOrResumeChildRun( + client, + runName, + { type: 'actor', id: actorId, input }, + async () => client.actor(actorId).start(input, { ...rest, runTimeoutSecs }), ); return run; } @@ -996,8 +1002,11 @@ export class Actor { if (!runName) return client.task(taskId).call(input, { ...rest, runTimeoutSecs }); const { waitSecs, ...startOptions } = rest; - const { run } = await this.#startOrResumeChildRun(client, runName, async () => - client.task(taskId).start(input, { ...startOptions, runTimeoutSecs }), + const { run } = await this.#startOrResumeChildRun( + client, + runName, + { type: 'task', id: taskId, input }, + async () => client.task(taskId).start(input, { ...startOptions, runTimeoutSecs }), ); return client.run(run.id).waitForFinish({ waitSecs }); } @@ -1009,6 +1018,7 @@ export class Actor { async #startOrResumeChildRun( client: ApifyClient, runName: string, + request: ChildRunRequest, start: () => Promise, ): Promise<{ run: ClientActorRun; resumed: boolean }> { const trackedRunId = await this.#childRunTracker.get(runName); @@ -1018,10 +1028,11 @@ export class Actor { case 'SUCCEEDED': case 'READY': case 'RUNNING': + this.#childRunTracker.verifyRequest(runName, request); return { run: trackedRun, resumed: true }; default: { const run = await start(); - await this.#childRunTracker.set(runName, run.id); + await this.#childRunTracker.set(runName, run.id, request); return { run, resumed: false }; } } diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index a9ddf99d23..67261a347f 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -1,11 +1,55 @@ +import { createHash } from 'node:crypto'; + import { KeyValueStore, withDirectStorageAccess } from '@crawlee/core'; +export interface ChildRunRequest { + type: 'actor' | 'task'; + id: string; + input?: unknown; +} + +const sortKeys = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(sortKeys); + if (value === null || typeof value !== 'object') return value; + + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, nested]) => [key, sortKeys(nested)]), + ); +}; + +const checksumRequest = ({ type, id, input }: ChildRunRequest) => + createHash('sha256') + .update(JSON.stringify(sortKeys({ type, id, input })) ?? '') + .digest('hex'); + export class ChildRunTracker { private CHILD_RUN_TRACKER_KVS_KEY = 'CHILD_RUN_IDS'; private childRunIds?: Promise>; private lastWrite: Promise = Promise.resolve(); + private requestChecksums = new Map(); + + /** + * Throws if `runName` was already used by this process for a different Actor / task or input, + * as the earlier run would otherwise be silently returned in place of the requested one. + */ + verifyRequest(runName: string, request: ChildRunRequest) { + const checksum = checksumRequest(request); + const knownChecksum = this.requestChecksums.get(runName); + + if (knownChecksum && knownChecksum !== checksum) { + throw new Error( + `The run name "${runName}" was already used for a different Actor, task or input. Use a unique \`runName\` for each child run.`, + ); + } + + this.requestChecksums.set(runName, checksum); + } + + async set(runName: string, runId: string, request: ChildRunRequest) { + this.requestChecksums.set(runName, checksumRequest(request)); - async set(runName: string, runId: string) { const childRunIds = await this.load(); childRunIds[runName] = runId; diff --git a/test/apify/actor.test.ts b/test/apify/actor.test.ts index c7940de3fe..c744b1b572 100644 --- a/test/apify/actor.test.ts +++ b/test/apify/actor.test.ts @@ -1171,6 +1171,49 @@ describe('Actor', () => { }); }); + test('reusing a run name for a different Actor in the same process throws', async () => { + const actor = newActor(); + await actor.start(actId, input, { runName }); + + await expect(actor.start('other-actor', input, { runName })).rejects.toThrow(/already used/); + await expect(actor.callTask(taskId, input, { runName })).rejects.toThrow(/already used/); + expect(startSpy).toBeCalledTimes(1); + }); + + test('reusing a run name with a different input in the same process throws', async () => { + const actor = newActor(); + await actor.start(actId, input, { runName }); + + await expect(actor.start(actId, { foo: 'baz' }, { runName })).rejects.toThrow(/already used/); + }); + + test('reusing a run name with an equivalent input in the same process resumes the run', async () => { + const actor = newActor(); + await actor.start(actId, { a: 1, b: { c: 2, d: 3 } }, { runName }); + + await actor.start(actId, { b: { d: 3, c: 2 }, a: 1 }, { runName }); + + expect(startSpy).toBeCalledTimes(1); + }); + + test('reusing a run name with a different input is allowed after a restart', async () => { + await newActor().start(actId, input, { runName }); + + await newActor().start(actId, { foo: 'baz' }, { runName }); + + expect(startSpy).toBeCalledTimes(1); + }); + + test('reusing a run name with a different input is allowed when the tracked run is replaced', async () => { + const actor = newActor(); + await actor.start(actId, input, { runName }); + getSpy.mockResolvedValue({ ...trackedRun, status: 'FAILED' }); + + await actor.start(actId, { foo: 'baz' }, { runName }); + + expect(startSpy).toBeCalledTimes(2); + }); + test('call() waits for a new run started with a run name', async () => { const actor = newActor(); From 3162daa2b6c96b3ddd704f337987e7388247b852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Wed, 30 Sep 2026 10:37:28 +0200 Subject: [PATCH 05/31] feat: resurrect aborted child runs and expose tracked child runs via Actor.childRuns() --- src/actor.ts | 64 ++++++++++++++++++++---- src/child_run_tracker.ts | 91 ++++++++++++++++++++++++++++------ src/index.ts | 1 + test/apify/actor.test.ts | 104 +++++++++++++++++++++++++++++++-------- 4 files changed, 216 insertions(+), 44 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 798041016d..9b0b195013 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -27,6 +27,7 @@ import type { ActorStartOptions, ApifyClientOptions, RunAbortOptions, + RunResurrectOptions, TaskCallOptions, Webhook, WebhookEventType, @@ -50,7 +51,7 @@ import { ApifyFileSystemStorageBackend } from './apify_file_system_storage_backe import type { RequestQueueAccessMode } from './apify_request_queue_backend.js'; import { ApifyStorageBackend } from './apify_storage_backend.js'; import type { ChargeOptions, ChargeResult } from './charging.js'; -import { type ChildRunRequest, ChildRunTracker } from './child_run_tracker.js'; +import { type ChildRunRequest, ChildRunTracker, type TrackedChildRun } from './child_run_tracker.js'; import { ChargingManager, DEFAULT_DATASET_ITEM_EVENT } from './charging.js'; import { ChargingStorageBackend } from './charging_storage_backend.js'; import type { ConfigurationOptions } from './configuration.js'; @@ -326,6 +327,11 @@ export interface Timeout { timeout?: number | 'inherit'; } +const pickResurrectOptions = ( + { build, memory, maxItems, maxTotalChargeUsd, restartOnError }: Omit, + runTimeoutSecs?: number, +): RunResurrectOptions => ({ build, memory, runTimeoutSecs, maxItems, maxTotalChargeUsd, restartOnError }); + export interface ChildRunOptions { /** * Local name for the child run. This serves as a way to identify the child run in case of a migration. @@ -889,13 +895,16 @@ export class Actor { runName, { type: 'actor', id: actorId, input }, async () => client.actor(actorId).start(input, { ...startOptions, runTimeoutSecs }), + pickResurrectOptions(startOptions, runTimeoutSecs), ); // The earlier part of a resumed run's log was already redirected before the migration. const streamedLog = await client.run(run.id).getStreamedLog({ toLog: log, fromStart: !resumed }); streamedLog?.start(); try { - return await client.run(run.id).waitForFinish({ waitSecs }); + const finishedRun = await client.run(run.id).waitForFinish({ waitSecs }); + await this.#childRunTracker.update(runName, finishedRun); + return finishedRun; } finally { await streamedLog?.stop(); } @@ -936,6 +945,7 @@ export class Actor { runName, { type: 'actor', id: actorId, input }, async () => client.actor(actorId).start(input, { ...rest, runTimeoutSecs }), + pickResurrectOptions(rest, runTimeoutSecs), ); return run; } @@ -1007,37 +1017,62 @@ export class Actor { runName, { type: 'task', id: taskId, input }, async () => client.task(taskId).start(input, { ...startOptions, runTimeoutSecs }), + pickResurrectOptions(startOptions, runTimeoutSecs), ); - return client.run(run.id).waitForFinish({ waitSecs }); + const finishedRun = await client.run(run.id).waitForFinish({ waitSecs }); + await this.#childRunTracker.update(runName, finishedRun); + return finishedRun; } /** - * Returns the child run tracked under `runName` if it is still in progress or has succeeded, - * otherwise starts a new one using `start` and tracks it under `runName`. + * Returns the child run tracked under `runName` if it is still in progress or has succeeded, and resurrects it + * if it was aborted or timed out. Otherwise (the run failed or is gone), starts a new one using `start` + * and tracks it under `runName`, keeping the previous one in the history. */ async #startOrResumeChildRun( client: ApifyClient, runName: string, request: ChildRunRequest, start: () => Promise, + resurrectOptions: RunResurrectOptions, ): Promise<{ run: ClientActorRun; resumed: boolean }> { - const trackedRunId = await this.#childRunTracker.get(runName); - const trackedRun = trackedRunId ? await client.run(trackedRunId).get() : undefined; + const tracked = await this.#childRunTracker.get(runName); + const trackedRun = tracked ? await client.run(tracked.runId).get() : undefined; switch (trackedRun?.status) { case 'SUCCEEDED': case 'READY': - case 'RUNNING': + case 'RUNNING': { this.#childRunTracker.verifyRequest(runName, request); + await this.#childRunTracker.update(runName, trackedRun); return { run: trackedRun, resumed: true }; + } + case 'ABORTED': + case 'TIMED-OUT': { + this.#childRunTracker.verifyRequest(runName, request); + const run = await client.run(trackedRun.id).resurrect(resurrectOptions); + await this.#childRunTracker.update(runName, run); + return { run, resumed: true }; + } default: { const run = await start(); - await this.#childRunTracker.set(runName, run.id, request); + await this.#childRunTracker.track(runName, run, request, trackedRun?.status ?? 'LOST'); return { run, resumed: false }; } } } + /** + * Returns the child runs started with the `runName` option, keyed by that name, including the runs + * that were replaced under the same name in `history`. + * + * The statuses are the last ones this Actor observed: a run nobody waits for is not refreshed. + * The same record is stored in the default key-value store under the `CHILD_RUNS` key. + */ + async childRuns(): Promise> { + return this.#childRunTracker.getAll(); + } + /** * Transforms this Actor run to an Actor run of a given Actor. The system stops the current container and starts * the new container instead. All the default storages are preserved and the new input is stored under the `INPUT-METAMORPH-1` key @@ -2015,6 +2050,17 @@ export class Actor { return Actor.getDefaultInstance().start(actorId, input, options); } + /** + * Returns the child runs started with the `runName` option, keyed by that name, including the runs + * that were replaced under the same name in `history`. + * + * The statuses are the last ones this Actor observed: a run nobody waits for is not refreshed. + * The same record is stored in the default key-value store under the `CHILD_RUNS` key. + */ + static async childRuns(): Promise> { + return Actor.getDefaultInstance().childRuns(); + } + /** * Aborts given Actor run on the Apify platform using the current user account (determined by the `APIFY_TOKEN` environment variable). * diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index 67261a347f..7edcc5918e 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -1,6 +1,22 @@ import { createHash } from 'node:crypto'; import { KeyValueStore, withDirectStorageAccess } from '@crawlee/core'; +import type { ActorRun } from 'apify-client'; + +/** `LOST` marks a tracked run that the platform no longer returns. */ +export type ChildRunStatus = ActorRun['status'] | 'LOST'; + +export interface ChildRunInfo { + runId: string; + status: ChildRunStatus; + /** ISO 8601 timestamp. */ + startedAt: string; +} + +export interface TrackedChildRun extends ChildRunInfo { + /** Earlier runs that were replaced under the same name, oldest first. */ + history: ChildRunInfo[]; +} export interface ChildRunRequest { type: 'actor' | 'task'; @@ -24,9 +40,15 @@ const checksumRequest = ({ type, id, input }: ChildRunRequest) => .update(JSON.stringify(sortKeys({ type, id, input })) ?? '') .digest('hex'); +const toInfo = (run: ActorRun): ChildRunInfo => ({ + runId: run.id, + status: run.status, + startedAt: run.startedAt.toISOString(), +}); + export class ChildRunTracker { - private CHILD_RUN_TRACKER_KVS_KEY = 'CHILD_RUN_IDS'; - private childRunIds?: Promise>; + private CHILD_RUN_TRACKER_KVS_KEY = 'CHILD_RUNS'; + private trackedRuns?: Promise>; private lastWrite: Promise = Promise.resolve(); private requestChecksums = new Map(); @@ -47,32 +69,71 @@ export class ChildRunTracker { this.requestChecksums.set(runName, checksum); } - async set(runName: string, runId: string, request: ChildRunRequest) { + /** + * Tracks `run` as the current run under `runName`. A run tracked earlier under the same name is moved to the + * history, with `replacedStatus` as its last known status. + */ + async track(runName: string, run: ActorRun, request: ChildRunRequest, replacedStatus: ChildRunStatus) { this.requestChecksums.set(runName, checksumRequest(request)); - const childRunIds = await this.load(); - childRunIds[runName] = runId; + const trackedRuns = await this.load(); + const previous = trackedRuns[runName]; + + trackedRuns[runName] = { + ...toInfo(run), + history: previous + ? [ + ...previous.history, + { runId: previous.runId, startedAt: previous.startedAt, status: replacedStatus }, + ] + : [], + }; + + await this.persist(); + } + + /** + * Records the latest observed state of the run tracked under `runName`. + */ + async update(runName: string, run: ActorRun) { + const trackedRuns = await this.load(); + const tracked = trackedRuns[runName]; + + if (tracked?.runId !== run.id || tracked.status === run.status) return; + + tracked.status = run.status; + await this.persist(); + } + + async get(runName: string) { + const trackedRuns = await this.load(); + return trackedRuns[runName]; + } + + async getAll() { + return structuredClone(await this.load()); + } + + private async persist() { + const trackedRuns = await this.load(); const write = this.lastWrite.then(async () => withDirectStorageAccess(async () => { const defaultStore = await KeyValueStore.open(); - await defaultStore.setValue(this.CHILD_RUN_TRACKER_KVS_KEY, childRunIds); + await defaultStore.setValue(this.CHILD_RUN_TRACKER_KVS_KEY, trackedRuns); }), ); this.lastWrite = write.catch(() => {}); await write; } - async get(runName: string) { - const childRunIds = await this.load(); - return childRunIds[runName]; - } - private async load() { - this.childRunIds ??= KeyValueStore.open() - .then(async (defaultStore) => defaultStore.getValue>(this.CHILD_RUN_TRACKER_KVS_KEY)) - .then((storedChildIds) => storedChildIds ?? {}); + this.trackedRuns ??= KeyValueStore.open() + .then(async (defaultStore) => + defaultStore.getValue>(this.CHILD_RUN_TRACKER_KVS_KEY), + ) + .then((storedRuns) => storedRuns ?? {}); - return this.childRunIds; + return this.trackedRuns; } } diff --git a/src/index.ts b/src/index.ts index 8e728ec08f..a4187f13f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export { type UrlPatternRequestOptions, } from './enqueue_links_filters.js'; export type { OpenStorageOptions, StorageAlias, StorageId, StorageName, StorageIdentifier } from './storage.js'; +export type { ChildRunInfo, ChildRunStatus, TrackedChildRun } from './child_run_tracker.js'; export { ChargeOptions, ChargeResult, ActorPricingInfo, ChargingManager } from './charging.js'; export * from './configuration.js'; export * from './proxy_configuration.js'; diff --git a/test/apify/actor.test.ts b/test/apify/actor.test.ts index c744b1b572..60939335ca 100644 --- a/test/apify/actor.test.ts +++ b/test/apify/actor.test.ts @@ -1065,9 +1065,11 @@ describe('Actor', () => { describe('child run tracking with `runName`', () => { const { input, actId, taskId } = globalOptions; const runName = 'child'; - const startedRun = { id: 'started-run', status: 'RUNNING' } as ActorRun; - const trackedRun = { id: 'tracked-run', status: 'RUNNING' } as ActorRun; - const finishedRun = { id: 'tracked-run', status: 'SUCCEEDED' } as ActorRun; + const startedAt = new Date('2026-01-01T00:00:00.000Z'); + const startedRun = { id: 'child-run', status: 'RUNNING', startedAt } as ActorRun; + const trackedRun = startedRun; + const finishedRun = { ...startedRun, status: 'SUCCEEDED' } as ActorRun; + const trackedInfo = { runId: startedRun.id, status: 'RUNNING', startedAt: startedAt.toISOString() }; let storage: MemoryStorageBackend; let startSpy: MockInstance; @@ -1075,6 +1077,7 @@ describe('Actor', () => { let getSpy: MockInstance; let waitForFinishSpy: MockInstance; let getStreamedLogSpy: MockInstance; + let resurrectSpy: MockInstance; const newActor = () => createIsolatedActor({ storageClient: storage }).actor; @@ -1085,6 +1088,7 @@ describe('Actor', () => { getSpy = vitest.spyOn(RunClient.prototype, 'get').mockResolvedValue(trackedRun); waitForFinishSpy = vitest.spyOn(RunClient.prototype, 'waitForFinish').mockResolvedValue(finishedRun); getStreamedLogSpy = vitest.spyOn(RunClient.prototype, 'getStreamedLog').mockResolvedValue(undefined); + resurrectSpy = vitest.spyOn(RunClient.prototype, 'resurrect'); }); test('start() without a run name does not track the run', async () => { @@ -1093,7 +1097,7 @@ describe('Actor', () => { await actor.start(actId, input); expect(startSpy).toBeCalledTimes(1); - expect(await (await actor.openKeyValueStore()).getValue('CHILD_RUN_IDS')).toBeNull(); + expect(await (await actor.openKeyValueStore()).getValue('CHILD_RUNS')).toBeNull(); }); test('start() starts a new run and tracks it', async () => { @@ -1103,8 +1107,8 @@ describe('Actor', () => { expect(run).toEqual(startedRun); expect(startSpy).toBeCalledTimes(1); - expect(await (await actor.openKeyValueStore()).getValue('CHILD_RUN_IDS')).toEqual({ - [runName]: startedRun.id, + expect(await (await actor.openKeyValueStore()).getValue('CHILD_RUNS')).toEqual({ + [runName]: { ...trackedInfo, history: [] }, }); }); @@ -1115,42 +1119,99 @@ describe('Actor', () => { startSpy.mockClear(); getSpy.mockResolvedValue({ ...trackedRun, status }); - const run = await newActor().start(actId, input, { runName }); + const actor = newActor(); + const run = await actor.start(actId, input, { runName }); expect(startSpy).not.toBeCalled(); expect(run).toEqual({ ...trackedRun, status }); expect(getSpy).toBeCalledTimes(1); + expect((await actor.childRuns())[runName]).toEqual({ ...trackedInfo, status, history: [] }); + }, + ); + + test.each(['ABORTED', 'TIMED-OUT'] as const)( + 'start() resurrects a tracked run in the %s status', + async (status) => { + await newActor().start(actId, input, { runName }); + startSpy.mockClear(); + getSpy.mockResolvedValue({ ...trackedRun, status }); + resurrectSpy.mockResolvedValue({ ...trackedRun, status: 'READY' }); + + const actor = newActor(); + const run = await actor.start(actId, input, { runName, memory: 512, timeout: 30 }); + + expect(startSpy).not.toBeCalled(); + expect(resurrectSpy).toBeCalledTimes(1); + expect(resurrectSpy).toBeCalledWith(expect.objectContaining({ memory: 512, runTimeoutSecs: 30 })); + expect(run.status).toBe('READY'); + expect((await actor.childRuns())[runName]).toEqual({ ...trackedInfo, status: 'READY', history: [] }); }, ); - test.each(['FAILED', 'ABORTING', 'ABORTED', 'TIMING-OUT', 'TIMED-OUT'] as const)( - 'start() starts a new run when the tracked run is in the %s status', + test.each(['FAILED', 'ABORTING', 'TIMING-OUT'] as const)( + 'start() starts a new run when the tracked run is in the %s status and keeps the old one in the history', async (status) => { await newActor().start(actId, input, { runName }); startSpy.mockClear(); getSpy.mockResolvedValue({ ...trackedRun, status }); - const replacement = { id: 'replacement-run', status: 'RUNNING' } as ActorRun; + const replacement = { id: 'replacement-run', status: 'RUNNING', startedAt } as ActorRun; startSpy.mockResolvedValue(replacement); const actor = newActor(); const run = await actor.start(actId, input, { runName }); expect(startSpy).toBeCalledTimes(1); + expect(resurrectSpy).not.toBeCalled(); expect(run).toEqual(replacement); - expect(await (await actor.openKeyValueStore()).getValue('CHILD_RUN_IDS')).toEqual({ - [runName]: replacement.id, + expect(await (await actor.openKeyValueStore()).getValue('CHILD_RUNS')).toEqual({ + [runName]: { + ...trackedInfo, + runId: replacement.id, + history: [{ ...trackedInfo, status }], + }, }); }, ); - test('start() starts a new run when the tracked run no longer exists', async () => { + test('start() starts a new run when the tracked run no longer exists and records the old one as lost', async () => { await newActor().start(actId, input, { runName }); startSpy.mockClear(); getSpy.mockResolvedValue(undefined); + startSpy.mockResolvedValue({ ...startedRun, id: 'replacement-run' }); - await newActor().start(actId, input, { runName }); + const actor = newActor(); + await actor.start(actId, input, { runName }); expect(startSpy).toBeCalledTimes(1); + expect((await actor.childRuns())[runName].history).toEqual([{ ...trackedInfo, status: 'LOST' }]); + }); + + test('repeated replacements accumulate in the history, oldest first', async () => { + const actor = newActor(); + await actor.start(actId, input, { runName }); + getSpy.mockResolvedValue({ ...trackedRun, status: 'FAILED' }); + startSpy.mockResolvedValueOnce({ ...startedRun, id: 'second' }); + await actor.start(actId, input, { runName }); + getSpy.mockResolvedValue({ ...trackedRun, id: 'second', status: 'FAILED' }); + startSpy.mockResolvedValueOnce({ ...startedRun, id: 'third' }); + await actor.start(actId, input, { runName }); + + const tracked = (await actor.childRuns())[runName]; + expect(tracked.runId).toBe('third'); + expect(tracked.history.map(({ runId, status }) => [runId, status])).toEqual([ + [startedRun.id, 'FAILED'], + ['second', 'FAILED'], + ]); + }); + + test('childRuns() is empty without tracked runs and returns a copy', async () => { + const actor = newActor(); + expect(await actor.childRuns()).toEqual({}); + + await actor.start(actId, input, { runName }); + (await actor.childRuns())[runName].status = 'FAILED'; + + expect((await actor.childRuns())[runName].status).toBe('RUNNING'); }); test('start() tracks different run names independently', async () => { @@ -1165,10 +1226,9 @@ describe('Actor', () => { actor.start(actId, input, { runName: 'b' }), ]); - expect(await (await actor.openKeyValueStore()).getValue('CHILD_RUN_IDS')).toEqual({ - a: 'first', - b: 'second', - }); + expect( + Object.fromEntries(Object.entries(await actor.childRuns()).map(([name, { runId }]) => [name, runId])), + ).toEqual({ a: 'first', b: 'second' }); }); test('reusing a run name for a different Actor in the same process throws', async () => { @@ -1229,11 +1289,13 @@ describe('Actor', () => { await newActor().start(actId, input, { runName }); startSpy.mockClear(); - const run = await newActor().call(actId, input, { runName }); + const actor = newActor(); + const run = await actor.call(actId, input, { runName }); expect(run).toEqual(finishedRun); expect(startSpy).not.toBeCalled(); expect(getStreamedLogSpy).toBeCalledWith({ toLog: undefined, fromStart: false }); + expect((await actor.childRuns())[runName].status).toBe('SUCCEEDED'); }); test('call() stops the log stream when waiting for the run fails', async () => { @@ -1261,11 +1323,13 @@ describe('Actor', () => { expect(taskStartSpy).toBeCalledTimes(1); taskStartSpy.mockClear(); - const run = await newActor().callTask(taskId, input, { runName, waitSecs: 5 }); + const actor = newActor(); + const run = await actor.callTask(taskId, input, { runName, waitSecs: 5 }); expect(run).toEqual(finishedRun); expect(taskStartSpy).not.toBeCalled(); expect(waitForFinishSpy).toHaveBeenLastCalledWith({ waitSecs: 5 }); + expect((await actor.childRuns())[runName].status).toBe('SUCCEEDED'); }); }); From 81815f257d8b2416b36f0349c2193f093e86b437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Wed, 30 Sep 2026 11:15:42 +0200 Subject: [PATCH 06/31] refactor: start a new child run instead of resurrecting aborted or timed out ones --- src/actor.ts | 21 ++------------------- test/apify/actor.test.ts | 24 +----------------------- 2 files changed, 3 insertions(+), 42 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 9b0b195013..f4046922c6 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -27,7 +27,6 @@ import type { ActorStartOptions, ApifyClientOptions, RunAbortOptions, - RunResurrectOptions, TaskCallOptions, Webhook, WebhookEventType, @@ -327,11 +326,6 @@ export interface Timeout { timeout?: number | 'inherit'; } -const pickResurrectOptions = ( - { build, memory, maxItems, maxTotalChargeUsd, restartOnError }: Omit, - runTimeoutSecs?: number, -): RunResurrectOptions => ({ build, memory, runTimeoutSecs, maxItems, maxTotalChargeUsd, restartOnError }); - export interface ChildRunOptions { /** * Local name for the child run. This serves as a way to identify the child run in case of a migration. @@ -895,7 +889,6 @@ export class Actor { runName, { type: 'actor', id: actorId, input }, async () => client.actor(actorId).start(input, { ...startOptions, runTimeoutSecs }), - pickResurrectOptions(startOptions, runTimeoutSecs), ); // The earlier part of a resumed run's log was already redirected before the migration. @@ -945,7 +938,6 @@ export class Actor { runName, { type: 'actor', id: actorId, input }, async () => client.actor(actorId).start(input, { ...rest, runTimeoutSecs }), - pickResurrectOptions(rest, runTimeoutSecs), ); return run; } @@ -1017,7 +1009,6 @@ export class Actor { runName, { type: 'task', id: taskId, input }, async () => client.task(taskId).start(input, { ...startOptions, runTimeoutSecs }), - pickResurrectOptions(startOptions, runTimeoutSecs), ); const finishedRun = await client.run(run.id).waitForFinish({ waitSecs }); await this.#childRunTracker.update(runName, finishedRun); @@ -1025,8 +1016,8 @@ export class Actor { } /** - * Returns the child run tracked under `runName` if it is still in progress or has succeeded, and resurrects it - * if it was aborted or timed out. Otherwise (the run failed or is gone), starts a new one using `start` + * Returns the child run tracked under `runName` if it is still in progress or has succeeded. + * Otherwise (the run failed, was aborted, timed out or is gone), starts a new one using `start` * and tracks it under `runName`, keeping the previous one in the history. */ async #startOrResumeChildRun( @@ -1034,7 +1025,6 @@ export class Actor { runName: string, request: ChildRunRequest, start: () => Promise, - resurrectOptions: RunResurrectOptions, ): Promise<{ run: ClientActorRun; resumed: boolean }> { const tracked = await this.#childRunTracker.get(runName); const trackedRun = tracked ? await client.run(tracked.runId).get() : undefined; @@ -1047,13 +1037,6 @@ export class Actor { await this.#childRunTracker.update(runName, trackedRun); return { run: trackedRun, resumed: true }; } - case 'ABORTED': - case 'TIMED-OUT': { - this.#childRunTracker.verifyRequest(runName, request); - const run = await client.run(trackedRun.id).resurrect(resurrectOptions); - await this.#childRunTracker.update(runName, run); - return { run, resumed: true }; - } default: { const run = await start(); await this.#childRunTracker.track(runName, run, request, trackedRun?.status ?? 'LOST'); diff --git a/test/apify/actor.test.ts b/test/apify/actor.test.ts index 60939335ca..f4e3ea6eca 100644 --- a/test/apify/actor.test.ts +++ b/test/apify/actor.test.ts @@ -1077,7 +1077,6 @@ describe('Actor', () => { let getSpy: MockInstance; let waitForFinishSpy: MockInstance; let getStreamedLogSpy: MockInstance; - let resurrectSpy: MockInstance; const newActor = () => createIsolatedActor({ storageClient: storage }).actor; @@ -1088,7 +1087,6 @@ describe('Actor', () => { getSpy = vitest.spyOn(RunClient.prototype, 'get').mockResolvedValue(trackedRun); waitForFinishSpy = vitest.spyOn(RunClient.prototype, 'waitForFinish').mockResolvedValue(finishedRun); getStreamedLogSpy = vitest.spyOn(RunClient.prototype, 'getStreamedLog').mockResolvedValue(undefined); - resurrectSpy = vitest.spyOn(RunClient.prototype, 'resurrect'); }); test('start() without a run name does not track the run', async () => { @@ -1129,26 +1127,7 @@ describe('Actor', () => { }, ); - test.each(['ABORTED', 'TIMED-OUT'] as const)( - 'start() resurrects a tracked run in the %s status', - async (status) => { - await newActor().start(actId, input, { runName }); - startSpy.mockClear(); - getSpy.mockResolvedValue({ ...trackedRun, status }); - resurrectSpy.mockResolvedValue({ ...trackedRun, status: 'READY' }); - - const actor = newActor(); - const run = await actor.start(actId, input, { runName, memory: 512, timeout: 30 }); - - expect(startSpy).not.toBeCalled(); - expect(resurrectSpy).toBeCalledTimes(1); - expect(resurrectSpy).toBeCalledWith(expect.objectContaining({ memory: 512, runTimeoutSecs: 30 })); - expect(run.status).toBe('READY'); - expect((await actor.childRuns())[runName]).toEqual({ ...trackedInfo, status: 'READY', history: [] }); - }, - ); - - test.each(['FAILED', 'ABORTING', 'TIMING-OUT'] as const)( + test.each(['FAILED', 'ABORTING', 'ABORTED', 'TIMING-OUT', 'TIMED-OUT'] as const)( 'start() starts a new run when the tracked run is in the %s status and keeps the old one in the history', async (status) => { await newActor().start(actId, input, { runName }); @@ -1161,7 +1140,6 @@ describe('Actor', () => { const run = await actor.start(actId, input, { runName }); expect(startSpy).toBeCalledTimes(1); - expect(resurrectSpy).not.toBeCalled(); expect(run).toEqual(replacement); expect(await (await actor.openKeyValueStore()).getValue('CHILD_RUNS')).toEqual({ [runName]: { From a337bb0a0440de9bbee3c8aea96a9e0529a352d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Wed, 30 Sep 2026 11:26:08 +0200 Subject: [PATCH 07/31] docs: describe the public ChildRunTracker methods --- src/child_run_tracker.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index 7edcc5918e..69ac2a5842 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -105,11 +105,13 @@ export class ChildRunTracker { await this.persist(); } + /** Returns the tracked run and its history for `runName`, if there is one. */ async get(runName: string) { const trackedRuns = await this.load(); return trackedRuns[runName]; } + /** Returns a copy of all the tracked runs, keyed by run name. */ async getAll() { return structuredClone(await this.load()); } From c9922a1756990da152249a7dd39e7a26abab6cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Wed, 30 Sep 2026 11:27:27 +0200 Subject: [PATCH 08/31] style: use multiline jsdoc for ChildRunTracker methods --- src/child_run_tracker.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index 69ac2a5842..b296bf2dd5 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -105,13 +105,17 @@ export class ChildRunTracker { await this.persist(); } - /** Returns the tracked run and its history for `runName`, if there is one. */ + /** + * Returns the tracked run and its history for `runName`, if there is one. + */ async get(runName: string) { const trackedRuns = await this.load(); return trackedRuns[runName]; } - /** Returns a copy of all the tracked runs, keyed by run name. */ + /** + * Returns a copy of all the tracked runs, keyed by run name. + */ async getAll() { return structuredClone(await this.load()); } From 1940b863df833199124ec9fc2427f9fedeb27513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Wed, 30 Sep 2026 11:33:51 +0200 Subject: [PATCH 09/31] refactor: simplify child run tracking --- src/actor.ts | 23 +++++++++-------------- src/child_run_tracker.ts | 17 +++++++---------- test/apify/actor.test.ts | 22 +++++++++++----------- 3 files changed, 27 insertions(+), 35 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index f4046922c6..e1610f0983 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -50,9 +50,9 @@ import { ApifyFileSystemStorageBackend } from './apify_file_system_storage_backe import type { RequestQueueAccessMode } from './apify_request_queue_backend.js'; import { ApifyStorageBackend } from './apify_storage_backend.js'; import type { ChargeOptions, ChargeResult } from './charging.js'; -import { type ChildRunRequest, ChildRunTracker, type TrackedChildRun } from './child_run_tracker.js'; import { ChargingManager, DEFAULT_DATASET_ITEM_EVENT } from './charging.js'; import { ChargingStorageBackend } from './charging_storage_backend.js'; +import { type ChildRunRequest, ChildRunTracker, type TrackedChildRun } from './child_run_tracker.js'; import type { ConfigurationOptions } from './configuration.js'; import { Configuration } from './configuration.js'; import { ActorInputError } from './errors.js'; @@ -1029,20 +1029,15 @@ export class Actor { const tracked = await this.#childRunTracker.get(runName); const trackedRun = tracked ? await client.run(tracked.runId).get() : undefined; - switch (trackedRun?.status) { - case 'SUCCEEDED': - case 'READY': - case 'RUNNING': { - this.#childRunTracker.verifyRequest(runName, request); - await this.#childRunTracker.update(runName, trackedRun); - return { run: trackedRun, resumed: true }; - } - default: { - const run = await start(); - await this.#childRunTracker.track(runName, run, request, trackedRun?.status ?? 'LOST'); - return { run, resumed: false }; - } + if (trackedRun && ['SUCCEEDED', 'READY', 'RUNNING'].includes(trackedRun.status)) { + this.#childRunTracker.verifyRequest(runName, request); + await this.#childRunTracker.update(runName, trackedRun); + return { run: trackedRun, resumed: true }; } + + const run = await start(); + await this.#childRunTracker.track(runName, run, request, trackedRun?.status ?? 'LOST'); + return { run, resumed: false }; } /** diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index b296bf2dd5..de2af3ae53 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -46,8 +46,9 @@ const toInfo = (run: ActorRun): ChildRunInfo => ({ startedAt: run.startedAt.toISOString(), }); +const CHILD_RUNS_KVS_KEY = 'CHILD_RUNS'; + export class ChildRunTracker { - private CHILD_RUN_TRACKER_KVS_KEY = 'CHILD_RUNS'; private trackedRuns?: Promise>; private lastWrite: Promise = Promise.resolve(); private requestChecksums = new Map(); @@ -89,7 +90,7 @@ export class ChildRunTracker { : [], }; - await this.persist(); + await this.persist(trackedRuns); } /** @@ -102,7 +103,7 @@ export class ChildRunTracker { if (tracked?.runId !== run.id || tracked.status === run.status) return; tracked.status = run.status; - await this.persist(); + await this.persist(trackedRuns); } /** @@ -120,13 +121,11 @@ export class ChildRunTracker { return structuredClone(await this.load()); } - private async persist() { - const trackedRuns = await this.load(); - + private async persist(trackedRuns: Record) { const write = this.lastWrite.then(async () => withDirectStorageAccess(async () => { const defaultStore = await KeyValueStore.open(); - await defaultStore.setValue(this.CHILD_RUN_TRACKER_KVS_KEY, trackedRuns); + await defaultStore.setValue(CHILD_RUNS_KVS_KEY, trackedRuns); }), ); this.lastWrite = write.catch(() => {}); @@ -135,9 +134,7 @@ export class ChildRunTracker { private async load() { this.trackedRuns ??= KeyValueStore.open() - .then(async (defaultStore) => - defaultStore.getValue>(this.CHILD_RUN_TRACKER_KVS_KEY), - ) + .then(async (defaultStore) => defaultStore.getValue>(CHILD_RUNS_KVS_KEY)) .then((storedRuns) => storedRuns ?? {}); return this.trackedRuns; diff --git a/test/apify/actor.test.ts b/test/apify/actor.test.ts index f4e3ea6eca..5831609049 100644 --- a/test/apify/actor.test.ts +++ b/test/apify/actor.test.ts @@ -1067,7 +1067,6 @@ describe('Actor', () => { const runName = 'child'; const startedAt = new Date('2026-01-01T00:00:00.000Z'); const startedRun = { id: 'child-run', status: 'RUNNING', startedAt } as ActorRun; - const trackedRun = startedRun; const finishedRun = { ...startedRun, status: 'SUCCEEDED' } as ActorRun; const trackedInfo = { runId: startedRun.id, status: 'RUNNING', startedAt: startedAt.toISOString() }; @@ -1078,13 +1077,14 @@ describe('Actor', () => { let waitForFinishSpy: MockInstance; let getStreamedLogSpy: MockInstance; + const readStored = async (actor: Actor) => (await actor.openKeyValueStore()).getValue('CHILD_RUNS'); const newActor = () => createIsolatedActor({ storageClient: storage }).actor; beforeEach(() => { storage = new MemoryStorageBackend(); startSpy = vitest.spyOn(ActorClient.prototype, 'start').mockResolvedValue(startedRun); taskStartSpy = vitest.spyOn(TaskClient.prototype, 'start').mockResolvedValue(startedRun); - getSpy = vitest.spyOn(RunClient.prototype, 'get').mockResolvedValue(trackedRun); + getSpy = vitest.spyOn(RunClient.prototype, 'get').mockResolvedValue(startedRun); waitForFinishSpy = vitest.spyOn(RunClient.prototype, 'waitForFinish').mockResolvedValue(finishedRun); getStreamedLogSpy = vitest.spyOn(RunClient.prototype, 'getStreamedLog').mockResolvedValue(undefined); }); @@ -1095,7 +1095,7 @@ describe('Actor', () => { await actor.start(actId, input); expect(startSpy).toBeCalledTimes(1); - expect(await (await actor.openKeyValueStore()).getValue('CHILD_RUNS')).toBeNull(); + expect(await readStored(actor)).toBeNull(); }); test('start() starts a new run and tracks it', async () => { @@ -1105,7 +1105,7 @@ describe('Actor', () => { expect(run).toEqual(startedRun); expect(startSpy).toBeCalledTimes(1); - expect(await (await actor.openKeyValueStore()).getValue('CHILD_RUNS')).toEqual({ + expect(await readStored(actor)).toEqual({ [runName]: { ...trackedInfo, history: [] }, }); }); @@ -1115,13 +1115,13 @@ describe('Actor', () => { async (status) => { await newActor().start(actId, input, { runName }); startSpy.mockClear(); - getSpy.mockResolvedValue({ ...trackedRun, status }); + getSpy.mockResolvedValue({ ...startedRun, status }); const actor = newActor(); const run = await actor.start(actId, input, { runName }); expect(startSpy).not.toBeCalled(); - expect(run).toEqual({ ...trackedRun, status }); + expect(run).toEqual({ ...startedRun, status }); expect(getSpy).toBeCalledTimes(1); expect((await actor.childRuns())[runName]).toEqual({ ...trackedInfo, status, history: [] }); }, @@ -1132,7 +1132,7 @@ describe('Actor', () => { async (status) => { await newActor().start(actId, input, { runName }); startSpy.mockClear(); - getSpy.mockResolvedValue({ ...trackedRun, status }); + getSpy.mockResolvedValue({ ...startedRun, status }); const replacement = { id: 'replacement-run', status: 'RUNNING', startedAt } as ActorRun; startSpy.mockResolvedValue(replacement); @@ -1141,7 +1141,7 @@ describe('Actor', () => { expect(startSpy).toBeCalledTimes(1); expect(run).toEqual(replacement); - expect(await (await actor.openKeyValueStore()).getValue('CHILD_RUNS')).toEqual({ + expect(await readStored(actor)).toEqual({ [runName]: { ...trackedInfo, runId: replacement.id, @@ -1167,10 +1167,10 @@ describe('Actor', () => { test('repeated replacements accumulate in the history, oldest first', async () => { const actor = newActor(); await actor.start(actId, input, { runName }); - getSpy.mockResolvedValue({ ...trackedRun, status: 'FAILED' }); + getSpy.mockResolvedValue({ ...startedRun, status: 'FAILED' }); startSpy.mockResolvedValueOnce({ ...startedRun, id: 'second' }); await actor.start(actId, input, { runName }); - getSpy.mockResolvedValue({ ...trackedRun, id: 'second', status: 'FAILED' }); + getSpy.mockResolvedValue({ ...startedRun, id: 'second', status: 'FAILED' }); startSpy.mockResolvedValueOnce({ ...startedRun, id: 'third' }); await actor.start(actId, input, { runName }); @@ -1245,7 +1245,7 @@ describe('Actor', () => { test('reusing a run name with a different input is allowed when the tracked run is replaced', async () => { const actor = newActor(); await actor.start(actId, input, { runName }); - getSpy.mockResolvedValue({ ...trackedRun, status: 'FAILED' }); + getSpy.mockResolvedValue({ ...startedRun, status: 'FAILED' }); await actor.start(actId, { foo: 'baz' }, { runName }); From 7751f4c93fef65bf6ec879818b14c01edc616bf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Wed, 30 Sep 2026 11:36:34 +0200 Subject: [PATCH 10/31] feat: persist the child run request checksum so the name check survives restarts --- src/actor.ts | 2 +- src/child_run_tracker.ts | 19 ++++++++----------- test/apify/actor.test.ts | 18 +++++++++++++----- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index e1610f0983..6cbd2e0106 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -1030,7 +1030,7 @@ export class Actor { const trackedRun = tracked ? await client.run(tracked.runId).get() : undefined; if (trackedRun && ['SUCCEEDED', 'READY', 'RUNNING'].includes(trackedRun.status)) { - this.#childRunTracker.verifyRequest(runName, request); + await this.#childRunTracker.verifyRequest(runName, request); await this.#childRunTracker.update(runName, trackedRun); return { run: trackedRun, resumed: true }; } diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index de2af3ae53..41e3013593 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -14,6 +14,8 @@ export interface ChildRunInfo { } export interface TrackedChildRun extends ChildRunInfo { + /** Identifies the Actor / task and input the run was started with. */ + checksum: string; /** Earlier runs that were replaced under the same name, oldest first. */ history: ChildRunInfo[]; } @@ -51,23 +53,19 @@ const CHILD_RUNS_KVS_KEY = 'CHILD_RUNS'; export class ChildRunTracker { private trackedRuns?: Promise>; private lastWrite: Promise = Promise.resolve(); - private requestChecksums = new Map(); /** - * Throws if `runName` was already used by this process for a different Actor / task or input, - * as the earlier run would otherwise be silently returned in place of the requested one. + * Throws if the run tracked under `runName` was started for a different Actor / task or input, + * as it would otherwise be silently returned in place of the requested one. */ - verifyRequest(runName: string, request: ChildRunRequest) { - const checksum = checksumRequest(request); - const knownChecksum = this.requestChecksums.get(runName); + async verifyRequest(runName: string, request: ChildRunRequest) { + const tracked = await this.get(runName); - if (knownChecksum && knownChecksum !== checksum) { + if (tracked && tracked.checksum !== checksumRequest(request)) { throw new Error( `The run name "${runName}" was already used for a different Actor, task or input. Use a unique \`runName\` for each child run.`, ); } - - this.requestChecksums.set(runName, checksum); } /** @@ -75,13 +73,12 @@ export class ChildRunTracker { * history, with `replacedStatus` as its last known status. */ async track(runName: string, run: ActorRun, request: ChildRunRequest, replacedStatus: ChildRunStatus) { - this.requestChecksums.set(runName, checksumRequest(request)); - const trackedRuns = await this.load(); const previous = trackedRuns[runName]; trackedRuns[runName] = { ...toInfo(run), + checksum: checksumRequest(request), history: previous ? [ ...previous.history, diff --git a/test/apify/actor.test.ts b/test/apify/actor.test.ts index 5831609049..beb6b3e0ed 100644 --- a/test/apify/actor.test.ts +++ b/test/apify/actor.test.ts @@ -1069,6 +1069,7 @@ describe('Actor', () => { const startedRun = { id: 'child-run', status: 'RUNNING', startedAt } as ActorRun; const finishedRun = { ...startedRun, status: 'SUCCEEDED' } as ActorRun; const trackedInfo = { runId: startedRun.id, status: 'RUNNING', startedAt: startedAt.toISOString() }; + const stored = { ...trackedInfo, checksum: expect.any(String) }; let storage: MemoryStorageBackend; let startSpy: MockInstance; @@ -1106,7 +1107,7 @@ describe('Actor', () => { expect(run).toEqual(startedRun); expect(startSpy).toBeCalledTimes(1); expect(await readStored(actor)).toEqual({ - [runName]: { ...trackedInfo, history: [] }, + [runName]: { ...stored, history: [] }, }); }); @@ -1123,7 +1124,7 @@ describe('Actor', () => { expect(startSpy).not.toBeCalled(); expect(run).toEqual({ ...startedRun, status }); expect(getSpy).toBeCalledTimes(1); - expect((await actor.childRuns())[runName]).toEqual({ ...trackedInfo, status, history: [] }); + expect((await actor.childRuns())[runName]).toEqual({ ...stored, status, history: [] }); }, ); @@ -1143,7 +1144,7 @@ describe('Actor', () => { expect(run).toEqual(replacement); expect(await readStored(actor)).toEqual({ [runName]: { - ...trackedInfo, + ...stored, runId: replacement.id, history: [{ ...trackedInfo, status }], }, @@ -1234,10 +1235,17 @@ describe('Actor', () => { expect(startSpy).toBeCalledTimes(1); }); - test('reusing a run name with a different input is allowed after a restart', async () => { + test('reusing a run name with a different input after a restart throws', async () => { await newActor().start(actId, input, { runName }); - await newActor().start(actId, { foo: 'baz' }, { runName }); + await expect(newActor().start(actId, { foo: 'baz' }, { runName })).rejects.toThrow(/already used/); + expect(startSpy).toBeCalledTimes(1); + }); + + test('reusing a run name with the same input after a restart resumes the run', async () => { + await newActor().start(actId, { a: 1, b: 2 }, { runName }); + + await newActor().start(actId, { b: 2, a: 1 }, { runName }); expect(startSpy).toBeCalledTimes(1); }); From f4cfdc471998e821f11e443dc96d609cbba0067d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 6 Oct 2026 13:35:27 +0200 Subject: [PATCH 11/31] docs: extend the JSDoc for the `runName` option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Martin Adámek --- src/actor.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 6cbd2e0106..e4923f19e4 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -328,8 +328,11 @@ export interface Timeout { export interface ChildRunOptions { /** - * Local name for the child run. This serves as a way to identify the child run in case of a migration. - * The name must be unique across all Actors and tasks started by this run. + * Local name for the child run, unique across all Actors and tasks started by this run. + * + * If a `READY`, `RUNNING` or `SUCCEEDED` run is already tracked under this name, it is returned instead of + * starting a new one, including after a migration or resurrection of this run. A run that failed, was aborted + * or timed out is replaced by a new one. Reusing the name for a different Actor, task or input throws. */ runName?: string; } From 29f7d1335de37b532f26c8d62443c92cf739c857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 6 Oct 2026 13:39:40 +0200 Subject: [PATCH 12/31] fix: clear `trackedRuns` cache on a KVS load error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Martin Adámek --- src/child_run_tracker.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index 41e3013593..df9bc71fce 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -132,7 +132,11 @@ export class ChildRunTracker { private async load() { this.trackedRuns ??= KeyValueStore.open() .then(async (defaultStore) => defaultStore.getValue>(CHILD_RUNS_KVS_KEY)) - .then((storedRuns) => storedRuns ?? {}); + .then((storedRuns) => storedRuns ?? {}) + .catch((error) => { + this.trackedRuns = undefined; + throw error; + }); return this.trackedRuns; } From ca71dd2b26acca015132715b491d5478e077105a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 6 Oct 2026 13:43:17 +0200 Subject: [PATCH 13/31] fix: add a per-name mutex for named run start and tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Martin Adámek --- src/actor.ts | 24 +++++++++++++----------- src/child_run_tracker.ts | 10 ++++++++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index e4923f19e4..2e1ae8aa6c 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -1029,18 +1029,20 @@ export class Actor { request: ChildRunRequest, start: () => Promise, ): Promise<{ run: ClientActorRun; resumed: boolean }> { - const tracked = await this.#childRunTracker.get(runName); - const trackedRun = tracked ? await client.run(tracked.runId).get() : undefined; - - if (trackedRun && ['SUCCEEDED', 'READY', 'RUNNING'].includes(trackedRun.status)) { - await this.#childRunTracker.verifyRequest(runName, request); - await this.#childRunTracker.update(runName, trackedRun); - return { run: trackedRun, resumed: true }; - } + return this.#childRunTracker.withLock(runName, async () => { + const tracked = await this.#childRunTracker.get(runName); + const trackedRun = tracked ? await client.run(tracked.runId).get() : undefined; + + if (trackedRun && ['SUCCEEDED', 'READY', 'RUNNING'].includes(trackedRun.status)) { + await this.#childRunTracker.verifyRequest(runName, request); + await this.#childRunTracker.update(runName, trackedRun); + return { run: trackedRun, resumed: true }; + } - const run = await start(); - await this.#childRunTracker.track(runName, run, request, trackedRun?.status ?? 'LOST'); - return { run, resumed: false }; + const run = await start(); + await this.#childRunTracker.track(runName, run, request, trackedRun?.status ?? 'LOST'); + return { run, resumed: false }; + }); } /** diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index df9bc71fce..1d4fa6607f 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -53,6 +53,16 @@ const CHILD_RUNS_KVS_KEY = 'CHILD_RUNS'; export class ChildRunTracker { private trackedRuns?: Promise>; private lastWrite: Promise = Promise.resolve(); + private locks = new Map>(); + + /** + * Runs `fn` once the earlier calls for the same `runName` have settled. + */ + async withLock(runName: string, fn: () => Promise): Promise { + const result = (this.locks.get(runName) ?? Promise.resolve()).then(fn); + this.locks.set(runName, result.catch(() => {})); + return result; + } /** * Throws if the run tracked under `runName` was started for a different Actor / task or input, From 677379e0a7ce92ee16a816fafe2ab9a9bd5e43ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 6 Oct 2026 14:44:21 +0200 Subject: [PATCH 14/31] fix: checksum the serialized child run request `Date` and `URL` inputs were hashed as `{}` and function inputs were dropped, so inputs differing only in those values got the same checksum and the earlier run was resumed. Hash the JSON form `apify-client` sends instead, and sort keys by code units, as `localeCompare` depends on the locale. --- src/child_run_tracker.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index 1d4fa6607f..7b74380a09 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -32,14 +32,19 @@ const sortKeys = (value: unknown): unknown => { return Object.fromEntries( Object.entries(value) - .sort(([a], [b]) => a.localeCompare(b)) + .sort(([a], [b]) => Number(a > b) - Number(a < b)) .map(([key, nested]) => [key, sortKeys(nested)]), ); }; +// Hashes the JSON form that `apify-client` sends (`Date` -> ISO string, `URL` -> href, functions -> source), +// not the objects' own keys. +const toSentJson = (value: unknown) => + JSON.parse(JSON.stringify(value, (_key, nested) => (typeof nested === 'function' ? nested.toString() : nested))); + const checksumRequest = ({ type, id, input }: ChildRunRequest) => createHash('sha256') - .update(JSON.stringify(sortKeys({ type, id, input })) ?? '') + .update(JSON.stringify(sortKeys(toSentJson({ type, id, input })))) .digest('hex'); const toInfo = (run: ActorRun): ChildRunInfo => ({ @@ -60,7 +65,10 @@ export class ChildRunTracker { */ async withLock(runName: string, fn: () => Promise): Promise { const result = (this.locks.get(runName) ?? Promise.resolve()).then(fn); - this.locks.set(runName, result.catch(() => {})); + this.locks.set( + runName, + result.catch(() => {}), + ); return result; } From aebd5a4bb1d88100adac081f950cf2d15552d467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Wed, 7 Oct 2026 14:08:01 +0200 Subject: [PATCH 15/31] refactor: store tracked child runs under the `__ACTOR_CHILD_RUNS` key --- src/actor.ts | 4 ++-- src/child_run_tracker.ts | 2 +- test/apify/actor.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 2e1ae8aa6c..232d54f995 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -1050,7 +1050,7 @@ export class Actor { * that were replaced under the same name in `history`. * * The statuses are the last ones this Actor observed: a run nobody waits for is not refreshed. - * The same record is stored in the default key-value store under the `CHILD_RUNS` key. + * The same record is stored in the default key-value store under the `__ACTOR_CHILD_RUNS` key. */ async childRuns(): Promise> { return this.#childRunTracker.getAll(); @@ -2038,7 +2038,7 @@ export class Actor { * that were replaced under the same name in `history`. * * The statuses are the last ones this Actor observed: a run nobody waits for is not refreshed. - * The same record is stored in the default key-value store under the `CHILD_RUNS` key. + * The same record is stored in the default key-value store under the `__ACTOR_CHILD_RUNS` key. */ static async childRuns(): Promise> { return Actor.getDefaultInstance().childRuns(); diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index 7b74380a09..8f192d84bb 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -53,7 +53,7 @@ const toInfo = (run: ActorRun): ChildRunInfo => ({ startedAt: run.startedAt.toISOString(), }); -const CHILD_RUNS_KVS_KEY = 'CHILD_RUNS'; +const CHILD_RUNS_KVS_KEY = '__ACTOR_CHILD_RUNS'; export class ChildRunTracker { private trackedRuns?: Promise>; diff --git a/test/apify/actor.test.ts b/test/apify/actor.test.ts index beb6b3e0ed..bbd924ebb0 100644 --- a/test/apify/actor.test.ts +++ b/test/apify/actor.test.ts @@ -1078,7 +1078,7 @@ describe('Actor', () => { let waitForFinishSpy: MockInstance; let getStreamedLogSpy: MockInstance; - const readStored = async (actor: Actor) => (await actor.openKeyValueStore()).getValue('CHILD_RUNS'); + const readStored = async (actor: Actor) => (await actor.openKeyValueStore()).getValue('__ACTOR_CHILD_RUNS'); const newActor = () => createIsolatedActor({ storageClient: storage }).actor; beforeEach(() => { From 37193a045e06a684a47b29ee82c7d215b2724311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Thu, 8 Oct 2026 13:24:05 +0200 Subject: [PATCH 16/31] refactor: split off the child run specific tests into a separate file --- test/apify/actor.test.ts | 258 -------------------------------- test/apify/child_runs.test.ts | 274 ++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+), 258 deletions(-) create mode 100644 test/apify/child_runs.test.ts diff --git a/test/apify/actor.test.ts b/test/apify/actor.test.ts index bbd924ebb0..47a3e1b78a 100644 --- a/test/apify/actor.test.ts +++ b/test/apify/actor.test.ts @@ -11,7 +11,6 @@ import { Actor, ActorInputError, Configuration, Dataset, KeyValueStore, ProxyCon import type { ActorRun, WebhookUpdateData } from 'apify-client'; import { ActorClient, ApifyClient, RunClient, TaskClient } from 'apify-client'; import { BasicCrawler } from 'crawlee'; -import type { MockInstance } from 'vitest'; import { ACT_JOB_STATUSES, @@ -1062,263 +1061,6 @@ describe('Actor', () => { }); }); - describe('child run tracking with `runName`', () => { - const { input, actId, taskId } = globalOptions; - const runName = 'child'; - const startedAt = new Date('2026-01-01T00:00:00.000Z'); - const startedRun = { id: 'child-run', status: 'RUNNING', startedAt } as ActorRun; - const finishedRun = { ...startedRun, status: 'SUCCEEDED' } as ActorRun; - const trackedInfo = { runId: startedRun.id, status: 'RUNNING', startedAt: startedAt.toISOString() }; - const stored = { ...trackedInfo, checksum: expect.any(String) }; - - let storage: MemoryStorageBackend; - let startSpy: MockInstance; - let taskStartSpy: MockInstance; - let getSpy: MockInstance; - let waitForFinishSpy: MockInstance; - let getStreamedLogSpy: MockInstance; - - const readStored = async (actor: Actor) => (await actor.openKeyValueStore()).getValue('__ACTOR_CHILD_RUNS'); - const newActor = () => createIsolatedActor({ storageClient: storage }).actor; - - beforeEach(() => { - storage = new MemoryStorageBackend(); - startSpy = vitest.spyOn(ActorClient.prototype, 'start').mockResolvedValue(startedRun); - taskStartSpy = vitest.spyOn(TaskClient.prototype, 'start').mockResolvedValue(startedRun); - getSpy = vitest.spyOn(RunClient.prototype, 'get').mockResolvedValue(startedRun); - waitForFinishSpy = vitest.spyOn(RunClient.prototype, 'waitForFinish').mockResolvedValue(finishedRun); - getStreamedLogSpy = vitest.spyOn(RunClient.prototype, 'getStreamedLog').mockResolvedValue(undefined); - }); - - test('start() without a run name does not track the run', async () => { - const actor = newActor(); - - await actor.start(actId, input); - - expect(startSpy).toBeCalledTimes(1); - expect(await readStored(actor)).toBeNull(); - }); - - test('start() starts a new run and tracks it', async () => { - const actor = newActor(); - - const run = await actor.start(actId, input, { runName }); - - expect(run).toEqual(startedRun); - expect(startSpy).toBeCalledTimes(1); - expect(await readStored(actor)).toEqual({ - [runName]: { ...stored, history: [] }, - }); - }); - - test.each(['READY', 'RUNNING', 'SUCCEEDED'] as const)( - 'start() resumes a tracked run in the %s status after a restart', - async (status) => { - await newActor().start(actId, input, { runName }); - startSpy.mockClear(); - getSpy.mockResolvedValue({ ...startedRun, status }); - - const actor = newActor(); - const run = await actor.start(actId, input, { runName }); - - expect(startSpy).not.toBeCalled(); - expect(run).toEqual({ ...startedRun, status }); - expect(getSpy).toBeCalledTimes(1); - expect((await actor.childRuns())[runName]).toEqual({ ...stored, status, history: [] }); - }, - ); - - test.each(['FAILED', 'ABORTING', 'ABORTED', 'TIMING-OUT', 'TIMED-OUT'] as const)( - 'start() starts a new run when the tracked run is in the %s status and keeps the old one in the history', - async (status) => { - await newActor().start(actId, input, { runName }); - startSpy.mockClear(); - getSpy.mockResolvedValue({ ...startedRun, status }); - const replacement = { id: 'replacement-run', status: 'RUNNING', startedAt } as ActorRun; - startSpy.mockResolvedValue(replacement); - - const actor = newActor(); - const run = await actor.start(actId, input, { runName }); - - expect(startSpy).toBeCalledTimes(1); - expect(run).toEqual(replacement); - expect(await readStored(actor)).toEqual({ - [runName]: { - ...stored, - runId: replacement.id, - history: [{ ...trackedInfo, status }], - }, - }); - }, - ); - - test('start() starts a new run when the tracked run no longer exists and records the old one as lost', async () => { - await newActor().start(actId, input, { runName }); - startSpy.mockClear(); - getSpy.mockResolvedValue(undefined); - startSpy.mockResolvedValue({ ...startedRun, id: 'replacement-run' }); - - const actor = newActor(); - await actor.start(actId, input, { runName }); - - expect(startSpy).toBeCalledTimes(1); - expect((await actor.childRuns())[runName].history).toEqual([{ ...trackedInfo, status: 'LOST' }]); - }); - - test('repeated replacements accumulate in the history, oldest first', async () => { - const actor = newActor(); - await actor.start(actId, input, { runName }); - getSpy.mockResolvedValue({ ...startedRun, status: 'FAILED' }); - startSpy.mockResolvedValueOnce({ ...startedRun, id: 'second' }); - await actor.start(actId, input, { runName }); - getSpy.mockResolvedValue({ ...startedRun, id: 'second', status: 'FAILED' }); - startSpy.mockResolvedValueOnce({ ...startedRun, id: 'third' }); - await actor.start(actId, input, { runName }); - - const tracked = (await actor.childRuns())[runName]; - expect(tracked.runId).toBe('third'); - expect(tracked.history.map(({ runId, status }) => [runId, status])).toEqual([ - [startedRun.id, 'FAILED'], - ['second', 'FAILED'], - ]); - }); - - test('childRuns() is empty without tracked runs and returns a copy', async () => { - const actor = newActor(); - expect(await actor.childRuns()).toEqual({}); - - await actor.start(actId, input, { runName }); - (await actor.childRuns())[runName].status = 'FAILED'; - - expect((await actor.childRuns())[runName].status).toBe('RUNNING'); - }); - - test('start() tracks different run names independently', async () => { - const actor = newActor(); - startSpy.mockResolvedValueOnce({ ...startedRun, id: 'first' }).mockResolvedValueOnce({ - ...startedRun, - id: 'second', - }); - - await Promise.all([ - actor.start(actId, input, { runName: 'a' }), - actor.start(actId, input, { runName: 'b' }), - ]); - - expect( - Object.fromEntries(Object.entries(await actor.childRuns()).map(([name, { runId }]) => [name, runId])), - ).toEqual({ a: 'first', b: 'second' }); - }); - - test('reusing a run name for a different Actor in the same process throws', async () => { - const actor = newActor(); - await actor.start(actId, input, { runName }); - - await expect(actor.start('other-actor', input, { runName })).rejects.toThrow(/already used/); - await expect(actor.callTask(taskId, input, { runName })).rejects.toThrow(/already used/); - expect(startSpy).toBeCalledTimes(1); - }); - - test('reusing a run name with a different input in the same process throws', async () => { - const actor = newActor(); - await actor.start(actId, input, { runName }); - - await expect(actor.start(actId, { foo: 'baz' }, { runName })).rejects.toThrow(/already used/); - }); - - test('reusing a run name with an equivalent input in the same process resumes the run', async () => { - const actor = newActor(); - await actor.start(actId, { a: 1, b: { c: 2, d: 3 } }, { runName }); - - await actor.start(actId, { b: { d: 3, c: 2 }, a: 1 }, { runName }); - - expect(startSpy).toBeCalledTimes(1); - }); - - test('reusing a run name with a different input after a restart throws', async () => { - await newActor().start(actId, input, { runName }); - - await expect(newActor().start(actId, { foo: 'baz' }, { runName })).rejects.toThrow(/already used/); - expect(startSpy).toBeCalledTimes(1); - }); - - test('reusing a run name with the same input after a restart resumes the run', async () => { - await newActor().start(actId, { a: 1, b: 2 }, { runName }); - - await newActor().start(actId, { b: 2, a: 1 }, { runName }); - - expect(startSpy).toBeCalledTimes(1); - }); - - test('reusing a run name with a different input is allowed when the tracked run is replaced', async () => { - const actor = newActor(); - await actor.start(actId, input, { runName }); - getSpy.mockResolvedValue({ ...startedRun, status: 'FAILED' }); - - await actor.start(actId, { foo: 'baz' }, { runName }); - - expect(startSpy).toBeCalledTimes(2); - }); - - test('call() waits for a new run started with a run name', async () => { - const actor = newActor(); - - const run = await actor.call(actId, input, { runName, waitSecs: 10 }); - - expect(run).toEqual(finishedRun); - expect(startSpy).toBeCalledTimes(1); - expect(waitForFinishSpy).toBeCalledWith({ waitSecs: 10 }); - expect(getStreamedLogSpy).toBeCalledWith({ toLog: undefined, fromStart: true }); - }); - - test('call() waits for the tracked run instead of starting a new one', async () => { - await newActor().start(actId, input, { runName }); - startSpy.mockClear(); - - const actor = newActor(); - const run = await actor.call(actId, input, { runName }); - - expect(run).toEqual(finishedRun); - expect(startSpy).not.toBeCalled(); - expect(getStreamedLogSpy).toBeCalledWith({ toLog: undefined, fromStart: false }); - expect((await actor.childRuns())[runName].status).toBe('SUCCEEDED'); - }); - - test('call() stops the log stream when waiting for the run fails', async () => { - const streamedLog = { start: vitest.fn(), stop: vitest.fn().mockResolvedValue(undefined) }; - getStreamedLogSpy.mockResolvedValue(streamedLog as any); - waitForFinishSpy.mockRejectedValue(new Error('boom')); - - await expect(newActor().call(actId, input, { runName })).rejects.toThrow('boom'); - - expect(streamedLog.start).toBeCalledTimes(1); - expect(streamedLog.stop).toBeCalledTimes(1); - }); - - test('call() without a run name uses `ActorClient.call`', async () => { - const callSpy = vitest.spyOn(ActorClient.prototype, 'call').mockResolvedValue(finishedRun); - - await newActor().call(actId, input); - - expect(callSpy).toBeCalledTimes(1); - expect(startSpy).not.toBeCalled(); - }); - - test('callTask() resumes the tracked run instead of starting a new one', async () => { - await newActor().callTask(taskId, input, { runName }); - expect(taskStartSpy).toBeCalledTimes(1); - taskStartSpy.mockClear(); - - const actor = newActor(); - const run = await actor.callTask(taskId, input, { runName, waitSecs: 5 }); - - expect(run).toEqual(finishedRun); - expect(taskStartSpy).not.toBeCalled(); - expect(waitForFinishSpy).toHaveBeenLastCalledWith({ waitSecs: 5 }); - expect((await actor.childRuns())[runName].status).toBe('SUCCEEDED'); - }); - }); - // TODO we should remove the duplication if possible describe('Actor.metamorph()', () => { const { actId, runId, targetActorId, input, contentType, build } = globalOptions; diff --git a/test/apify/child_runs.test.ts b/test/apify/child_runs.test.ts new file mode 100644 index 0000000000..37fa21d602 --- /dev/null +++ b/test/apify/child_runs.test.ts @@ -0,0 +1,274 @@ +import { MemoryStorageBackend } from '@crawlee/core'; +import type { Actor } from 'apify'; +import type { ActorRun } from 'apify-client'; +import { ActorClient, RunClient, TaskClient } from 'apify-client'; +import type { MockInstance } from 'vitest'; +import { createIsolatedActor } from '../createIsolatedActor'; + +const globalOptions = { + token: 'some-token', + actId: 'some-act-id', + defaultKeyValueStoreId: 'some-store-id', + input: { foo: 'bar' }, + contentType: 'application/json', + outputKey: 'OUTPUT', + outputValue: 'some-output', + build: 'xxx', + taskId: 'some-task-id', + runId: 'some-run-id', + targetActorId: 'some-target-actor-id', +}; + +describe('child run tracking with `runName`', () => { + const { input, actId, taskId } = globalOptions; + const runName = 'child'; + const startedAt = new Date('2026-01-01T00:00:00.000Z'); + const startedRun = { id: 'child-run', status: 'RUNNING', startedAt } as ActorRun; + const finishedRun = { ...startedRun, status: 'SUCCEEDED' } as ActorRun; + const trackedInfo = { runId: startedRun.id, status: 'RUNNING', startedAt: startedAt.toISOString() }; + const stored = { ...trackedInfo, checksum: expect.any(String) }; + + let storage: MemoryStorageBackend; + let startSpy: MockInstance; + let taskStartSpy: MockInstance; + let getSpy: MockInstance; + let waitForFinishSpy: MockInstance; + let getStreamedLogSpy: MockInstance; + + const getChildRunLedger = async (actor: Actor) => (await actor.openKeyValueStore()).getValue('__ACTOR_CHILD_RUNS'); + const newActor = () => createIsolatedActor({ storageClient: storage }).actor; + + beforeEach(() => { + storage = new MemoryStorageBackend(); + startSpy = vitest.spyOn(ActorClient.prototype, 'start').mockResolvedValue(startedRun); + taskStartSpy = vitest.spyOn(TaskClient.prototype, 'start').mockResolvedValue(startedRun); + getSpy = vitest.spyOn(RunClient.prototype, 'get').mockResolvedValue(startedRun); + waitForFinishSpy = vitest.spyOn(RunClient.prototype, 'waitForFinish').mockResolvedValue(finishedRun); + getStreamedLogSpy = vitest.spyOn(RunClient.prototype, 'getStreamedLog').mockResolvedValue(undefined); + }); + + test('start() without a run name does not track the run', async () => { + const actor = newActor(); + + await actor.start(actId, input); + + expect(startSpy).toBeCalledTimes(1); + expect(await getChildRunLedger(actor)).toBeNull(); + }); + + test('start() starts a new run and tracks it', async () => { + const actor = newActor(); + + const run = await actor.start(actId, input, { runName }); + + expect(run).toEqual(startedRun); + expect(startSpy).toBeCalledTimes(1); + expect(await getChildRunLedger(actor)).toEqual({ + [runName]: { ...stored, history: [] }, + }); + }); + + test.each(['READY', 'RUNNING', 'SUCCEEDED'] as const)( + 'start() resumes a tracked run in the %s status after a restart', + async (status) => { + await newActor().start(actId, input, { runName }); + startSpy.mockClear(); + getSpy.mockResolvedValue({ ...startedRun, status }); + + const actor = newActor(); + const run = await actor.start(actId, input, { runName }); + + expect(startSpy).not.toBeCalled(); + expect(run).toEqual({ ...startedRun, status }); + expect(getSpy).toBeCalledTimes(1); + expect((await actor.childRuns())[runName]).toEqual({ ...stored, status, history: [] }); + }, + ); + + test.each(['FAILED', 'ABORTING', 'ABORTED', 'TIMING-OUT', 'TIMED-OUT'] as const)( + 'start() starts a new run when the tracked run is in the %s status and keeps the old one in the history', + async (status) => { + await newActor().start(actId, input, { runName }); + startSpy.mockClear(); + getSpy.mockResolvedValue({ ...startedRun, status }); + const replacement = { id: 'replacement-run', status: 'RUNNING', startedAt } as ActorRun; + startSpy.mockResolvedValue(replacement); + + const actor = newActor(); + const run = await actor.start(actId, input, { runName }); + + expect(startSpy).toBeCalledTimes(1); + expect(run).toEqual(replacement); + expect(await getChildRunLedger(actor)).toEqual({ + [runName]: { + ...stored, + runId: replacement.id, + history: [{ ...trackedInfo, status }], + }, + }); + }, + ); + + test('start() starts a new run when the tracked run no longer exists and records the old one as lost', async () => { + await newActor().start(actId, input, { runName }); + startSpy.mockClear(); + getSpy.mockResolvedValue(undefined); + startSpy.mockResolvedValue({ ...startedRun, id: 'replacement-run' }); + + const actor = newActor(); + await actor.start(actId, input, { runName }); + + expect(startSpy).toBeCalledTimes(1); + expect((await actor.childRuns())[runName].history).toEqual([{ ...trackedInfo, status: 'LOST' }]); + }); + + test('repeated replacements accumulate in the history, oldest first', async () => { + const actor = newActor(); + await actor.start(actId, input, { runName }); + getSpy.mockResolvedValue({ ...startedRun, status: 'FAILED' }); + startSpy.mockResolvedValueOnce({ ...startedRun, id: 'second' }); + await actor.start(actId, input, { runName }); + getSpy.mockResolvedValue({ ...startedRun, id: 'second', status: 'FAILED' }); + startSpy.mockResolvedValueOnce({ ...startedRun, id: 'third' }); + await actor.start(actId, input, { runName }); + + const tracked = (await actor.childRuns())[runName]; + expect(tracked.runId).toBe('third'); + expect(tracked.history.map(({ runId, status }) => [runId, status])).toEqual([ + [startedRun.id, 'FAILED'], + ['second', 'FAILED'], + ]); + }); + + test('childRuns() is empty without tracked runs and returns a copy', async () => { + const actor = newActor(); + expect(await actor.childRuns()).toEqual({}); + + await actor.start(actId, input, { runName }); + (await actor.childRuns())[runName].status = 'FAILED'; + + expect((await actor.childRuns())[runName].status).toBe('RUNNING'); + }); + + test('start() tracks different run names independently', async () => { + const actor = newActor(); + startSpy.mockResolvedValueOnce({ ...startedRun, id: 'first' }).mockResolvedValueOnce({ + ...startedRun, + id: 'second', + }); + + await Promise.all([actor.start(actId, input, { runName: 'a' }), actor.start(actId, input, { runName: 'b' })]); + + expect( + Object.fromEntries(Object.entries(await actor.childRuns()).map(([name, { runId }]) => [name, runId])), + ).toEqual({ a: 'first', b: 'second' }); + }); + + test('reusing a run name for a different Actor in the same process throws', async () => { + const actor = newActor(); + await actor.start(actId, input, { runName }); + + await expect(actor.start('other-actor', input, { runName })).rejects.toThrow(/already used/); + await expect(actor.callTask(taskId, input, { runName })).rejects.toThrow(/already used/); + expect(startSpy).toBeCalledTimes(1); + }); + + test('reusing a run name with a different input in the same process throws', async () => { + const actor = newActor(); + await actor.start(actId, input, { runName }); + + await expect(actor.start(actId, { foo: 'baz' }, { runName })).rejects.toThrow(/already used/); + }); + + test('reusing a run name with an equivalent input in the same process resumes the run', async () => { + const actor = newActor(); + await actor.start(actId, { a: 1, b: { c: 2, d: 3 } }, { runName }); + + await actor.start(actId, { b: { d: 3, c: 2 }, a: 1 }, { runName }); + + expect(startSpy).toBeCalledTimes(1); + }); + + test('reusing a run name with a different input after a restart throws', async () => { + await newActor().start(actId, input, { runName }); + + await expect(newActor().start(actId, { foo: 'baz' }, { runName })).rejects.toThrow(/already used/); + expect(startSpy).toBeCalledTimes(1); + }); + + test('reusing a run name with the same input after a restart resumes the run', async () => { + await newActor().start(actId, { a: 1, b: 2 }, { runName }); + + await newActor().start(actId, { b: 2, a: 1 }, { runName }); + + expect(startSpy).toBeCalledTimes(1); + }); + + test('reusing a run name with a different input is allowed when the tracked run is replaced', async () => { + const actor = newActor(); + await actor.start(actId, input, { runName }); + getSpy.mockResolvedValue({ ...startedRun, status: 'FAILED' }); + + await actor.start(actId, { foo: 'baz' }, { runName }); + + expect(startSpy).toBeCalledTimes(2); + }); + + test('call() waits for a new run started with a run name', async () => { + const actor = newActor(); + + const run = await actor.call(actId, input, { runName, waitSecs: 10 }); + + expect(run).toEqual(finishedRun); + expect(startSpy).toBeCalledTimes(1); + expect(waitForFinishSpy).toBeCalledWith({ waitSecs: 10 }); + expect(getStreamedLogSpy).toBeCalledWith({ toLog: undefined, fromStart: true }); + }); + + test('call() waits for the tracked run instead of starting a new one', async () => { + await newActor().start(actId, input, { runName }); + startSpy.mockClear(); + + const actor = newActor(); + const run = await actor.call(actId, input, { runName }); + + expect(run).toEqual(finishedRun); + expect(startSpy).not.toBeCalled(); + expect(getStreamedLogSpy).toBeCalledWith({ toLog: undefined, fromStart: false }); + expect((await actor.childRuns())[runName].status).toBe('SUCCEEDED'); + }); + + test('call() stops the log stream when waiting for the run fails', async () => { + const streamedLog = { start: vitest.fn(), stop: vitest.fn().mockResolvedValue(undefined) }; + getStreamedLogSpy.mockResolvedValue(streamedLog as any); + waitForFinishSpy.mockRejectedValue(new Error('boom')); + + await expect(newActor().call(actId, input, { runName })).rejects.toThrow('boom'); + + expect(streamedLog.start).toBeCalledTimes(1); + expect(streamedLog.stop).toBeCalledTimes(1); + }); + + test('call() without a run name uses `ActorClient.call`', async () => { + const callSpy = vitest.spyOn(ActorClient.prototype, 'call').mockResolvedValue(finishedRun); + + await newActor().call(actId, input); + + expect(callSpy).toBeCalledTimes(1); + expect(startSpy).not.toBeCalled(); + }); + + test('callTask() resumes the tracked run instead of starting a new one', async () => { + await newActor().callTask(taskId, input, { runName }); + expect(taskStartSpy).toBeCalledTimes(1); + taskStartSpy.mockClear(); + + const actor = newActor(); + const run = await actor.callTask(taskId, input, { runName, waitSecs: 5 }); + + expect(run).toEqual(finishedRun); + expect(taskStartSpy).not.toBeCalled(); + expect(waitForFinishSpy).toHaveBeenLastCalledWith({ waitSecs: 5 }); + expect((await actor.childRuns())[runName].status).toBe('SUCCEEDED'); + }); +}); From 1a9dbe7e7a551d264f9bdcd39bde3e54717f4800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Thu, 8 Oct 2026 13:46:28 +0200 Subject: [PATCH 17/31] chore: improve test naming --- test/apify/child_runs.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/apify/child_runs.test.ts b/test/apify/child_runs.test.ts index 37fa21d602..e947d7611e 100644 --- a/test/apify/child_runs.test.ts +++ b/test/apify/child_runs.test.ts @@ -69,7 +69,7 @@ describe('child run tracking with `runName`', () => { }); test.each(['READY', 'RUNNING', 'SUCCEEDED'] as const)( - 'start() resumes a tracked run in the %s status after a restart', + 'start() respects an existing Actor run with %s status', async (status) => { await newActor().start(actId, input, { runName }); startSpy.mockClear(); @@ -180,7 +180,7 @@ describe('child run tracking with `runName`', () => { await expect(actor.start(actId, { foo: 'baz' }, { runName })).rejects.toThrow(/already used/); }); - test('reusing a run name with an equivalent input in the same process resumes the run', async () => { + test('reusing a run name with an equivalent input in the same process deduplicates the run', async () => { const actor = newActor(); await actor.start(actId, { a: 1, b: { c: 2, d: 3 } }, { runName }); @@ -189,14 +189,14 @@ describe('child run tracking with `runName`', () => { expect(startSpy).toBeCalledTimes(1); }); - test('reusing a run name with a different input after a restart throws', async () => { + test('reusing a run name with a different input after a migration throws', async () => { await newActor().start(actId, input, { runName }); await expect(newActor().start(actId, { foo: 'baz' }, { runName })).rejects.toThrow(/already used/); expect(startSpy).toBeCalledTimes(1); }); - test('reusing a run name with the same input after a restart resumes the run', async () => { + test('reusing a run name with the same input after a migration deduplicates the run', async () => { await newActor().start(actId, { a: 1, b: 2 }, { runName }); await newActor().start(actId, { b: 2, a: 1 }, { runName }); @@ -258,7 +258,7 @@ describe('child run tracking with `runName`', () => { expect(startSpy).not.toBeCalled(); }); - test('callTask() resumes the tracked run instead of starting a new one', async () => { + test('callTask() deduplicates the tracked run instead of starting a new one', async () => { await newActor().callTask(taskId, input, { runName }); expect(taskStartSpy).toBeCalledTimes(1); taskStartSpy.mockClear(); From bb4d73f269236f33523400d256f39b59300788fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Thu, 8 Oct 2026 14:12:07 +0200 Subject: [PATCH 18/31] chore: use the `ReentrantAsyncLock` class instead of a custom lock implementation --- src/actor.ts | 35 ++++-------------------------- src/charging.ts | 23 +------------------- src/child_run_tracker.ts | 47 +++++++++++++++++++++++++++++----------- src/utils.ts | 21 ++++++++++++++++++ 4 files changed, 60 insertions(+), 66 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 232d54f995..01ffab98fb 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -52,7 +52,7 @@ import { ApifyStorageBackend } from './apify_storage_backend.js'; import type { ChargeOptions, ChargeResult } from './charging.js'; import { ChargingManager, DEFAULT_DATASET_ITEM_EVENT } from './charging.js'; import { ChargingStorageBackend } from './charging_storage_backend.js'; -import { type ChildRunRequest, ChildRunTracker, type TrackedChildRun } from './child_run_tracker.js'; +import { ChildRunTracker, type TrackedChildRun } from './child_run_tracker.js'; import type { ConfigurationOptions } from './configuration.js'; import { Configuration } from './configuration.js'; import { ActorInputError } from './errors.js'; @@ -887,7 +887,7 @@ export class Actor { if (!runName) return client.actor(actorId).call(input, { ...rest, runTimeoutSecs }); const { waitSecs, log, ...startOptions } = rest; - const { run, resumed } = await this.#startOrResumeChildRun( + const { run, resumed } = await this.#childRunTracker.startOrResumeChildRun( client, runName, { type: 'actor', id: actorId, input }, @@ -936,7 +936,7 @@ export class Actor { if (!runName) return client.actor(actorId).start(input, { ...rest, runTimeoutSecs }); - const { run } = await this.#startOrResumeChildRun( + const { run } = await this.#childRunTracker.startOrResumeChildRun( client, runName, { type: 'actor', id: actorId, input }, @@ -1007,7 +1007,7 @@ export class Actor { if (!runName) return client.task(taskId).call(input, { ...rest, runTimeoutSecs }); const { waitSecs, ...startOptions } = rest; - const { run } = await this.#startOrResumeChildRun( + const { run } = await this.#childRunTracker.startOrResumeChildRun( client, runName, { type: 'task', id: taskId, input }, @@ -1018,33 +1018,6 @@ export class Actor { return finishedRun; } - /** - * Returns the child run tracked under `runName` if it is still in progress or has succeeded. - * Otherwise (the run failed, was aborted, timed out or is gone), starts a new one using `start` - * and tracks it under `runName`, keeping the previous one in the history. - */ - async #startOrResumeChildRun( - client: ApifyClient, - runName: string, - request: ChildRunRequest, - start: () => Promise, - ): Promise<{ run: ClientActorRun; resumed: boolean }> { - return this.#childRunTracker.withLock(runName, async () => { - const tracked = await this.#childRunTracker.get(runName); - const trackedRun = tracked ? await client.run(tracked.runId).get() : undefined; - - if (trackedRun && ['SUCCEEDED', 'READY', 'RUNNING'].includes(trackedRun.status)) { - await this.#childRunTracker.verifyRequest(runName, request); - await this.#childRunTracker.update(runName, trackedRun); - return { run: trackedRun, resumed: true }; - } - - const run = await start(); - await this.#childRunTracker.track(runName, run, request, trackedRun?.status ?? 'LOST'); - return { run, resumed: false }; - }); - } - /** * Returns the child runs started with the `runName` option, keyed by that name, including the runs * that were replaced under the same name in `history`. diff --git a/src/charging.ts b/src/charging.ts index eaa529ff98..53036363f4 100644 --- a/src/charging.ts +++ b/src/charging.ts @@ -1,11 +1,10 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; - import { Dataset, KeyValueStore } from '@crawlee/core'; import type { ActorRunPricingInfo, ApifyClient } from 'apify-client'; import log from '@apify/log'; import type { Configuration } from './configuration.js'; +import { ReentrantAsyncLock } from './utils.js'; interface ChargingStateItem { chargeCount: number; @@ -74,26 +73,6 @@ export interface ActorPricingInfo { perEventPrices: Record; } -/** - * A FIFO mutex that a critical section may re-enter from a nested call — the charge lock is taken by - * `Actor.pushData()` and again, one level down, by the dataset backend it pushes through. - */ -class ReentrantAsyncLock { - #tail: Promise = Promise.resolve(); - readonly #held = new AsyncLocalStorage(); - - async runExclusive(fn: () => Promise): Promise { - if (this.#held.getStore()) { - return await fn(); - } - - const run = this.#tail.then(async () => this.#held.run(true, fn)); - // Keep the chain alive even when the critical section throws. - this.#tail = run.catch(() => {}); - return await run; - } -} - /** * Handles pay-per-event charging. */ diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index 8f192d84bb..0c02d93fc4 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -2,6 +2,8 @@ import { createHash } from 'node:crypto'; import { KeyValueStore, withDirectStorageAccess } from '@crawlee/core'; import type { ActorRun } from 'apify-client'; +import { ReentrantAsyncLock } from './utils.js'; +import type { ApifyClient } from './index.js'; /** `LOST` marks a tracked run that the platform no longer returns. */ export type ChildRunStatus = ActorRun['status'] | 'LOST'; @@ -58,19 +60,7 @@ const CHILD_RUNS_KVS_KEY = '__ACTOR_CHILD_RUNS'; export class ChildRunTracker { private trackedRuns?: Promise>; private lastWrite: Promise = Promise.resolve(); - private locks = new Map>(); - - /** - * Runs `fn` once the earlier calls for the same `runName` have settled. - */ - async withLock(runName: string, fn: () => Promise): Promise { - const result = (this.locks.get(runName) ?? Promise.resolve()).then(fn); - this.locks.set( - runName, - result.catch(() => {}), - ); - return result; - } + private locks = new Map(); /** * Throws if the run tracked under `runName` was started for a different Actor / task or input, @@ -86,6 +76,37 @@ export class ChildRunTracker { } } + /** + * Returns the child run tracked under `runName` if it is still in progress or has succeeded. + * Otherwise (the run failed, was aborted, timed out or is gone), starts a new one using `start` + * and tracks it under `runName`, keeping the previous one in the history. + */ + async startOrResumeChildRun( + client: ApifyClient, + runName: string, + request: ChildRunRequest, + start: () => Promise, + ): Promise<{ run: ActorRun; resumed: boolean }> { + if (!this.locks.has(runName)) { + this.locks.set(runName, new ReentrantAsyncLock()); + } + + return this.locks.get(runName).runExclusive(async () => { + const tracked = await this.get(runName); + const trackedRun = tracked ? await client.run(tracked.runId).get() : undefined; + + if (trackedRun && ['SUCCEEDED', 'READY', 'RUNNING'].includes(trackedRun.status)) { + await this.verifyRequest(runName, request); + await this.update(runName, trackedRun); + return { run: trackedRun, resumed: true }; + } + + const run = await start(); + await this.track(runName, run, request, trackedRun?.status ?? 'LOST'); + return { run, resumed: false }; + }); + } + /** * Tracks `run` as the current run under `runName`. A run tracked earlier under the same name is moved to the * history, with `replacedStatus` as its last known status. diff --git a/src/utils.ts b/src/utils.ts index c085103004..a569c5b15b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -15,6 +15,7 @@ import log from '@apify/log'; // @ts-ignore if we enable resolveJsonModule, we end up with `src` folder in `dist` import apifyPkgJson from '../package.json' with { type: 'json' }; +import { AsyncLocalStorage } from 'node:async_hooks'; export { ArgumentValidationError } from '@apify/validations'; @@ -138,3 +139,23 @@ export function printOutdatedSdkWarning() { log.warning(`You are using an outdated version (${apifyPkgJson.version}) of Apify SDK. We recommend you to update to the latest version (${latestApifyVersion}). Read more about Apify SDK versioning at: https://help.apify.com/en/articles/3184510-updates-and-versioning-of-apify-sdk`); } + +/** + * A FIFO mutex that a critical section may re-enter from a nested call — the charge lock is taken by + * `Actor.pushData()` and again, one level down, by the dataset backend it pushes through. + */ +export class ReentrantAsyncLock { + #tail: Promise = Promise.resolve(); + readonly #held = new AsyncLocalStorage(); + + async runExclusive(fn: () => Promise): Promise { + if (this.#held.getStore()) { + return await fn(); + } + + const run = this.#tail.then(async () => this.#held.run(true, fn)); + // Keep the chain alive even when the critical section throws. + this.#tail = run.catch(() => {}); + return await run; + } +} From 2e974c69caa9d376b39ab4ef85b5af5328e063fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Thu, 8 Oct 2026 14:23:05 +0200 Subject: [PATCH 19/31] chore: fix lint error --- src/child_run_tracker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index 0c02d93fc4..25ace32913 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -91,7 +91,7 @@ export class ChildRunTracker { this.locks.set(runName, new ReentrantAsyncLock()); } - return this.locks.get(runName).runExclusive(async () => { + return this.locks.get(runName)!.runExclusive(async () => { const tracked = await this.get(runName); const trackedRun = tracked ? await client.run(tracked.runId).get() : undefined; From 452d7f615c1f77187badbc69671dbceec8bc909f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Fri, 9 Oct 2026 10:32:36 +0200 Subject: [PATCH 20/31] docs: document the `LOST` runs better --- src/child_run_tracker.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index 25ace32913..6d7c79acc9 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -5,7 +5,12 @@ import type { ActorRun } from 'apify-client'; import { ReentrantAsyncLock } from './utils.js'; import type { ApifyClient } from './index.js'; -/** `LOST` marks a tracked run that the platform no longer returns. */ +/** + * Status of a tracked child run. This copies the `ActorRun['status']` type, + * but adds `LOST` to mark a locally tracked run that the platform no longer returns. + * + * `LOST` runs can happen as a result of a manual removal or expiration. + */ export type ChildRunStatus = ActorRun['status'] | 'LOST'; export interface ChildRunInfo { From a76459d609fcb3344311c83f3185ede0b140250f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Fri, 9 Oct 2026 10:37:26 +0200 Subject: [PATCH 21/31] chore: lock apify-client version --- package.json | 2 +- pnpm-lock.yaml | 19 +++++-------------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 593279d45d..761f198de1 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "@crawlee/fs-storage": "4.0.0-beta.200", "@crawlee/types": "4.0.0-beta.200", "@crawlee/utils": "4.0.0-beta.200", - "apify-client": "^3.0.0-beta.28", + "apify-client": "3.0.0-beta.35", "minimatch": "^10.2.6", "semver": "^7.8.5", "tslib": "^2.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ff9065d2b..74c9135f65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -211,8 +211,8 @@ importers: specifier: 4.0.0-beta.200 version: 4.0.0-beta.200(supports-color@8.1.1) apify-client: - specifier: ^3.0.0-beta.28 - version: 3.0.0-beta.31(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) + specifier: 3.0.0-beta.35 + version: 3.0.0-beta.35(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) minimatch: specifier: ^10.2.6 version: 10.2.6 @@ -1390,10 +1390,6 @@ packages: resolution: {integrity: sha512-T2niGD0IWNbigwiL64Ydf2qLWRqbnD6pOp6xu8fdLzlCs69/tP/vsjgoXmc/P7p0phfJhAV7GE9TZ6o+eRwT5A==} engines: {node: '>=22.0.0'} - '@crawlee/types@4.0.0-beta.199': - resolution: {integrity: sha512-fgWtedO+jvI41ClVQKxhLqhdpKsBW5+FtFPMpBOo++uUDZaPhPR5lD28nZsgMmC0/zaeBQCkKWK4oBpedWiQxQ==} - engines: {node: '>=22.0.0'} - '@crawlee/types@4.0.0-beta.200': resolution: {integrity: sha512-DqqKf1A5vFBMiGaUf/RkZRlzAETMo/1blH+uHdBnG0P+JSPxXjHURIzvXClN1puh9U58NrUG1blU5cJOWhKGQA==} engines: {node: '>=22.0.0'} @@ -4482,8 +4478,8 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - apify-client@3.0.0-beta.31: - resolution: {integrity: sha512-5BkRa2ARxYDB7AaHnNKCbYkNR70QOmiSf+SLKcn7TsXmNc9PRMuwSNg55Et7/qfAE/APvKgcQmLhrwK2R8ee8w==} + apify-client@3.0.0-beta.35: + resolution: {integrity: sha512-QKBxk/mGe77oLJSdylyHVuvRMUWFnwjtO/GZcWEJGeFSlfVMIozadlvvSTdZESEOcv3vUgwxVJqmzowyhBU1RQ==} engines: {node: '>=22.0.0'} arg@5.0.2: @@ -10759,10 +10755,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@crawlee/types@4.0.0-beta.199': - dependencies: - tslib: 2.8.1 - '@crawlee/types@4.0.0-beta.200': dependencies: tslib: 2.8.1 @@ -14508,13 +14500,12 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 - apify-client@3.0.0-beta.31(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1): + apify-client@3.0.0-beta.35(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1): dependencies: '@apify/consts': 3.0.1 '@apify/log': 3.0.1 '@apify/utilities': 3.1.0 '@apify/validations': 1.1.1 - '@crawlee/types': 4.0.0-beta.199 ansi-colors: 4.1.3 axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) content-type: 3.1.1 From c83bcc6e1d9c475bd206a12d3cb56b277c178f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Fri, 9 Oct 2026 10:59:21 +0200 Subject: [PATCH 22/31] chore: fix failing build --- src/child_run_tracker.ts | 2 +- test/apify/child_runs.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index 6d7c79acc9..886573a722 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { KeyValueStore, withDirectStorageAccess } from '@crawlee/core'; -import type { ActorRun } from 'apify-client'; +import type { Run as ActorRun } from 'apify-client'; import { ReentrantAsyncLock } from './utils.js'; import type { ApifyClient } from './index.js'; diff --git a/test/apify/child_runs.test.ts b/test/apify/child_runs.test.ts index e947d7611e..d11cb813de 100644 --- a/test/apify/child_runs.test.ts +++ b/test/apify/child_runs.test.ts @@ -1,6 +1,6 @@ import { MemoryStorageBackend } from '@crawlee/core'; import type { Actor } from 'apify'; -import type { ActorRun } from 'apify-client'; +import type { Run as ActorRun } from 'apify-client'; import { ActorClient, RunClient, TaskClient } from 'apify-client'; import type { MockInstance } from 'vitest'; import { createIsolatedActor } from '../createIsolatedActor'; From 8143fd72851f0e8c0655133bfd1c238e919fc404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Fri, 9 Oct 2026 11:24:47 +0200 Subject: [PATCH 23/31] feat: expose tracked child runs as a synchronous `Actor.childRuns` getter --- src/actor.ts | 40 +++++++++++++------ src/child_run_tracker.ts | 29 +++++++++++--- src/index.ts | 1 - test/apify/charging.test.ts | 7 +++- test/apify/child_runs.test.ts | 75 ++++++++++++++++++++++++++--------- 5 files changed, 112 insertions(+), 40 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 17de216e5c..c5cce0da4a 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -27,6 +27,7 @@ import type { ActorStartOptions, ApifyClientOptions, RunAbortOptions, + RunClient, TaskCallOptions, WebhookResource, WebhookEventType, @@ -52,7 +53,7 @@ import { ApifyStorageBackend } from './apify_storage_backend.js'; import type { ChargeOptions, ChargeResult } from './charging.js'; import { ChargingManager, DEFAULT_DATASET_ITEM_EVENT } from './charging.js'; import { ChargingStorageBackend } from './charging_storage_backend.js'; -import { ChildRunTracker, type TrackedChildRun } from './child_run_tracker.js'; +import { ChildRunTracker } from './child_run_tracker.js'; import type { ConfigurationOptions } from './configuration.js'; import { Configuration } from './configuration.js'; import { ActorInputError } from './errors.js'; @@ -721,6 +722,8 @@ export class Actor { }); log.debug(`Default storages purged`); + await this.#childRunTracker.load(); + await this.#chargingManager.init(); log.debug(`ChargingManager initialized`, this.#chargingManager.getPricingInfo()); @@ -1019,14 +1022,20 @@ export class Actor { } /** - * Returns the child runs started with the `runName` option, keyed by that name, including the runs - * that were replaced under the same name in `history`. + * Clients for the child runs started with the `runName` option, keyed by that name, including the runs started + * before a migration or resurrection of this run. Each client points to the current run under its name. + * + * ```js + * await Actor.childRuns['my-child'].waitForFinish(); + * ``` * - * The statuses are the last ones this Actor observed: a run nobody waits for is not refreshed. - * The same record is stored in the default key-value store under the `__ACTOR_CHILD_RUNS` key. + * A run started with a custom `token` uses that token, except for a run started before a migration or + * resurrection, which uses the default client. The tracked runs, including the earlier runs replaced + * under the same name, are stored in the default key-value store under the `__ACTOR_CHILD_RUNS` key. */ - async childRuns(): Promise> { - return this.#childRunTracker.getAll(); + get childRuns(): Record { + this.ensureActorInit('childRuns'); + return this.#childRunTracker.getRunClients(this.apifyClient); } /** @@ -2007,14 +2016,19 @@ export class Actor { } /** - * Returns the child runs started with the `runName` option, keyed by that name, including the runs - * that were replaced under the same name in `history`. + * Clients for the child runs started with the `runName` option, keyed by that name, including the runs started + * before a migration or resurrection of this run. Each client points to the current run under its name. + * + * ```js + * await Actor.childRuns['my-child'].waitForFinish(); + * ``` * - * The statuses are the last ones this Actor observed: a run nobody waits for is not refreshed. - * The same record is stored in the default key-value store under the `__ACTOR_CHILD_RUNS` key. + * A run started with a custom `token` uses that token, except for a run started before a migration or + * resurrection, which uses the default client. The tracked runs, including the earlier runs replaced + * under the same name, are stored in the default key-value store under the `__ACTOR_CHILD_RUNS` key. */ - static async childRuns(): Promise> { - return Actor.getDefaultInstance().childRuns(); + static get childRuns(): Record { + return Actor.getDefaultInstance().childRuns; } /** diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index 886573a722..db8e705d01 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { KeyValueStore, withDirectStorageAccess } from '@crawlee/core'; -import type { Run as ActorRun } from 'apify-client'; +import type { Run as ActorRun, RunClient } from 'apify-client'; import { ReentrantAsyncLock } from './utils.js'; import type { ApifyClient } from './index.js'; @@ -64,6 +64,9 @@ const CHILD_RUNS_KVS_KEY = '__ACTOR_CHILD_RUNS'; export class ChildRunTracker { private trackedRuns?: Promise>; + private loadedRuns: Record = {}; + /** Client each name was last started or resumed with in this process, so a custom `token` is kept. */ + private clients = new Map(); private lastWrite: Promise = Promise.resolve(); private locks = new Map(); @@ -103,11 +106,13 @@ export class ChildRunTracker { if (trackedRun && ['SUCCEEDED', 'READY', 'RUNNING'].includes(trackedRun.status)) { await this.verifyRequest(runName, request); await this.update(runName, trackedRun); + this.clients.set(runName, client); return { run: trackedRun, resumed: true }; } const run = await start(); await this.track(runName, run, request, trackedRun?.status ?? 'LOST'); + this.clients.set(runName, client); return { run, resumed: false }; }); } @@ -156,10 +161,16 @@ export class ChildRunTracker { } /** - * Returns a copy of all the tracked runs, keyed by run name. + * Returns a `RunClient` for the current run under each name loaded so far. The client is the one the name was + * last started or resumed with in this process, or `defaultClient` (e.g. for a run tracked before a migration). */ - async getAll() { - return structuredClone(await this.load()); + getRunClients(defaultClient: ApifyClient): Record { + return Object.fromEntries( + Object.entries(this.loadedRuns).map(([runName, { runId }]) => [ + runName, + (this.clients.get(runName) ?? defaultClient).run(runId), + ]), + ); } private async persist(trackedRuns: Record) { @@ -173,10 +184,16 @@ export class ChildRunTracker { await write; } - private async load() { + /** + * Loads the tracked runs from the default key-value store, once. + */ + async load() { this.trackedRuns ??= KeyValueStore.open() .then(async (defaultStore) => defaultStore.getValue>(CHILD_RUNS_KVS_KEY)) - .then((storedRuns) => storedRuns ?? {}) + .then((storedRuns) => { + this.loadedRuns = storedRuns ?? {}; + return this.loadedRuns; + }) .catch((error) => { this.trackedRuns = undefined; throw error; diff --git a/src/index.ts b/src/index.ts index a4187f13f1..8e728ec08f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,6 @@ export { type UrlPatternRequestOptions, } from './enqueue_links_filters.js'; export type { OpenStorageOptions, StorageAlias, StorageId, StorageName, StorageIdentifier } from './storage.js'; -export type { ChildRunInfo, ChildRunStatus, TrackedChildRun } from './child_run_tracker.js'; export { ChargeOptions, ChargeResult, ActorPricingInfo, ChargingManager } from './charging.js'; export * from './configuration.js'; export * from './proxy_configuration.js'; diff --git a/test/apify/charging.test.ts b/test/apify/charging.test.ts index 22bc091778..3e921b2f3b 100644 --- a/test/apify/charging.test.ts +++ b/test/apify/charging.test.ts @@ -5,7 +5,8 @@ import { withStorageTransaction, } from '@crawlee/core'; import { Actor } from 'apify'; -import type { RunClient } from 'apify-client'; +import type { KeyValueStoreResource, RunClient } from 'apify-client'; +import { KeyValueStoreClient } from 'apify-client'; import type { MockInstance } from 'vitest'; import { afterEach, beforeEach, describe, expect, test } from 'vitest'; @@ -53,6 +54,10 @@ function setUpPlatformEnv( } process.env.APIFY_CHARGED_ACTOR_EVENT_COUNTS = JSON.stringify(options.chargedEventCounts ?? {}); + + // `Actor.init()` reads the tracked child runs from the default key-value store. + vitest.spyOn(KeyValueStoreClient.prototype, 'get').mockResolvedValue({ id: 'default' } as KeyValueStoreResource); + vitest.spyOn(KeyValueStoreClient.prototype, 'getRecord').mockResolvedValue(undefined); } /** diff --git a/test/apify/child_runs.test.ts b/test/apify/child_runs.test.ts index d11cb813de..96cc2dea71 100644 --- a/test/apify/child_runs.test.ts +++ b/test/apify/child_runs.test.ts @@ -3,6 +3,10 @@ import type { Actor } from 'apify'; import type { Run as ActorRun } from 'apify-client'; import { ActorClient, RunClient, TaskClient } from 'apify-client'; import type { MockInstance } from 'vitest'; + +import log from '@apify/log'; + +import type { TrackedChildRun } from '../../src/child_run_tracker.js'; import { createIsolatedActor } from '../createIsolatedActor'; const globalOptions = { @@ -35,7 +39,8 @@ describe('child run tracking with `runName`', () => { let waitForFinishSpy: MockInstance; let getStreamedLogSpy: MockInstance; - const getChildRunLedger = async (actor: Actor) => (await actor.openKeyValueStore()).getValue('__ACTOR_CHILD_RUNS'); + const getChildRunLedger = async (actor: Actor) => + (await actor.openKeyValueStore()).getValue>('__ACTOR_CHILD_RUNS'); const newActor = () => createIsolatedActor({ storageClient: storage }).actor; beforeEach(() => { @@ -81,7 +86,7 @@ describe('child run tracking with `runName`', () => { expect(startSpy).not.toBeCalled(); expect(run).toEqual({ ...startedRun, status }); expect(getSpy).toBeCalledTimes(1); - expect((await actor.childRuns())[runName]).toEqual({ ...stored, status, history: [] }); + expect(await getChildRunLedger(actor)).toEqual({ [runName]: { ...stored, status, history: [] } }); }, ); @@ -119,7 +124,7 @@ describe('child run tracking with `runName`', () => { await actor.start(actId, input, { runName }); expect(startSpy).toBeCalledTimes(1); - expect((await actor.childRuns())[runName].history).toEqual([{ ...trackedInfo, status: 'LOST' }]); + expect((await getChildRunLedger(actor))![runName].history).toEqual([{ ...trackedInfo, status: 'LOST' }]); }); test('repeated replacements accumulate in the history, oldest first', async () => { @@ -132,7 +137,7 @@ describe('child run tracking with `runName`', () => { startSpy.mockResolvedValueOnce({ ...startedRun, id: 'third' }); await actor.start(actId, input, { runName }); - const tracked = (await actor.childRuns())[runName]; + const tracked = (await getChildRunLedger(actor))![runName]; expect(tracked.runId).toBe('third'); expect(tracked.history.map(({ runId, status }) => [runId, status])).toEqual([ [startedRun.id, 'FAILED'], @@ -140,16 +145,6 @@ describe('child run tracking with `runName`', () => { ]); }); - test('childRuns() is empty without tracked runs and returns a copy', async () => { - const actor = newActor(); - expect(await actor.childRuns()).toEqual({}); - - await actor.start(actId, input, { runName }); - (await actor.childRuns())[runName].status = 'FAILED'; - - expect((await actor.childRuns())[runName].status).toBe('RUNNING'); - }); - test('start() tracks different run names independently', async () => { const actor = newActor(); startSpy.mockResolvedValueOnce({ ...startedRun, id: 'first' }).mockResolvedValueOnce({ @@ -159,9 +154,51 @@ describe('child run tracking with `runName`', () => { await Promise.all([actor.start(actId, input, { runName: 'a' }), actor.start(actId, input, { runName: 'b' })]); - expect( - Object.fromEntries(Object.entries(await actor.childRuns()).map(([name, { runId }]) => [name, runId])), - ).toEqual({ a: 'first', b: 'second' }); + expect(Object.fromEntries(Object.entries(actor.childRuns).map(([name, { id }]) => [name, id]))).toEqual({ + a: 'first', + b: 'second', + }); + }); + + test('childRuns returns a client for the current run under each name', async () => { + const actor = newActor(); + await actor.start(actId, input, { runName }); + getSpy.mockResolvedValue({ ...startedRun, status: 'FAILED' }); + startSpy.mockResolvedValueOnce({ ...startedRun, id: 'replacement-run' }); + await actor.start(actId, input, { runName }); + + const runClient = actor.childRuns[runName]; + + expect(runClient.id).toBe('replacement-run'); + expect(await runClient.waitForFinish()).toEqual(finishedRun); + }); + + test('init() loads the child runs tracked before a migration', async () => { + await newActor().start(actId, input, { runName }); + + const actor = newActor(); + await actor.init(); + + expect(Object.keys(actor.childRuns)).toEqual([runName]); + expect(actor.childRuns[runName].id).toBe(startedRun.id); + }); + + test('childRuns uses the token a run was started with in this process', async () => { + await newActor().start(actId, input, { runName: 'before-migration' }); + + const actor = newActor(); + await actor.init(); + await actor.start(actId, input, { runName: 'custom-token', token: 'custom-token' }); + + expect(actor.childRuns['before-migration'].apifyClient).toBe(actor.apifyClient); + expect(actor.childRuns['custom-token'].apifyClient.token).toBe('custom-token'); + }); + + test('childRuns warns when accessed before init()', () => { + const warningSpy = vitest.spyOn(log, 'warning'); + + expect(newActor().childRuns).toEqual({}); + expect(warningSpy).toHaveBeenCalledWith(expect.stringContaining('Actor.init()')); }); test('reusing a run name for a different Actor in the same process throws', async () => { @@ -235,7 +272,7 @@ describe('child run tracking with `runName`', () => { expect(run).toEqual(finishedRun); expect(startSpy).not.toBeCalled(); expect(getStreamedLogSpy).toBeCalledWith({ toLog: undefined, fromStart: false }); - expect((await actor.childRuns())[runName].status).toBe('SUCCEEDED'); + expect((await getChildRunLedger(actor))![runName].status).toBe('SUCCEEDED'); }); test('call() stops the log stream when waiting for the run fails', async () => { @@ -269,6 +306,6 @@ describe('child run tracking with `runName`', () => { expect(run).toEqual(finishedRun); expect(taskStartSpy).not.toBeCalled(); expect(waitForFinishSpy).toHaveBeenLastCalledWith({ waitSecs: 5 }); - expect((await actor.childRuns())[runName].status).toBe('SUCCEEDED'); + expect((await getChildRunLedger(actor))![runName].status).toBe('SUCCEEDED'); }); }); From 476b80008fedb5f04b795b72f5688a23d0b298e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Mon, 12 Oct 2026 10:59:47 +0200 Subject: [PATCH 24/31] chore: address nits --- src/actor.ts | 3 --- src/child_run_tracker.ts | 22 +++++++++++----------- src/utils.ts | 5 ++--- test/apify/child_runs.test.ts | 2 +- 4 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index c5cce0da4a..10f8c869bf 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -461,9 +461,6 @@ export class Actor { /** @internal */ static #instance?: Actor; - /** - * Tracks child runs of this Actor instance. - */ #childRunTracker: ChildRunTracker; /** diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index db8e705d01..593ba32eb9 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -63,8 +63,8 @@ const toInfo = (run: ActorRun): ChildRunInfo => ({ const CHILD_RUNS_KVS_KEY = '__ACTOR_CHILD_RUNS'; export class ChildRunTracker { - private trackedRuns?: Promise>; - private loadedRuns: Record = {}; + private trackedRuns: Record = {}; + private loading?: Promise; /** Client each name was last started or resumed with in this process, so a custom `token` is kept. */ private clients = new Map(); private lastWrite: Promise = Promise.resolve(); @@ -136,7 +136,7 @@ export class ChildRunTracker { : [], }; - await this.persist(trackedRuns); + await this.persist(); } /** @@ -149,7 +149,7 @@ export class ChildRunTracker { if (tracked?.runId !== run.id || tracked.status === run.status) return; tracked.status = run.status; - await this.persist(trackedRuns); + await this.persist(); } /** @@ -166,18 +166,18 @@ export class ChildRunTracker { */ getRunClients(defaultClient: ApifyClient): Record { return Object.fromEntries( - Object.entries(this.loadedRuns).map(([runName, { runId }]) => [ + Object.entries(this.trackedRuns).map(([runName, { runId }]) => [ runName, (this.clients.get(runName) ?? defaultClient).run(runId), ]), ); } - private async persist(trackedRuns: Record) { + private async persist() { const write = this.lastWrite.then(async () => withDirectStorageAccess(async () => { const defaultStore = await KeyValueStore.open(); - await defaultStore.setValue(CHILD_RUNS_KVS_KEY, trackedRuns); + await defaultStore.setValue(CHILD_RUNS_KVS_KEY, this.trackedRuns); }), ); this.lastWrite = write.catch(() => {}); @@ -188,17 +188,17 @@ export class ChildRunTracker { * Loads the tracked runs from the default key-value store, once. */ async load() { - this.trackedRuns ??= KeyValueStore.open() + this.loading ??= KeyValueStore.open() .then(async (defaultStore) => defaultStore.getValue>(CHILD_RUNS_KVS_KEY)) .then((storedRuns) => { - this.loadedRuns = storedRuns ?? {}; - return this.loadedRuns; + this.trackedRuns = storedRuns ?? {}; }) .catch((error) => { - this.trackedRuns = undefined; + this.loading = undefined; throw error; }); + await this.loading; return this.trackedRuns; } } diff --git a/src/utils.ts b/src/utils.ts index a569c5b15b..4d20e417c0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import { existsSync, readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { type } from 'node:os'; @@ -15,7 +16,6 @@ import log from '@apify/log'; // @ts-ignore if we enable resolveJsonModule, we end up with `src` folder in `dist` import apifyPkgJson from '../package.json' with { type: 'json' }; -import { AsyncLocalStorage } from 'node:async_hooks'; export { ArgumentValidationError } from '@apify/validations'; @@ -141,8 +141,7 @@ export function printOutdatedSdkWarning() { } /** - * A FIFO mutex that a critical section may re-enter from a nested call — the charge lock is taken by - * `Actor.pushData()` and again, one level down, by the dataset backend it pushes through. + * A FIFO mutex that a critical section may re-enter from a nested call without deadlocking. */ export class ReentrantAsyncLock { #tail: Promise = Promise.resolve(); diff --git a/test/apify/child_runs.test.ts b/test/apify/child_runs.test.ts index 96cc2dea71..d8b555dd89 100644 --- a/test/apify/child_runs.test.ts +++ b/test/apify/child_runs.test.ts @@ -7,7 +7,7 @@ import type { MockInstance } from 'vitest'; import log from '@apify/log'; import type { TrackedChildRun } from '../../src/child_run_tracker.js'; -import { createIsolatedActor } from '../createIsolatedActor'; +import { createIsolatedActor } from '../createIsolatedActor.js'; const globalOptions = { token: 'some-token', From ec5e6fa76c2a66f3d491bb30a8c263ca7f6d6911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Mon, 12 Oct 2026 11:21:56 +0200 Subject: [PATCH 25/31] fix: forward `signal` and `timeoutSecs` in named `call` and `callTask` --- src/actor.ts | 14 +++++++------- test/apify/child_runs.test.ts | 25 ++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 10f8c869bf..972820d525 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -886,19 +886,19 @@ export class Actor { if (!runName) return client.actor(actorId).call(input, { ...rest, runTimeoutSecs }); - const { waitSecs, log, ...startOptions } = rest; + const { waitSecs, log, timeoutSecs = 'noTimeout', signal, ...startOptions } = rest; const { run, resumed } = await this.#childRunTracker.startOrResumeChildRun( client, runName, { type: 'actor', id: actorId, input }, - async () => client.actor(actorId).start(input, { ...startOptions, runTimeoutSecs }), + async () => client.actor(actorId).start(input, { ...startOptions, runTimeoutSecs, timeoutSecs, signal }), ); // The earlier part of a resumed run's log was already redirected before the migration. - const streamedLog = await client.run(run.id).getStreamedLog({ toLog: log, fromStart: !resumed }); + const streamedLog = await client.run(run.id).getStreamedLog({ toLog: log, fromStart: !resumed, signal }); streamedLog?.start(); try { - const finishedRun = await client.run(run.id).waitForFinish({ waitSecs }); + const finishedRun = await client.run(run.id).waitForFinish({ waitSecs, timeoutSecs, signal }); await this.#childRunTracker.update(runName, finishedRun); return finishedRun; } finally { @@ -1006,14 +1006,14 @@ export class Actor { if (!runName) return client.task(taskId).call(input, { ...rest, runTimeoutSecs }); - const { waitSecs, ...startOptions } = rest; + const { waitSecs, timeoutSecs = 'noTimeout', signal, ...startOptions } = rest; const { run } = await this.#childRunTracker.startOrResumeChildRun( client, runName, { type: 'task', id: taskId, input }, - async () => client.task(taskId).start(input, { ...startOptions, runTimeoutSecs }), + async () => client.task(taskId).start(input, { ...startOptions, runTimeoutSecs, timeoutSecs, signal }), ); - const finishedRun = await client.run(run.id).waitForFinish({ waitSecs }); + const finishedRun = await client.run(run.id).waitForFinish({ waitSecs, timeoutSecs, signal }); await this.#childRunTracker.update(runName, finishedRun); return finishedRun; } diff --git a/test/apify/child_runs.test.ts b/test/apify/child_runs.test.ts index d8b555dd89..3835038e43 100644 --- a/test/apify/child_runs.test.ts +++ b/test/apify/child_runs.test.ts @@ -257,11 +257,21 @@ describe('child run tracking with `runName`', () => { const run = await actor.call(actId, input, { runName, waitSecs: 10 }); expect(run).toEqual(finishedRun); - expect(startSpy).toBeCalledTimes(1); - expect(waitForFinishSpy).toBeCalledWith({ waitSecs: 10 }); + expect(startSpy).toBeCalledWith(input, expect.objectContaining({ timeoutSecs: 'noTimeout' })); + expect(waitForFinishSpy).toBeCalledWith({ waitSecs: 10, timeoutSecs: 'noTimeout' }); expect(getStreamedLogSpy).toBeCalledWith({ toLog: undefined, fromStart: true }); }); + test('call() with a run name forwards the signal and request timeout like `ActorClient.call`', async () => { + const { signal } = new AbortController(); + + await newActor().call(actId, input, { runName, signal, timeoutSecs: 30 }); + + expect(startSpy).toBeCalledWith(input, expect.objectContaining({ signal, timeoutSecs: 30 })); + expect(getStreamedLogSpy).toBeCalledWith(expect.objectContaining({ signal })); + expect(waitForFinishSpy).toBeCalledWith({ timeoutSecs: 30, signal }); + }); + test('call() waits for the tracked run instead of starting a new one', async () => { await newActor().start(actId, input, { runName }); startSpy.mockClear(); @@ -305,7 +315,16 @@ describe('child run tracking with `runName`', () => { expect(run).toEqual(finishedRun); expect(taskStartSpy).not.toBeCalled(); - expect(waitForFinishSpy).toHaveBeenLastCalledWith({ waitSecs: 5 }); + expect(waitForFinishSpy).toHaveBeenLastCalledWith({ waitSecs: 5, timeoutSecs: 'noTimeout' }); expect((await getChildRunLedger(actor))![runName].status).toBe('SUCCEEDED'); }); + + test('callTask() with a run name forwards the signal and request timeout like `TaskClient.call`', async () => { + const { signal } = new AbortController(); + + await newActor().callTask(taskId, input, { runName, signal, timeoutSecs: 30 }); + + expect(taskStartSpy).toBeCalledWith(input, expect.objectContaining({ signal, timeoutSecs: 30 })); + expect(waitForFinishSpy).toBeCalledWith({ timeoutSecs: 30, signal }); + }); }); From be2e9111f628a8dd006369abec71467d8a7a3465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 13 Oct 2026 17:14:21 +0200 Subject: [PATCH 26/31] feat: resurrect aborted and timed out child runs instead of replacing them Runs that are still aborting or timing out are awaited first, as only finished runs can be resurrected. The resurrection gets the build, memory, run timeout and limits of the call. --- src/actor.ts | 21 ++++--- src/child_run_tracker.ts | 41 ++++++++++--- test/apify/child_runs.test.ts | 112 +++++++++++++++++++++++++++++----- 3 files changed, 145 insertions(+), 29 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 7f908350da..7d938c4470 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -334,8 +334,9 @@ export interface ChildRunOptions { * Local name for the child run, unique across all Actors and tasks started by this run. * * If a `READY`, `RUNNING` or `SUCCEEDED` run is already tracked under this name, it is returned instead of - * starting a new one, including after a migration or resurrection of this run. A run that failed, was aborted - * or timed out is replaced by a new one. Reusing the name for a different Actor, task or input throws. + * starting a new one, including after a migration or resurrection of this run. A run that was aborted or timed out + * is resurrected with the given options, once it finishes aborting or timing out. A run that failed is replaced + * by a new one. Reusing the name for a different Actor, task or input throws. */ runName?: string; } @@ -890,12 +891,14 @@ export class Actor { if (!runName) return client.actor(actorId).call(input, { ...rest, runTimeoutSecs }); - const { waitSecs, log, timeoutSecs = 'noTimeout', signal, ...startOptions } = rest; + const { waitSecs, log, timeoutSecs = 'noTimeout', signal, ...otherOptions } = rest; + const startOptions = { ...otherOptions, runTimeoutSecs, timeoutSecs, signal }; const { run, resumed } = await this.#childRunTracker.startOrResumeChildRun( client, runName, { type: 'actor', id: actorId, input }, - async () => client.actor(actorId).start(input, { ...startOptions, runTimeoutSecs, timeoutSecs, signal }), + startOptions, + async () => client.actor(actorId).start(input, startOptions), ); // The earlier part of a resumed run's log was already redirected before the migration. @@ -940,11 +943,13 @@ export class Actor { if (!runName) return client.actor(actorId).start(input, { ...rest, runTimeoutSecs }); + const startOptions = { ...rest, runTimeoutSecs }; const { run } = await this.#childRunTracker.startOrResumeChildRun( client, runName, { type: 'actor', id: actorId, input }, - async () => client.actor(actorId).start(input, { ...rest, runTimeoutSecs }), + startOptions, + async () => client.actor(actorId).start(input, startOptions), ); return run; } @@ -1036,12 +1041,14 @@ export class Actor { if (!runName) return client.task(taskId).call(input, { ...rest, runTimeoutSecs }); - const { waitSecs, timeoutSecs = 'noTimeout', signal, ...startOptions } = rest; + const { waitSecs, timeoutSecs = 'noTimeout', signal, ...otherOptions } = rest; + const startOptions = { ...otherOptions, runTimeoutSecs, timeoutSecs, signal }; const { run } = await this.#childRunTracker.startOrResumeChildRun( client, runName, { type: 'task', id: taskId, input }, - async () => client.task(taskId).start(input, { ...startOptions, runTimeoutSecs, timeoutSecs, signal }), + startOptions, + async () => client.task(taskId).start(input, startOptions), ); const finishedRun = await client.run(run.id).waitForFinish({ waitSecs, timeoutSecs, signal }); await this.#childRunTracker.update(runName, finishedRun); diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index 593ba32eb9..c88e7ea1c8 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { KeyValueStore, withDirectStorageAccess } from '@crawlee/core'; -import type { Run as ActorRun, RunClient } from 'apify-client'; +import type { Run as ActorRun, RunClient, RunResurrectOptions } from 'apify-client'; import { ReentrantAsyncLock } from './utils.js'; import type { ApifyClient } from './index.js'; @@ -62,6 +62,11 @@ const toInfo = (run: ActorRun): ChildRunInfo => ({ const CHILD_RUNS_KVS_KEY = '__ACTOR_CHILD_RUNS'; +/** Statuses that shortly end as `ABORTED` / `TIMED-OUT`. */ +const SETTLING_STATUSES = ['ABORTING', 'TIMING-OUT']; +const RESURRECTABLE_STATUSES = ['ABORTED', 'TIMED-OUT']; +const REUSABLE_STATUSES = ['READY', 'RUNNING', 'SUCCEEDED', ...RESURRECTABLE_STATUSES]; + export class ChildRunTracker { private trackedRuns: Record = {}; private loading?: Promise; @@ -85,14 +90,16 @@ export class ChildRunTracker { } /** - * Returns the child run tracked under `runName` if it is still in progress or has succeeded. - * Otherwise (the run failed, was aborted, timed out or is gone), starts a new one using `start` - * and tracks it under `runName`, keeping the previous one in the history. + * Returns the child run tracked under `runName` if it is still in progress or has succeeded, and resurrects it + * with `startOptions` if it was aborted or timed out (waiting for it to finish aborting or timing out first). + * Otherwise (the run failed or is gone), starts a new one using `start` and tracks it under `runName`, + * keeping the previous one in the history. */ async startOrResumeChildRun( client: ApifyClient, runName: string, request: ChildRunRequest, + startOptions: RunResurrectOptions, start: () => Promise, ): Promise<{ run: ActorRun; resumed: boolean }> { if (!this.locks.has(runName)) { @@ -100,14 +107,32 @@ export class ChildRunTracker { } return this.locks.get(runName)!.runExclusive(async () => { + const { build, memory, runTimeoutSecs, maxItems, maxTotalChargeUsd, restartOnError, timeoutSecs, signal } = + startOptions; const tracked = await this.get(runName); - const trackedRun = tracked ? await client.run(tracked.runId).get() : undefined; + let trackedRun = tracked ? await client.run(tracked.runId).get() : undefined; + + if (trackedRun && SETTLING_STATUSES.includes(trackedRun.status)) { + trackedRun = await client.run(trackedRun.id).waitForFinish({ timeoutSecs, signal }); + } - if (trackedRun && ['SUCCEEDED', 'READY', 'RUNNING'].includes(trackedRun.status)) { + if (trackedRun && REUSABLE_STATUSES.includes(trackedRun.status)) { await this.verifyRequest(runName, request); - await this.update(runName, trackedRun); + const run = RESURRECTABLE_STATUSES.includes(trackedRun.status) + ? await client.run(trackedRun.id).resurrect({ + build, + memory, + runTimeoutSecs, + maxItems, + maxTotalChargeUsd, + restartOnError, + timeoutSecs, + signal, + }) + : trackedRun; + await this.update(runName, run); this.clients.set(runName, client); - return { run: trackedRun, resumed: true }; + return { run, resumed: true }; } const run = await start(); diff --git a/test/apify/child_runs.test.ts b/test/apify/child_runs.test.ts index 3835038e43..a100012890 100644 --- a/test/apify/child_runs.test.ts +++ b/test/apify/child_runs.test.ts @@ -3,7 +3,9 @@ import type { Actor } from 'apify'; import type { Run as ActorRun } from 'apify-client'; import { ActorClient, RunClient, TaskClient } from 'apify-client'; import type { MockInstance } from 'vitest'; +import { onTestFinished } from 'vitest'; +import { ACTOR_ENV_VARS, APIFY_ENV_VARS } from '@apify/consts'; import log from '@apify/log'; import type { TrackedChildRun } from '../../src/child_run_tracker.js'; @@ -38,6 +40,7 @@ describe('child run tracking with `runName`', () => { let getSpy: MockInstance; let waitForFinishSpy: MockInstance; let getStreamedLogSpy: MockInstance; + let resurrectSpy: MockInstance; const getChildRunLedger = async (actor: Actor) => (await actor.openKeyValueStore()).getValue>('__ACTOR_CHILD_RUNS'); @@ -50,6 +53,7 @@ describe('child run tracking with `runName`', () => { getSpy = vitest.spyOn(RunClient.prototype, 'get').mockResolvedValue(startedRun); waitForFinishSpy = vitest.spyOn(RunClient.prototype, 'waitForFinish').mockResolvedValue(finishedRun); getStreamedLogSpy = vitest.spyOn(RunClient.prototype, 'getStreamedLog').mockResolvedValue(undefined); + resurrectSpy = vitest.spyOn(RunClient.prototype, 'resurrect').mockResolvedValue(startedRun); }); test('start() without a run name does not track the run', async () => { @@ -90,30 +94,88 @@ describe('child run tracking with `runName`', () => { }, ); - test.each(['FAILED', 'ABORTING', 'ABORTED', 'TIMING-OUT', 'TIMED-OUT'] as const)( - 'start() starts a new run when the tracked run is in the %s status and keeps the old one in the history', + test('start() starts a new run when the tracked run failed and keeps the old one in the history', async () => { + await newActor().start(actId, input, { runName }); + startSpy.mockClear(); + getSpy.mockResolvedValue({ ...startedRun, status: 'FAILED' }); + const replacement = { id: 'replacement-run', status: 'RUNNING', startedAt } as ActorRun; + startSpy.mockResolvedValue(replacement); + + const actor = newActor(); + const run = await actor.start(actId, input, { runName }); + + expect(startSpy).toBeCalledTimes(1); + expect(resurrectSpy).not.toBeCalled(); + expect(run).toEqual(replacement); + expect(await getChildRunLedger(actor)).toEqual({ + [runName]: { + ...stored, + runId: replacement.id, + history: [{ ...trackedInfo, status: 'FAILED' }], + }, + }); + }); + + test.each(['ABORTED', 'TIMED-OUT'] as const)( + 'start() resurrects a tracked run in the %s status with the resurrect options of the call', async (status) => { await newActor().start(actId, input, { runName }); startSpy.mockClear(); getSpy.mockResolvedValue({ ...startedRun, status }); - const replacement = { id: 'replacement-run', status: 'RUNNING', startedAt } as ActorRun; - startSpy.mockResolvedValue(replacement); const actor = newActor(); - const run = await actor.start(actId, input, { runName }); - - expect(startSpy).toBeCalledTimes(1); - expect(run).toEqual(replacement); - expect(await getChildRunLedger(actor)).toEqual({ - [runName]: { - ...stored, - runId: replacement.id, - history: [{ ...trackedInfo, status }], - }, + const run = await actor.start(actId, input, { + runName, + build: 'beta', + memory: 1024, + timeout: 60, + maxItems: 10, + webhooks: [], }); + + expect(startSpy).not.toBeCalled(); + expect(resurrectSpy).toBeCalledWith({ build: 'beta', memory: 1024, runTimeoutSecs: 60, maxItems: 10 }); + expect(run).toEqual(startedRun); + expect(await getChildRunLedger(actor)).toEqual({ [runName]: { ...stored, history: [] } }); + }, + ); + + test.each([ + ['ABORTING', 'ABORTED'], + ['TIMING-OUT', 'TIMED-OUT'], + ] as const)( + 'start() waits for a tracked run in the %s status to finish before resurrecting it', + async (status, finalStatus) => { + await newActor().start(actId, input, { runName }); + startSpy.mockClear(); + getSpy.mockResolvedValue({ ...startedRun, status }); + waitForFinishSpy.mockResolvedValue({ ...startedRun, status: finalStatus }); + + await newActor().start(actId, input, { runName }); + + expect(waitForFinishSpy).toBeCalledTimes(1); + expect(resurrectSpy).toBeCalledTimes(1); + expect(startSpy).not.toBeCalled(); }, ); + test("start() resurrects a tracked run with the remaining time of this run with `timeout: 'inherit'`", async () => { + await newActor().start(actId, input, { runName }); + getSpy.mockResolvedValue({ ...startedRun, status: 'TIMED-OUT' }); + vitest.setSystemTime(startedAt); + process.env[APIFY_ENV_VARS.IS_AT_HOME] = '1'; + process.env[ACTOR_ENV_VARS.TIMEOUT_AT] = new Date(startedAt.getTime() + 60_000).toISOString(); + onTestFinished(() => { + delete process.env[APIFY_ENV_VARS.IS_AT_HOME]; + delete process.env[ACTOR_ENV_VARS.TIMEOUT_AT]; + vitest.useRealTimers(); + }); + + await newActor().start(actId, input, { runName, timeout: 'inherit' }); + + expect(resurrectSpy).toBeCalledWith(expect.objectContaining({ runTimeoutSecs: 60 })); + }); + test('start() starts a new run when the tracked run no longer exists and records the old one as lost', async () => { await newActor().start(actId, input, { runName }); startSpy.mockClear(); @@ -241,6 +303,15 @@ describe('child run tracking with `runName`', () => { expect(startSpy).toBeCalledTimes(1); }); + test('reusing a run name with a different input throws when the tracked run would be resurrected', async () => { + const actor = newActor(); + await actor.start(actId, input, { runName }); + getSpy.mockResolvedValue({ ...startedRun, status: 'ABORTED' }); + + await expect(actor.start(actId, { foo: 'baz' }, { runName })).rejects.toThrow(/already used/); + expect(resurrectSpy).not.toBeCalled(); + }); + test('reusing a run name with a different input is allowed when the tracked run is replaced', async () => { const actor = newActor(); await actor.start(actId, input, { runName }); @@ -285,6 +356,19 @@ describe('child run tracking with `runName`', () => { expect((await getChildRunLedger(actor))![runName].status).toBe('SUCCEEDED'); }); + test('call() waits for a resurrected run without replaying its log', async () => { + await newActor().start(actId, input, { runName }); + startSpy.mockClear(); + getSpy.mockResolvedValue({ ...startedRun, status: 'ABORTED' }); + + const run = await newActor().call(actId, input, { runName }); + + expect(run).toEqual(finishedRun); + expect(startSpy).not.toBeCalled(); + expect(resurrectSpy).toBeCalledWith(expect.objectContaining({ timeoutSecs: 'noTimeout' })); + expect(getStreamedLogSpy).toBeCalledWith(expect.objectContaining({ fromStart: false })); + }); + test('call() stops the log stream when waiting for the run fails', async () => { const streamedLog = { start: vitest.fn(), stop: vitest.fn().mockResolvedValue(undefined) }; getStreamedLogSpy.mockResolvedValue(streamedLog as any); From 0741f7e6b56b4476a76195e91273a222e5f864d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 13 Oct 2026 17:14:33 +0200 Subject: [PATCH 27/31] docs: clarify that a replaced child run accepts a different input --- src/actor.ts | 2 +- src/child_run_tracker.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 7d938c4470..8c2a774184 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -336,7 +336,7 @@ export interface ChildRunOptions { * If a `READY`, `RUNNING` or `SUCCEEDED` run is already tracked under this name, it is returned instead of * starting a new one, including after a migration or resurrection of this run. A run that was aborted or timed out * is resurrected with the given options, once it finishes aborting or timing out. A run that failed is replaced - * by a new one. Reusing the name for a different Actor, task or input throws. + * by a new one. Reusing the name for a different Actor, task or input throws, unless the tracked run is replaced. */ runName?: string; } diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index c88e7ea1c8..45a3aacdab 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -77,7 +77,7 @@ export class ChildRunTracker { /** * Throws if the run tracked under `runName` was started for a different Actor / task or input, - * as it would otherwise be silently returned in place of the requested one. + * as it would otherwise be silently returned or resurrected in place of the requested one. */ async verifyRequest(runName: string, request: ChildRunRequest) { const tracked = await this.get(runName); From 68d44f2582379e905a7d9a37e779634b9aa1d212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 13 Oct 2026 17:16:18 +0200 Subject: [PATCH 28/31] fix: look a missing child run up again before treating it as lost A run started moments ago may not be on every API replica yet, so a single 404 could start a duplicate run. --- src/child_run_tracker.ts | 20 +++++++++++++++++++- test/apify/child_runs.test.ts | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index 45a3aacdab..2666f5f625 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { KeyValueStore, withDirectStorageAccess } from '@crawlee/core'; +import { sleep } from '@crawlee/utils'; import type { Run as ActorRun, RunClient, RunResurrectOptions } from 'apify-client'; import { ReentrantAsyncLock } from './utils.js'; import type { ApifyClient } from './index.js'; @@ -62,6 +63,23 @@ const toInfo = (run: ActorRun): ChildRunInfo => ({ const CHILD_RUNS_KVS_KEY = '__ACTOR_CHILD_RUNS'; +/** How many times a tracked run that the API reports as missing is looked up again before it counts as gone. */ +const NOT_FOUND_RETRIES = 12; +const NOT_FOUND_RETRY_INTERVAL_MILLIS = 250; + +/** + * Fetches a tracked run, retrying a 404 for a few seconds. A run started moments ago (e.g. by a concurrent call or + * right before a migration) may not be on every API replica yet, and treating it as gone would start a duplicate. + */ +const getTrackedRun = async (runClient: RunClient) => { + let run = await runClient.get(); + for (let retry = 0; !run && retry < NOT_FOUND_RETRIES; retry++) { + await sleep(NOT_FOUND_RETRY_INTERVAL_MILLIS); + run = await runClient.get(); + } + return run; +}; + /** Statuses that shortly end as `ABORTED` / `TIMED-OUT`. */ const SETTLING_STATUSES = ['ABORTING', 'TIMING-OUT']; const RESURRECTABLE_STATUSES = ['ABORTED', 'TIMED-OUT']; @@ -110,7 +128,7 @@ export class ChildRunTracker { const { build, memory, runTimeoutSecs, maxItems, maxTotalChargeUsd, restartOnError, timeoutSecs, signal } = startOptions; const tracked = await this.get(runName); - let trackedRun = tracked ? await client.run(tracked.runId).get() : undefined; + let trackedRun = tracked ? await getTrackedRun(client.run(tracked.runId)) : undefined; if (trackedRun && SETTLING_STATUSES.includes(trackedRun.status)) { trackedRun = await client.run(trackedRun.id).waitForFinish({ timeoutSecs, signal }); diff --git a/test/apify/child_runs.test.ts b/test/apify/child_runs.test.ts index a100012890..6575ba7521 100644 --- a/test/apify/child_runs.test.ts +++ b/test/apify/child_runs.test.ts @@ -1,4 +1,5 @@ import { MemoryStorageBackend } from '@crawlee/core'; +import { sleep } from '@crawlee/utils'; import type { Actor } from 'apify'; import type { Run as ActorRun } from 'apify-client'; import { ActorClient, RunClient, TaskClient } from 'apify-client'; @@ -11,6 +12,12 @@ import log from '@apify/log'; import type { TrackedChildRun } from '../../src/child_run_tracker.js'; import { createIsolatedActor } from '../createIsolatedActor.js'; +// Skips the delays between the lookups of a tracked run that the API reports as missing. +vitest.mock('@crawlee/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + sleep: vitest.fn(async () => {}), +})); + const globalOptions = { token: 'some-token', actId: 'some-act-id', @@ -186,9 +193,23 @@ describe('child run tracking with `runName`', () => { await actor.start(actId, input, { runName }); expect(startSpy).toBeCalledTimes(1); + expect(getSpy).toBeCalledTimes(13); + expect(sleep).toBeCalledWith(250); expect((await getChildRunLedger(actor))![runName].history).toEqual([{ ...trackedInfo, status: 'LOST' }]); }); + test('start() looks a missing tracked run up again, as it may not have reached every API replica yet', async () => { + await newActor().start(actId, input, { runName }); + startSpy.mockClear(); + getSpy.mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined); + + const run = await newActor().start(actId, input, { runName }); + + expect(run).toEqual(startedRun); + expect(getSpy).toBeCalledTimes(3); + expect(startSpy).not.toBeCalled(); + }); + test('repeated replacements accumulate in the history, oldest first', async () => { const actor = newActor(); await actor.start(actId, input, { runName }); From 9b52b8b2bef0ff26565dacaf7f6e5bd48cad683d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 13 Oct 2026 17:16:53 +0200 Subject: [PATCH 29/31] feat: support `runName` in `Actor.startTask()` --- src/actor.ts | 17 ++++++++++++++--- test/apify/child_runs.test.ts | 12 ++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 8c2a774184..f39d8a1fc5 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -346,7 +346,8 @@ export interface StartOptions extends Omit, Token, Timeout, ChildRunOptions {} export interface CallTaskOptions extends Omit, Token, Timeout, ChildRunOptions {} export interface ResurrectOptions extends Omit, Token, Timeout {} -export interface StartTaskOptions extends Omit, Token, Timeout {} +export interface StartTaskOptions + extends Omit, Token, Timeout, ChildRunOptions {} export interface AbortOptions extends RunAbortOptions, Token { /** Exit with given status message */ @@ -1097,10 +1098,20 @@ export class Actor { */ async startTask(taskId: string, input?: Dictionary, options: StartTaskOptions = {}): Promise { const runTimeoutSecs = options.timeout === 'inherit' ? this.getRemainingTimeSecs() : options.timeout; - const { token, timeout: _timeout, ...rest } = options; + const { token, timeout: _timeout, runName, ...rest } = options; const client = token ? this.newClient({ token }) : this.apifyClient; - return client.task(taskId).start(input, { ...rest, runTimeoutSecs }); + if (!runName) return client.task(taskId).start(input, { ...rest, runTimeoutSecs }); + + const startOptions = { ...rest, runTimeoutSecs }; + const { run } = await this.#childRunTracker.startOrResumeChildRun( + client, + runName, + { type: 'task', id: taskId, input }, + startOptions, + async () => client.task(taskId).start(input, startOptions), + ); + return run; } /** diff --git a/test/apify/child_runs.test.ts b/test/apify/child_runs.test.ts index 6575ba7521..10af8a4051 100644 --- a/test/apify/child_runs.test.ts +++ b/test/apify/child_runs.test.ts @@ -410,6 +410,18 @@ describe('child run tracking with `runName`', () => { expect(startSpy).not.toBeCalled(); }); + test('startTask() returns the tracked run instead of starting a new one', async () => { + await newActor().startTask(taskId, input, { runName }); + taskStartSpy.mockClear(); + + const actor = newActor(); + const run = await actor.startTask(taskId, input, { runName }); + + expect(run).toEqual(startedRun); + expect(taskStartSpy).not.toBeCalled(); + expect(await getChildRunLedger(actor)).toEqual({ [runName]: { ...stored, history: [] } }); + }); + test('callTask() deduplicates the tracked run instead of starting a new one', async () => { await newActor().callTask(taskId, input, { runName }); expect(taskStartSpy).toBeCalledTimes(1); From d6be7e8092572ed6ec33655e43964cef3e5ecbe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 13 Oct 2026 17:17:59 +0200 Subject: [PATCH 30/31] feat: validate the stored child runs when loading them --- src/child_run_tracker.ts | 19 +++++++++++++++++-- test/apify/child_runs.test.ts | 6 ++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/child_run_tracker.ts b/src/child_run_tracker.ts index 2666f5f625..b0d06c4ede 100644 --- a/src/child_run_tracker.ts +++ b/src/child_run_tracker.ts @@ -3,6 +3,8 @@ import { createHash } from 'node:crypto'; import { KeyValueStore, withDirectStorageAccess } from '@crawlee/core'; import { sleep } from '@crawlee/utils'; import type { Run as ActorRun, RunClient, RunResurrectOptions } from 'apify-client'; +import { z } from 'zod'; + import { ReentrantAsyncLock } from './utils.js'; import type { ApifyClient } from './index.js'; @@ -63,6 +65,12 @@ const toInfo = (run: ActorRun): ChildRunInfo => ({ const CHILD_RUNS_KVS_KEY = '__ACTOR_CHILD_RUNS'; +const childRunInfoSchema = z.object({ runId: z.string(), status: z.string(), startedAt: z.string() }); +const trackedRunsSchema = z.record( + z.string(), + childRunInfoSchema.extend({ checksum: z.string(), history: z.array(childRunInfoSchema).default([]) }), +); + /** How many times a tracked run that the API reports as missing is looked up again before it counts as gone. */ const NOT_FOUND_RETRIES = 12; const NOT_FOUND_RETRY_INTERVAL_MILLIS = 250; @@ -232,9 +240,16 @@ export class ChildRunTracker { */ async load() { this.loading ??= KeyValueStore.open() - .then(async (defaultStore) => defaultStore.getValue>(CHILD_RUNS_KVS_KEY)) + .then(async (defaultStore) => defaultStore.getValue(CHILD_RUNS_KVS_KEY)) .then((storedRuns) => { - this.trackedRuns = storedRuns ?? {}; + const parsed = trackedRunsSchema.safeParse(storedRuns ?? {}); + if (!parsed.success) { + throw new Error( + `The child runs stored under the "${CHILD_RUNS_KVS_KEY}" key in the default key-value store are malformed.`, + { cause: parsed.error }, + ); + } + this.trackedRuns = parsed.data as Record; }) .catch((error) => { this.loading = undefined; diff --git a/test/apify/child_runs.test.ts b/test/apify/child_runs.test.ts index 10af8a4051..f1433467a3 100644 --- a/test/apify/child_runs.test.ts +++ b/test/apify/child_runs.test.ts @@ -266,6 +266,12 @@ describe('child run tracking with `runName`', () => { expect(actor.childRuns[runName].id).toBe(startedRun.id); }); + test('init() throws when the stored child runs are malformed', async () => { + await (await newActor().openKeyValueStore()).setValue('__ACTOR_CHILD_RUNS', { [runName]: { runId: 42 } }); + + await expect(newActor().init()).rejects.toThrow(/__ACTOR_CHILD_RUNS.*malformed/); + }); + test('childRuns uses the token a run was started with in this process', async () => { await newActor().start(actId, input, { runName: 'before-migration' }); From a1a5ef70c5cd2be5ec67c00cdba6a46041578bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20B=C3=A4r?= Date: Tue, 13 Oct 2026 17:19:20 +0200 Subject: [PATCH 31/31] test: add an e2e test for reattaching and resurrecting named child runs after a reboot --- test/e2e/sdk/actorChildRuns/src/main.mjs | 47 ++++++++++++++++++++++++ test/e2e/sdk/actorChildRuns/test.mjs | 37 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 test/e2e/sdk/actorChildRuns/src/main.mjs create mode 100644 test/e2e/sdk/actorChildRuns/test.mjs diff --git a/test/e2e/sdk/actorChildRuns/src/main.mjs b/test/e2e/sdk/actorChildRuns/src/main.mjs new file mode 100644 index 0000000000..b69c650f63 --- /dev/null +++ b/test/e2e/sdk/actorChildRuns/src/main.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { setTimeout } from 'node:timers/promises'; + +import { Actor } from 'apify'; + +await Actor.init(); + +const { mode, childSleepSecs } = (await Actor.getInput()) ?? {}; +const { actorId } = Actor.getEnv(); +const childInput = { childSleepSecs: mode === 'resurrect' ? 300 : 20 }; +const childRunId = await Actor.getValue('CHILD_RUN_ID'); + +if (childSleepSecs !== undefined) { + // The child only waits, so that the parent can reboot while it is running. + await setTimeout(childSleepSecs * 1000); +} else if (!childRunId) { + const run = await Actor.start(actorId, childInput, { runName: 'child' }); + await Actor.setValue('CHILD_RUN_ID', run.id); + + if (mode === 'resurrect') { + await Actor.apifyClient.run(run.id).abort(); + const abortedRun = await Actor.apifyClient.run(run.id).waitForFinish(); + assert.equal(abortedRun.status, 'ABORTED'); + } + + // The container gets killed while this waits, the run continues from the top. + await Actor.reboot(); +} else if (mode === 'resurrect') { + const run = await Actor.start(actorId, childInput, { runName: 'child' }); + try { + assert.equal(run.id, childRunId); + assert.ok(['READY', 'RUNNING'].includes(run.status), `run.status=${run.status}`); + } finally { + await Actor.apifyClient.run(run.id).abort(); + } + await Actor.setValue('VERIFIED', true); +} else { + assert.deepEqual(Object.keys(Actor.childRuns), ['child']); + assert.equal(Actor.childRuns.child.id, childRunId); + + const run = await Actor.call(actorId, childInput, { runName: 'child' }); + assert.equal(run.id, childRunId); + assert.equal(run.status, 'SUCCEEDED'); + await Actor.setValue('VERIFIED', true); +} + +await Actor.exit(); diff --git a/test/e2e/sdk/actorChildRuns/test.mjs b/test/e2e/sdk/actorChildRuns/test.mjs new file mode 100644 index 0000000000..af19796e40 --- /dev/null +++ b/test/e2e/sdk/actorChildRuns/test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { ApifyClient } from 'apify'; + +const client = new ApifyClient({ + token: process.env.APIFY_TOKEN, +}); + +const actor = client.actor(process.argv[2]); + +const runParent = async (mode) => { + const { total: runsBefore } = await actor.runs().list(); + const run = await actor.call({ mode }); + const { total: runsAfter } = await actor.runs().list(); + const verified = await client.keyValueStore(run.defaultKeyValueStoreId).getRecord('VERIFIED'); + + return { run, startedRuns: runsAfter - runsBefore, verified: verified?.value === true }; +}; + +test('a named child run is reattached after a reboot', async () => { + const { run, startedRuns, verified } = await runParent('reattach'); + + assert.equal(run.status, 'SUCCEEDED'); + assert.ok(verified, 'the parent did not get past the reboot'); + // The parent run and the one child run it reattached to. + assert.equal(startedRuns, 2); +}); + +test('a named aborted child run is resurrected after a reboot', async () => { + const { run, startedRuns, verified } = await runParent('resurrect'); + + assert.equal(run.status, 'SUCCEEDED'); + assert.ok(verified, 'the parent did not get past the reboot'); + // The parent run and the one child run it resurrected. + assert.equal(startedRuns, 2); +});