Skip to content

Commit 91a7b4c

Browse files
authored
fix: log warning on sharing useState(), add id option to BasicCrawler (#3309)
Adds unique crawler id to the `BasicCrawler` class. Prints a warning on multiple crawlers sharing the state on `useState()`. There might be more resources shared between different `BasicCrawler` (-subclass) instances - this needs further investigation. Closes #3024
1 parent fbcfd32 commit 91a7b4c

4 files changed

Lines changed: 163 additions & 7 deletions

File tree

packages/basic-crawler/src/internals/basic-crawler.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,19 @@ export interface BasicCrawlerOptions<
392392
* the Proxy URLs provided and rotated according to the configuration.
393393
*/
394394
proxyConfiguration?: ProxyConfiguration;
395+
396+
/**
397+
* A unique identifier for the crawler instance. This ID is used to isolate the state returned by
398+
* {@apilink BasicCrawler.useState|`crawler.useState()`} from other crawler instances.
399+
*
400+
* When multiple crawler instances use `useState()` without an explicit `id`, they will share the same
401+
* state object for backward compatibility. A warning will be logged in this case.
402+
*
403+
* To ensure each crawler has its own isolated state that also persists across script restarts
404+
* (e.g., during Apify migrations), provide a stable, unique ID for each crawler instance.
405+
*
406+
*/
407+
id?: string;
395408
}
396409

397410
/**
@@ -481,6 +494,12 @@ export class BasicCrawler<
481494
> {
482495
protected static readonly CRAWLEE_STATE_KEY = 'CRAWLEE_STATE';
483496

497+
/**
498+
* Tracks crawler instances that accessed shared state without having an explicit id.
499+
* Used to detect and warn about multiple crawlers sharing the same state.
500+
*/
501+
private static useStateCrawlerIds = new Set<string>();
502+
484503
/**
485504
* A reference to the underlying {@apilink Statistics} class that collects and logs run statistics for requests.
486505
*/
@@ -574,6 +593,8 @@ export class BasicCrawler<
574593
private experiments: CrawlerExperiments;
575594
private readonly robotsTxtFileCache: LruCache<RobotsTxtFile>;
576595
private _experimentWarnings: Partial<Record<keyof CrawlerExperiments, boolean>> = {};
596+
private readonly crawlerId: string;
597+
private readonly hasExplicitId: boolean;
577598

578599
protected static optionsShape = {
579600
contextPipelineBuilder: ow.optional.object,
@@ -617,6 +638,8 @@ export class BasicCrawler<
617638
experiments: ow.optional.object,
618639

619640
statisticsOptions: ow.optional.object,
641+
642+
id: ow.optional.string,
620643
};
621644

622645
/**
@@ -664,8 +687,15 @@ export class BasicCrawler<
664687
// internal
665688
log = defaultLog.child({ prefix: this.constructor.name }),
666689
experiments = {},
690+
691+
id,
667692
} = options;
668693

694+
// Store whether the user explicitly provided an ID
695+
this.hasExplicitId = id !== undefined;
696+
// Store the user-provided ID, or generate a unique one for tracking purposes (not for state key)
697+
this.crawlerId = id ?? cryptoRandomObjectId();
698+
669699
// Store the builder so that it can be run when the contextPipeline is needed.
670700
// Invoking it immediately would cause problems with parent constructor call order.
671701
this.contextPipelineBuilder = () => {
@@ -759,6 +789,7 @@ export class BasicCrawler<
759789
logMessage: `${log.getOptions().prefix} request statistics:`,
760790
log,
761791
config,
792+
...(this.hasExplicitId ? { id: this.crawlerId } : {}),
762793
...statisticsOptions,
763794
});
764795
this.sessionPoolOptions = {
@@ -1103,6 +1134,23 @@ export class BasicCrawler<
11031134

11041135
async useState<State extends Dictionary = Dictionary>(defaultValue = {} as State): Promise<State> {
11051136
const kvs = await KeyValueStore.open(null, { config: this.config });
1137+
1138+
if (this.hasExplicitId) {
1139+
const stateKey = `${BasicCrawler.CRAWLEE_STATE_KEY}_${this.crawlerId}`;
1140+
return kvs.getAutoSavedValue<State>(stateKey, defaultValue);
1141+
}
1142+
1143+
BasicCrawler.useStateCrawlerIds.add(this.crawlerId);
1144+
1145+
if (BasicCrawler.useStateCrawlerIds.size > 1) {
1146+
defaultLog.warningOnce(
1147+
'Multiple crawler instances are calling useState() without an explicit `id` option. \n' +
1148+
'This means they will share the same state object, which is likely unintended. \n' +
1149+
'To fix this, provide a unique `id` option to each crawler instance. \n' +
1150+
'Example: new BasicCrawler({ id: "my-crawler-1", ... })',
1151+
);
1152+
}
1153+
11061154
return kvs.getAutoSavedValue<State>(BasicCrawler.CRAWLEE_STATE_KEY, defaultValue);
11071155
}
11081156

packages/core/src/crawlers/statistics.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export class Statistics {
7272
/**
7373
* Statistic instance id.
7474
*/
75-
readonly id = Statistics.id++; // assign an id while incrementing so it can be saved/restored from KV
75+
readonly id: string;
7676

7777
/**
7878
* Current statistic state used for doing calculations on {@apilink Statistics.calculate} calls
@@ -90,7 +90,7 @@ export class Statistics {
9090
private readonly config: Configuration;
9191

9292
protected keyValueStore?: KeyValueStore = undefined;
93-
protected persistStateKey = `SDK_CRAWLER_STATISTICS_${this.id}`;
93+
protected persistStateKey: string;
9494
private logIntervalMillis: number;
9595
private logMessage: string;
9696
private listener: () => Promise<void>;
@@ -115,6 +115,7 @@ export class Statistics {
115115
config: ow.optional.object,
116116
persistenceOptions: ow.optional.object,
117117
saveErrorSnapshots: ow.optional.boolean,
118+
id: ow.optional.any(ow.number, ow.string),
118119
}),
119120
);
120121

@@ -127,8 +128,12 @@ export class Statistics {
127128
enable: true,
128129
},
129130
saveErrorSnapshots = false,
131+
id,
130132
} = options;
131133

134+
this.id = id ?? String(Statistics.id++);
135+
this.persistStateKey = `SDK_CRAWLER_STATISTICS_${this.id}`;
136+
132137
this.log = (options.log ?? defaultLog).child({ prefix: 'Statistics' });
133138
this.errorTracker = new ErrorTracker({ ...errorTrackerConfig, saveErrorSnapshots });
134139
this.errorTrackerRetry = new ErrorTracker({ ...errorTrackerConfig, saveErrorSnapshots });
@@ -474,14 +479,24 @@ export interface StatisticsOptions {
474479
* @default false
475480
*/
476481
saveErrorSnapshots?: boolean;
482+
483+
/**
484+
* A unique identifier for this statistics instance. This ID is used for persistence
485+
* to the key value store, ensuring the same statistics can be loaded after script restarts.
486+
*
487+
* If not provided, an auto-incremented ID will be used for backward compatibility.
488+
* This means statistics may not persist correctly across script restarts
489+
* if crawler creation order changes.
490+
*/
491+
id?: string;
477492
}
478493

479494
/**
480495
* Format of the persisted stats
481496
*/
482497
export interface StatisticPersistedState extends Omit<StatisticState, 'statsPersistedAt'> {
483498
requestRetryHistogram: number[];
484-
statsId: number;
499+
statsId: string;
485500
requestAvgFailedDurationMillis: number;
486501
requestAvgFinishedDurationMillis: number;
487502
requestTotalDurationMillis: number;

test/core/crawlers/basic_crawler.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import express from 'express';
3030
import { MemoryStorageEmulator } from 'test/shared/MemoryStorageEmulator.js';
3131
import type { SetRequired } from 'type-fest';
3232
import type { Mock } from 'vitest';
33-
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest';
33+
import { afterAll, beforeAll, beforeEach, describe, expect, test, vitest } from 'vitest';
3434

3535
import log from '@apify/log';
3636

@@ -414,6 +414,61 @@ describe('BasicCrawler', () => {
414414
expect(await requestList.isEmpty()).toBe(true);
415415
});
416416

417+
test('print a warning on sharing state between two crawlers', async () => {
418+
function createCrawler() {
419+
return new BasicCrawler({
420+
requestHandler: async ({ request, useState }) => {
421+
const state = await useState<{ urls: string[] }>({ urls: [] });
422+
state.urls.push(request.url);
423+
},
424+
});
425+
}
426+
427+
const loggerSpy = vitest.spyOn(log, 'warning');
428+
429+
const [crawler1, crawler2] = [createCrawler(), createCrawler()];
430+
431+
await crawler1.run([`http://${HOSTNAME}:${port}/`]);
432+
await crawler2.run([`http://${HOSTNAME}:${port}/?page=2`]);
433+
434+
// Both crawlers should share the same state (backward compatibility)
435+
const state1 = await crawler1.useState<{ urls: string[] }>();
436+
const state2 = await crawler2.useState<{ urls: string[] }>();
437+
438+
expect(state1).toBe(state2);
439+
expect(state1.urls).toHaveLength(2);
440+
expect(state1.urls).toContain(`http://${HOSTNAME}:${port}/`);
441+
expect(state1.urls).toContain(`http://${HOSTNAME}:${port}/?page=2`);
442+
expect(loggerSpy).toBeCalledWith(expect.stringContaining('Multiple crawler instances are calling useState()'));
443+
});
444+
445+
test('crawlers with explicit id have isolated state', async () => {
446+
function createCrawler(id: string) {
447+
return new BasicCrawler({
448+
id,
449+
requestHandler: async ({ request, useState }) => {
450+
const state = await useState<{ urls: string[] }>({ urls: [] });
451+
state.urls.push(request.url);
452+
},
453+
});
454+
}
455+
456+
const [crawler1, crawler2] = [createCrawler('crawler-1'), createCrawler('crawler-2')];
457+
458+
await crawler1.run([`http://${HOSTNAME}:${port}/`]);
459+
await crawler2.run([`http://${HOSTNAME}:${port}/?page=2`]);
460+
461+
// Each crawler should have its own isolated state
462+
const state1 = await crawler1.useState<{ urls: string[] }>();
463+
const state2 = await crawler2.useState<{ urls: string[] }>();
464+
465+
expect(state1).not.toBe(state2);
466+
expect(state1.urls).toHaveLength(1);
467+
expect(state1.urls).toContain(`http://${HOSTNAME}:${port}/`);
468+
expect(state2.urls).toHaveLength(1);
469+
expect(state2.urls).toContain(`http://${HOSTNAME}:${port}/?page=2`);
470+
});
471+
417472
test.each([EventType.MIGRATING, EventType.ABORTING])(
418473
'should pause on %s event and persist RequestList state',
419474
async (event) => {

test/core/crawlers/statistics.test.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ describe('Statistics', () => {
3434
describe('persist state', () => {
3535
// needs to go first for predictability
3636
test('should increment id by each new consecutive instance', () => {
37-
expect(stats.id).toEqual(0);
37+
expect(stats.id).toEqual('0');
3838
// @ts-expect-error Accessing private prop
3939
expect(Statistics.id).toEqual(1);
4040
// @ts-expect-error Accessing private prop
4141
expect(stats.persistStateKey).toEqual('SDK_CRAWLER_STATISTICS_0');
4242
const [n1, n2] = [new Statistics(), new Statistics()];
43-
expect(n1.id).toEqual(1);
44-
expect(n2.id).toEqual(2);
43+
expect(n1.id).toEqual('1');
44+
expect(n2.id).toEqual('2');
4545
// @ts-expect-error Accessing private prop
4646
expect(Statistics.id).toEqual(3);
4747
});
@@ -338,4 +338,42 @@ describe('Statistics', () => {
338338
expect(stats.state.requestsFinished).toEqual(0);
339339
expect(stats.requestRetryHistogram).toEqual([]);
340340
});
341+
342+
describe('explicit id option', () => {
343+
test('statistics with same explicit id should share persisted state', async () => {
344+
const stats1 = new Statistics({ id: 'shared-stats' });
345+
stats1.startJob(0);
346+
vitest.advanceTimersByTime(100);
347+
stats1.finishJob(0, 0);
348+
349+
await stats1.startCapturing();
350+
await stats1.persistState();
351+
await stats1.stopCapturing();
352+
353+
const stats2 = new Statistics({ id: 'shared-stats' });
354+
await stats2.startCapturing();
355+
356+
expect(stats2.state.requestsFinished).toEqual(1);
357+
358+
await stats2.stopCapturing();
359+
});
360+
361+
test('statistics with different explicit ids should have isolated state', async () => {
362+
const statsA = new Statistics({ id: 'stats-a' });
363+
statsA.startJob(0);
364+
vitest.advanceTimersByTime(100);
365+
statsA.finishJob(0, 0);
366+
367+
await statsA.startCapturing();
368+
await statsA.persistState();
369+
await statsA.stopCapturing();
370+
371+
const statsB = new Statistics({ id: 'stats-b' });
372+
await statsB.startCapturing();
373+
374+
expect(statsB.state.requestsFinished).toEqual(0);
375+
376+
await statsB.stopCapturing();
377+
});
378+
});
341379
});

0 commit comments

Comments
 (0)