Skip to content

Commit 52f245c

Browse files
authored
feat: Implement storage aliases (#3636)
closes #3074
1 parent e00aa94 commit 52f245c

11 files changed

Lines changed: 487 additions & 56 deletions

File tree

docs/upgrading/upgrading_v4.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,52 @@ If you implemented a custom `StorageClient`, you need to:
532532
2. Replace the six getter methods (`dataset`, `datasets`, `keyValueStore`, `keyValueStores`, `requestQueue`, `requestQueues`) with three async factory methods (`createDatasetClient`, `createKeyValueStoreClient`, `createRequestQueueClient`). Each factory should handle both opening an existing storage and creating a new one.
533533
3. Apply the sub-client renames listed above (`get``getMetadata`, `delete``drop`, etc.) and implement the new `purge()` method.
534534

535+
## Multiple crawler instances use separate default request queues
536+
537+
In v3, every `BasicCrawler` (or subclass) that didn't receive an explicit `requestQueue` option would open the same default request queue. If you created two crawlers in the same process, they would silently share a queue — leading to request collisions and hard-to-debug deduplication issues.
538+
539+
In v4, only the **first** crawler instance uses the default request queue. Each subsequent instance automatically gets its own queue via an internal alias (e.g. `__default_1__`, `__default_2__`, etc.). This means multiple crawlers can safely coexist without interfering with each other's requests.
540+
541+
If you explicitly pass a `requestQueue` (or `requestManager`) to the crawler, that queue is used as-is regardless of instance order.
542+
543+
## Repeated `run()` calls use `purge()` instead of `drop()` + recreate
544+
545+
When calling `crawler.run()` multiple times on the same crawler instance, v3 would drop the default request queue and create a fresh one between runs. In v4, the crawler **purges** the queue instead — clearing all requests and resetting internal counters, but keeping the same queue object. This is more efficient and avoids edge cases around stale references.
546+
547+
The new `purge()` method is available on `RequestProvider` (the base class for `RequestQueue`) and is also defined as an optional method on the `IRequestManager` interface.
548+
549+
By default, only queues that the crawler created itself (the "owned" queue) are purged between runs — a user-supplied queue is never touched unless you explicitly opt in. The `purgeRequestQueue` option in `CrawlerRunOptions` controls this behavior:
550+
551+
| `purgeRequestQueue` value | Owned queue (auto-created) | User-supplied queue |
552+
|---|---|---|
553+
| omitted (default) | Purged | Not purged |
554+
| `true` | Purged | Purged |
555+
| `false` | Not purged | Not purged |
556+
557+
```typescript
558+
// The purge happens automatically between run() calls:
559+
const crawler = new BasicCrawler({ requestHandler: async ({ request }) => { /* ... */ } });
560+
await crawler.run(['https://example.com/a', 'https://example.com/b']);
561+
// Queue is purged here, so the same URLs can be processed again:
562+
await crawler.run(['https://example.com/a', 'https://example.com/c']);
563+
```
564+
565+
You can opt out of the automatic purge by passing `purgeRequestQueue: false`:
566+
567+
```typescript
568+
await crawler.run(urls, { purgeRequestQueue: false });
569+
```
570+
571+
If you supplied your own `requestQueue` and want it purged between runs, pass `purgeRequestQueue: true` explicitly:
572+
573+
```typescript
574+
const queue = await RequestQueue.open('my-queue');
575+
const crawler = new BasicCrawler({ requestQueue: queue, requestHandler: async () => { /* ... */ } });
576+
await crawler.run(['https://example.com/first']);
577+
// Explicitly purge the user-supplied queue before the second run:
578+
await crawler.run(['https://example.com/second'], { purgeRequestQueue: true });
579+
```
580+
535581
## Storage `.open()` now also accepts `{ id?, name? }`
536582

537583
`Dataset.open()`, `KeyValueStore.open()`, and `RequestQueue.open()` previously accepted a single `idOrName?: string` parameter. This was ambiguous — callers couldn't express whether they were opening a storage by its ID or by name.

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

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,13 @@ export class BasicCrawler<
537537
*/
538538
private static useStateCrawlerIds = new Set<string>();
539539

540+
/**
541+
* Tracks the number of crawler instances created. The first crawler uses the default
542+
* request queue; subsequent ones get their own queue via a unique alias so they don't
543+
* collide.
544+
*/
545+
private static instanceCount = 0;
546+
540547
/**
541548
* A reference to the underlying {@apilink Statistics} class that collects and logs run statistics for requests.
542549
*/
@@ -574,6 +581,13 @@ export class BasicCrawler<
574581
*/
575582
private ownedSessionPool?: SessionPool;
576583

584+
/**
585+
* Set when the crawler constructed its own {@apilink RequestQueue} (no `requestQueue`/`requestManager`
586+
* option was provided). The owned queue is purged (not dropped) between repeated `run()` calls.
587+
* A user-supplied queue is never purged by the crawler.
588+
*/
589+
private ownedRequestQueue?: RequestProvider;
590+
577591
/**
578592
* A reference to the underlying {@apilink AutoscaledPool} class that manages the concurrency of the crawler.
579593
* > *NOTE:* This property is only initialized after calling the {@apilink BasicCrawler.run|`crawler.run()`} function.
@@ -661,6 +675,7 @@ export class BasicCrawler<
661675
private _experimentWarnings: Partial<Record<keyof CrawlerExperiments, boolean>> = {};
662676
private readonly crawlerId: string;
663677
private readonly hasExplicitId: boolean;
678+
private readonly crawlerInstanceIndex: number;
664679
private readonly contextPipelineOptions: {
665680
contextPipelineBuilder?: () => ContextPipeline<CrawlingContext, Context>;
666681
extendContext?: (context: Context) => Awaitable<ContextExtension>;
@@ -803,6 +818,7 @@ export class BasicCrawler<
803818
this.hasExplicitId = id !== undefined;
804819
// Store the user-provided ID, or generate a unique one for tracking purposes (not for state key)
805820
this.crawlerId = id ?? cryptoRandomObjectId();
821+
this.crawlerInstanceIndex = BasicCrawler.instanceCount++;
806822

807823
if (requestManager !== undefined) {
808824
if (requestList !== undefined || requestQueue !== undefined) {
@@ -1284,19 +1300,21 @@ export class BasicCrawler<
12841300
);
12851301
}
12861302

1287-
const { purgeRequestQueue = true, ...addRequestsOptions } = options ?? {};
1303+
const { purgeRequestQueue, ...addRequestsOptions } = options ?? {};
12881304

12891305
if (this.hasFinishedBefore) {
12901306
// When executing the run method for the second time explicitly,
1291-
// we need to purge the default RQ to allow processing the same requests again - this is important so users can
1307+
// we need to purge the RQ to allow processing the same requests again this is important so users can
12921308
// pass in failed requests back to the `crawler.run()`, otherwise they would be considered as handled and
1293-
// ignored - as a failed requests is still handled.
1294-
const isDefaultQueue = this.requestQueue?.name === 'default';
1295-
if (isDefaultQueue && purgeRequestQueue && this.requestQueue) {
1296-
await this.requestQueue.drop();
1297-
this.requestQueue = await this._getRequestQueue();
1298-
this.requestManager = undefined;
1299-
await this.initializeRequestManager();
1309+
// ignored — as a failed request is still handled.
1310+
// By default (purgeRequestQueue unset or true), only the queue we created ourselves (ownedRequestQueue) is purged.
1311+
// When `purgeRequestQueue` is explicitly `true`, we also purge a user-supplied queue.
1312+
// When `purgeRequestQueue` is explicitly `false`, nothing is purged.
1313+
const shouldPurge = purgeRequestQueue !== false;
1314+
const queueToPurge = this.ownedRequestQueue ?? (purgeRequestQueue === true ? this.requestQueue : undefined);
1315+
1316+
if (queueToPurge && shouldPurge) {
1317+
await queueToPurge.purge();
13001318
this.handledRequestsCount = 0; // This would've been reset by this._init() further down below, but at that point `handledRequestsCount` could prevent `addRequests` from adding the initial requests
13011319
}
13021320

@@ -1425,6 +1443,7 @@ export class BasicCrawler<
14251443

14261444
if (!this.requestQueue) {
14271445
this.requestQueue = await this._getRequestQueue();
1446+
this.ownedRequestQueue = this.requestQueue;
14281447
this.requestManager = undefined;
14291448
}
14301449

@@ -2262,6 +2281,11 @@ export class BasicCrawler<
22622281
}
22632282

22642283
private async _getRequestQueue() {
2284+
// The first crawler instance uses the default queue (null identifier);
2285+
// subsequent instances get their own queue via a unique alias so they don't collide.
2286+
const identifier =
2287+
this.crawlerInstanceIndex === 0 ? null : { alias: `__default_${this.crawlerInstanceIndex}__` };
2288+
22652289
// Check if it's explicitly disabled
22662290
// oxlint-disable-next-line typescript/no-deprecated -- still honored for opt-out until the flag is removed
22672291
if (this.experiments.requestLocking === false) {
@@ -2272,10 +2296,10 @@ export class BasicCrawler<
22722296
this._experimentWarnings.requestLocking = true;
22732297
}
22742298

2275-
return RequestQueueV1.open(null, { config: serviceLocator.getConfiguration() });
2299+
return RequestQueueV1.open(identifier, { config: serviceLocator.getConfiguration() });
22762300
}
22772301

2278-
return RequestQueue.open(null, { config: serviceLocator.getConfiguration() });
2302+
return RequestQueue.open(identifier, { config: serviceLocator.getConfiguration() });
22792303
}
22802304

22812305
private requestMatchesEnqueueStrategy(request: Request) {
@@ -2349,9 +2373,14 @@ export interface CrawlerAddRequestsResult extends AddRequestsBatchedResult {}
23492373

23502374
export interface CrawlerRunOptions extends CrawlerAddRequestsOptions {
23512375
/**
2352-
* Whether to purge the RequestQueue before running the crawler again. Defaults to true, so it is possible to reprocess failed requests.
2353-
* When disabled, only new requests will be considered. Note that even a failed request is considered as handled.
2354-
* @default true
2376+
* Controls whether the request queue is purged between repeated `run()` calls on the same crawler instance.
2377+
* Purging clears all requests and resets internal counters, allowing the same URLs to be processed again.
2378+
*
2379+
* - **`undefined`** (default) — only the crawler's own (auto-created) queue is purged.
2380+
* A user-supplied `requestQueue` is left untouched.
2381+
* - **`true`** — the queue is always purged, even if it was supplied by the user.
2382+
* - **`false`** — nothing is purged. Only genuinely new requests will be processed;
2383+
* note that even a failed request is considered handled.
23552384
*/
23562385
purgeRequestQueue?: boolean;
23572386
}

packages/core/src/storages/request_provider.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,14 @@ export interface IRequestManager {
9999
addRequest(requestLike: Source, options?: RequestQueueOperationOptions): Promise<RequestQueueOperationInfo>;
100100

101101
addRequestsBatched(requests: RequestsLike, options?: AddRequestsBatchedOptions): Promise<AddRequestsBatchedResult>;
102+
103+
/**
104+
* Remove all requests from the queue but keep the queue itself, resetting it
105+
* so it can be reused (e.g. across multiple `crawler.run()` calls).
106+
*
107+
* Implementations that do not support purging may leave this `undefined`.
108+
*/
109+
purge?(): Promise<void>;
102110
}
103111

104112
export abstract class RequestProvider implements IStorage, IRequestManager {
@@ -726,6 +734,28 @@ export abstract class RequestProvider implements IStorage, IRequestManager {
726734
serviceLocator.getStorageInstanceManager().removeFromCache(this);
727735
}
728736

737+
/**
738+
* Remove all requests from the queue but keep the queue itself, resetting it
739+
* so it can be reused (e.g. across multiple `crawler.run()` calls).
740+
*/
741+
async purge(): Promise<void> {
742+
checkStorageAccess();
743+
744+
await this.client.purge();
745+
746+
// Reset in-memory bookkeeping so the queue behaves as if freshly opened.
747+
this.assumedTotalCount = 0;
748+
this.assumedHandledCount = 0;
749+
this.initialCount = 0;
750+
this.initialHandledCount = 0;
751+
this.queueHeadIds.clear();
752+
this.requestCache.clear();
753+
this.recentlyHandledRequestsCache.clear();
754+
this.lastActivity = new Date();
755+
this.isFinishedCalledWhileHeadWasNotEmpty = 0;
756+
this.inProgressRequestBatchCount = 0;
757+
}
758+
729759
/**
730760
* @inheritdoc
731761
*/

packages/core/src/storages/storage_instance_manager.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,33 @@ class StorageCache {
119119
}
120120
}
121121

122+
/**
123+
* Ensure that the same string is not used as both a name and an alias for the same
124+
* storage class + backend combination. Mirrors crawlee-python's `_check_name_alias_conflict`.
125+
*/
126+
checkNameAliasConflict<T extends IStorage>(
127+
cls: Constructor<T>,
128+
{ name, alias, clientCacheKey }: { name?: string; alias?: string; clientCacheKey: Hashable },
129+
): void {
130+
if (alias) {
131+
const existingByName = this.byName.get(cls)?.get(alias)?.get(clientCacheKey);
132+
if (existingByName) {
133+
throw new Error(
134+
`Cannot open storage with alias "${alias}" because a named storage with the same identifier already exists.`,
135+
);
136+
}
137+
}
138+
if (name) {
139+
const existingByAlias = this.byAlias.get(cls)?.get(name)?.get(clientCacheKey);
140+
if (existingByAlias) {
141+
throw new Error(
142+
`Cannot open storage with name "${name}" because an alias storage with the same identifier already exists.` +
143+
` If you meant to open the alias storage, use { alias: "${name}" } instead.`,
144+
);
145+
}
146+
}
147+
}
148+
122149
/** Iterate all cached instances across all storage types. */
123150
*allValues(): IterableIterator<IStorage> {
124151
const seen = new Set<IStorage>();
@@ -222,6 +249,9 @@ export class StorageInstanceManager {
222249
if (cached) return cached;
223250
}
224251

252+
// Prevent the same string from being used as both a name and an alias.
253+
this.cache.checkNameAliasConflict(cls, { name, alias, clientCacheKey });
254+
225255
// Cache miss — create the sub-client and storage instance.
226256
const subClient = await clientOpener();
227257
const storageInfo = await (
@@ -299,6 +329,7 @@ export interface DefaultStorageIdentifier {
299329
* - `string` → resolved via `storageExists` (ID-first, then name)
300330
* - `{ id }` → `{ id }`
301331
* - `{ name }` → `{ name }`
332+
* - `{ alias }` → `{ alias }`
302333
*/
303334
export async function resolveStorageIdentifier(
304335
identifier: string | StorageIdentifier | null | undefined,
@@ -324,6 +355,10 @@ export async function resolveStorageIdentifier(
324355
return { name: identifier.name };
325356
}
326357

358+
if ('alias' in identifier && identifier.alias) {
359+
return { alias: identifier.alias };
360+
}
361+
327362
// Empty object — treated as default storage.
328363
return { alias: DEFAULT_STORAGE_ALIAS };
329364
}

packages/memory-storage/src/cache-helpers.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ import { type MemoryStorage } from './memory-storage.js';
1313
const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
1414

1515
export async function findOrCacheDatasetByPossibleId(client: MemoryStorage, entryNameOrId: string) {
16-
// First check memory cache
16+
// First check memory cache — match by id, name, or directoryName (which covers alias lookups)
1717
const found = client.datasetClientCache.find(
18-
(store) => store.id === entryNameOrId || store.name?.toLowerCase() === entryNameOrId.toLowerCase(),
18+
(store) =>
19+
store.id === entryNameOrId ||
20+
store.name?.toLowerCase() === entryNameOrId.toLowerCase() ||
21+
store.directoryName.toLowerCase() === entryNameOrId.toLowerCase(),
1922
);
2023

2124
if (found) {
@@ -116,9 +119,12 @@ export async function findOrCacheDatasetByPossibleId(client: MemoryStorage, entr
116119
}
117120

118121
export async function findOrCacheKeyValueStoreByPossibleId(client: MemoryStorage, entryNameOrId: string) {
119-
// First check memory cache
122+
// First check memory cache — match by id, name, or directoryName (which covers alias lookups)
120123
const found = client.keyValueStoreCache.find(
121-
(store) => store.id === entryNameOrId || store.name?.toLowerCase() === entryNameOrId.toLowerCase(),
124+
(store) =>
125+
store.id === entryNameOrId ||
126+
store.name?.toLowerCase() === entryNameOrId.toLowerCase() ||
127+
store.directoryName.toLowerCase() === entryNameOrId.toLowerCase(),
122128
);
123129

124130
if (found) {
@@ -270,9 +276,12 @@ export async function findOrCacheKeyValueStoreByPossibleId(client: MemoryStorage
270276
}
271277

272278
export async function findRequestQueueByPossibleId(client: MemoryStorage, entryNameOrId: string) {
273-
// First check memory cache
279+
// First check memory cache — match by id, name, or directoryName (which covers alias lookups)
274280
const found = client.requestQueueCache.find(
275-
(store) => store.id === entryNameOrId || store.name?.toLowerCase() === entryNameOrId.toLowerCase(),
281+
(store) =>
282+
store.id === entryNameOrId ||
283+
store.name?.toLowerCase() === entryNameOrId.toLowerCase() ||
284+
store.directoryName.toLowerCase() === entryNameOrId.toLowerCase(),
276285
);
277286

278287
if (found) {

0 commit comments

Comments
 (0)