Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
187354e
feat: resumable `start` and `call` implementions
barjin Sep 25, 2026
30114ed
fix: do not resume child runs that are aborting or timing out
barjin Sep 30, 2026
075b9f7
refactor: move ChildRunTracker to its own file
barjin Sep 30, 2026
c3c0718
feat: reject reusing a child run name for a different request in one …
barjin Sep 30, 2026
3162daa
feat: resurrect aborted child runs and expose tracked child runs via …
barjin Sep 30, 2026
81815f2
refactor: start a new child run instead of resurrecting aborted or ti…
barjin Sep 30, 2026
a337bb0
docs: describe the public ChildRunTracker methods
barjin Sep 30, 2026
c9922a1
style: use multiline jsdoc for ChildRunTracker methods
barjin Sep 30, 2026
1940b86
refactor: simplify child run tracking
barjin Sep 30, 2026
7751f4c
feat: persist the child run request checksum so the name check surviv…
barjin Sep 30, 2026
f4cfdc4
docs: extend the JSDoc for the `runName` option
barjin Oct 6, 2026
29f7d13
fix: clear `trackedRuns` cache on a KVS load error
barjin Oct 6, 2026
ca71dd2
fix: add a per-name mutex for named run start and tracking
barjin Oct 6, 2026
677379e
fix: checksum the serialized child run request
barjin Oct 6, 2026
aebd5a4
refactor: store tracked child runs under the `__ACTOR_CHILD_RUNS` key
barjin Oct 7, 2026
37193a0
refactor: split off the child run specific tests into a separate file
barjin Oct 8, 2026
1a9dbe7
chore: improve test naming
barjin Oct 8, 2026
bb4d73f
chore: use the `ReentrantAsyncLock` class instead of a custom lock im…
barjin Oct 8, 2026
2e974c6
chore: fix lint error
barjin Oct 8, 2026
452d7f6
docs: document the `LOST` runs better
barjin Oct 9, 2026
a76459d
chore: lock apify-client version
barjin Oct 9, 2026
bd8ec9a
Merge branch 'master' into feat/track-child-runs
barjin Oct 9, 2026
c83bcc6
chore: fix failing build
barjin Oct 9, 2026
8143fd7
feat: expose tracked child runs as a synchronous `Actor.childRuns` ge…
barjin Oct 9, 2026
476b800
chore: address nits
barjin Oct 12, 2026
ec5e6fa
fix: forward `signal` and `timeoutSecs` in named `call` and `callTask`
barjin Oct 12, 2026
56034c3
Merge remote-tracking branch 'origin/master' into feat/track-child-runs
barjin Oct 13, 2026
be2e911
feat: resurrect aborted and timed out child runs instead of replacing…
barjin Oct 13, 2026
0741f7e
docs: clarify that a replaced child run accepts a different input
barjin Oct 13, 2026
68d44f2
fix: look a missing child run up again before treating it as lost
barjin Oct 13, 2026
9b52b8b
feat: support `runName` in `Actor.startTask()`
barjin Oct 13, 2026
d6be7e8
feat: validate the stored child runs when loading them
barjin Oct 13, 2026
a1a5ef7
test: add an e2e test for reattaching and resurrecting named child ru…
barjin Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 121 additions & 12 deletions src/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
ActorStartOptions,
ApifyClientOptions,
RunAbortOptions,
RunClient,
RunResurrectOptions,
TaskCallOptions,
TaskStartOptions,
Expand Down Expand Up @@ -54,6 +55,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 } from './child_run_tracker.js';
import type { ConfigurationOptions } from './configuration.js';
import { Configuration } from './configuration.js';
import { ActorInputError } from './errors.js';
Expand Down Expand Up @@ -327,11 +329,25 @@ export interface Timeout {
timeout?: number | 'inherit';
}

export interface CallOptions extends Omit<ActorCallOptions, 'runTimeoutSecs'>, Token, Timeout {}
export interface StartOptions extends Omit<ActorStartOptions, 'waitForFinish' | 'runTimeoutSecs'>, Token, Timeout {}
export interface CallTaskOptions extends Omit<TaskCallOptions, 'runTimeoutSecs'>, Token, Timeout {}
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 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, unless the tracked run is replaced.
*/
Comment thread
barjin marked this conversation as resolved.
runName?: string;
}

export interface CallOptions extends Omit<ActorCallOptions, 'runTimeoutSecs'>, Token, Timeout, ChildRunOptions {}
export interface StartOptions
extends Omit<ActorStartOptions, 'waitForFinish' | 'runTimeoutSecs'>, Token, Timeout, ChildRunOptions {}
export interface CallTaskOptions extends Omit<TaskCallOptions, 'runTimeoutSecs'>, Token, Timeout, ChildRunOptions {}
export interface ResurrectOptions extends Omit<RunResurrectOptions, 'runTimeoutSecs'>, Token, Timeout {}
export interface StartTaskOptions extends Omit<TaskStartOptions, 'waitForFinish' | 'runTimeoutSecs'>, Token, Timeout {}
export interface StartTaskOptions
extends Omit<TaskStartOptions, 'waitForFinish' | 'runTimeoutSecs'>, Token, Timeout, ChildRunOptions {}

export interface AbortOptions extends RunAbortOptions, Token {
/** Exit with given status message */
Expand Down Expand Up @@ -451,6 +467,8 @@ export class Actor<Data extends Dictionary = Dictionary> {
/** @internal */
static #instance?: Actor;

#childRunTracker: ChildRunTracker;

/**
* Configuration of this SDK instance (provided to its constructor). See {@apilink Configuration} for details.
* @internal
Expand Down Expand Up @@ -536,6 +554,7 @@ export class Actor<Data extends Dictionary = Dictionary> {
this.apifyClient = this.newClient();
this.eventManager = new PlatformEventManager(this.configuration);
this.#chargingManager = new ChargingManager(this.configuration, this.apifyClient);
this.#childRunTracker = new ChildRunTracker();
}

/**
Expand Down Expand Up @@ -706,6 +725,8 @@ export class Actor<Data extends Dictionary = Dictionary> {
});
log.debug(`Default storages purged`);

await this.#childRunTracker.load();
Comment thread
vdusek marked this conversation as resolved.

await this.#chargingManager.init();
log.debug(`ChargingManager initialized`, this.#chargingManager.getPricingInfo());

Expand Down Expand Up @@ -866,9 +887,31 @@ export class Actor<Data extends Dictionary = Dictionary> {
*/
async call(actorId: string, input?: ActorInput, options: CallOptions = {}): Promise<ClientActorRun> {
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, 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 },
startOptions,
async () => client.actor(actorId).start(input, startOptions),
);

// 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, signal });
streamedLog?.start();
try {
const finishedRun = await client.run(run.id).waitForFinish({ waitSecs, timeoutSecs, signal });
await this.#childRunTracker.update(runName, finishedRun);
return finishedRun;
} finally {
await streamedLog?.stop();
}
}

/**
Expand Down Expand Up @@ -896,10 +939,20 @@ export class Actor<Data extends Dictionary = Dictionary> {
*/
async start(actorId: string, input?: ActorInput, options: StartOptions = {}): Promise<ClientActorRun> {
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 startOptions = { ...rest, runTimeoutSecs };
const { run } = await this.#childRunTracker.startOrResumeChildRun(
client,
runName,
{ type: 'actor', id: actorId, input },
startOptions,
async () => client.actor(actorId).start(input, startOptions),
);
return run;
}

/**
Expand Down Expand Up @@ -984,10 +1037,40 @@ export class Actor<Data extends Dictionary = Dictionary> {
*/
async callTask(taskId: string, input?: Dictionary, options: CallTaskOptions = {}): Promise<ClientActorRun> {
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, timeoutSecs = 'noTimeout', signal, ...otherOptions } = rest;
const startOptions = { ...otherOptions, runTimeoutSecs, timeoutSecs, signal };
const { run } = await this.#childRunTracker.startOrResumeChildRun(
client,
runName,
{ type: 'task', id: taskId, input },
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);
return finishedRun;
}

/**
* 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();
* ```
*
* 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.
*/
get childRuns(): Record<string, RunClient> {
this.ensureActorInit('childRuns');
return this.#childRunTracker.getRunClients(this.apifyClient);
}

/**
Expand Down Expand Up @@ -1015,10 +1098,20 @@ export class Actor<Data extends Dictionary = Dictionary> {
*/
async startTask(taskId: string, input?: Dictionary, options: StartTaskOptions = {}): Promise<ClientActorRun> {
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;
}

/**
Expand Down Expand Up @@ -2028,6 +2121,22 @@ export class Actor<Data extends Dictionary = Dictionary> {
return Actor.getDefaultInstance().start(actorId, input, options);
}

/**
* 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();
* ```
*
* 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 get childRuns(): Record<string, RunClient> {
return Actor.getDefaultInstance().childRuns;
}

/**
* Aborts given Actor run on the Apify platform using the current user account (determined by the `APIFY_TOKEN` environment variable).
*
Expand Down
23 changes: 1 addition & 22 deletions src/charging.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -74,26 +73,6 @@ export interface ActorPricingInfo {
perEventPrices: Record<string, number>;
}

/**
* 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<unknown> = Promise.resolve();
readonly #held = new AsyncLocalStorage<true>();

async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
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.
*/
Expand Down
Loading
Loading