Skip to content

Commit 82801e2

Browse files
authored
fix: Rectify inconsistencies in the StorageClient interface family (#3800)
1 parent 39f689f commit 82801e2

30 files changed

Lines changed: 481 additions & 242 deletions

docs/public-api/crawlee-core.api.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ export class Dataset<Data extends Dictionary = Dictionary> {
363363
reduce(iteratee: DatasetReducer<Data, Data>): Promise<Data | undefined>;
364364
reduce(iteratee: DatasetReducer<Data, Data>, memo: undefined, options: DatasetIteratorOptions): Promise<Data | undefined>;
365365
reduce<T>(iteratee: DatasetReducer<T, Data>, memo: T, options?: DatasetIteratorOptions): Promise<T>;
366+
get stats(): DatasetStats;
366367
values(options?: DatasetIteratorOptions): AsyncIterable<Data> & Promise<Data[]>;
367368
}
368369

@@ -447,6 +448,12 @@ export interface DatasetReducer<T, Data> {
447448
(memo: T, item: Data, index: number): Awaitable_2<T>;
448449
}
449450

451+
// @public
452+
export interface DatasetStats {
453+
readCount: number;
454+
writeCount: number;
455+
}
456+
450457
// @public
451458
export interface DefaultStorageIdentifier {
452459
// (undocumented)
@@ -740,7 +747,7 @@ export interface IRequestManager extends IRequestLoader {
740747
addRequestsBatched(requests: RequestsLike, options?: AddRequestsBatchedOptions): Promise<AddRequestsBatchedResult>;
741748
purge?(): Promise<void>;
742749
reclaimRequest(request: Request_2, options?: RequestQueueOperationOptions): Promise<RequestQueueOperationInfo | null>;
743-
setExpectedRequestProcessingTimeSecs?(secs: number): void;
750+
setExpectedRequestProcessingTimeSecs?(secs: number): Promise<void>;
744751
}
745752

746753
// @internal (undocumented)
@@ -796,6 +803,7 @@ export class KeyValueStore {
796803
static recordExists(key: string): Promise<boolean>;
797804
setValue<T>(key: string, value: T | null, options?: RecordOptions): Promise<void>;
798805
static setValue<T>(key: string, value: T | null, options?: RecordOptions): Promise<void>;
806+
get stats(): KeyValueStoreStats;
799807
values<T = unknown>(options?: KeyValueStoreIteratorOptions): AsyncIterable<T> & Promise<T[]>;
800808
}
801809

@@ -822,6 +830,14 @@ export interface KeyValueStoreRawRecord {
822830
value: Buffer | ArrayBuffer;
823831
}
824832

833+
// @public
834+
export interface KeyValueStoreStats {
835+
deleteCount: number;
836+
listCount: number;
837+
readCount: number;
838+
writeCount: number;
839+
}
840+
825841
// @internal (undocumented)
826842
export type LoadedContext<Context extends RestrictedCrawlingContext> = IsAny<Context> extends true ? Context : {
827843
request: LoadedRequest<Context['request']>;
@@ -1213,7 +1229,7 @@ export class RequestManagerTandem implements IRequestManager {
12131229
purge(): Promise<void>;
12141230
// (undocumented)
12151231
reclaimRequest(request: Request_2, options?: RequestQueueOperationOptions): Promise<RequestQueueOperationInfo | null>;
1216-
setExpectedRequestProcessingTimeSecs(secs: number): void;
1232+
setExpectedRequestProcessingTimeSecs(secs: number): Promise<void>;
12171233
}
12181234

12191235
// @public
@@ -1294,7 +1310,8 @@ export class RequestQueue implements IStorage, IRequestManager {
12941310
reclaimRequest(request: Request_2, options?: RequestQueueOperationOptions): Promise<RequestQueueOperationInfo | null>;
12951311
// (undocumented)
12961312
protected requestCache: LruCache<RequestLruItem>;
1297-
setExpectedRequestProcessingTimeSecs(secs: number): void;
1313+
setExpectedRequestProcessingTimeSecs(secs: number): Promise<void>;
1314+
get stats(): RequestQueueStats;
12981315
// (undocumented)
12991316
timeoutSecs: number;
13001317
}
@@ -1325,6 +1342,12 @@ export interface RequestQueueOptions {
13251342
proxyConfiguration?: ProxyConfiguration;
13261343
}
13271344

1345+
// @public
1346+
export interface RequestQueueStats {
1347+
headItemReadCount: number;
1348+
writeCount: number;
1349+
}
1350+
13281351
// @internal (undocumented)
13291352
export const REQUESTS_PERSISTENCE_KEY = "REQUEST_LIST_REQUESTS";
13301353

@@ -1886,6 +1909,13 @@ export interface StorageOpenOptions {
18861909
storageClient?: StorageClient;
18871910
}
18881911

1912+
// @public
1913+
export class StorageStatsTracker<T extends Record<keyof T, number>> {
1914+
constructor(initial: T);
1915+
add(key: keyof T, by?: number): void;
1916+
get current(): T;
1917+
}
1918+
18891919
// @public
18901920
export interface SystemInfo {
18911921
// (undocumented)

docs/public-api/crawlee-types.api.md

Lines changed: 22 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,6 @@ export interface DatasetInfo {
113113
// (undocumented)
114114
accessedAt: Date;
115115
// (undocumented)
116-
actId?: string;
117-
// (undocumented)
118-
actRunId?: string;
119-
// (undocumented)
120116
createdAt: Date;
121117
// (undocumented)
122118
id: string;
@@ -128,18 +124,6 @@ export interface DatasetInfo {
128124
name?: string;
129125
}
130126

131-
// @public (undocumented)
132-
export interface DatasetStats {
133-
// (undocumented)
134-
deleteCount?: number;
135-
// (undocumented)
136-
readCount?: number;
137-
// (undocumented)
138-
storageBytes?: number;
139-
// (undocumented)
140-
writeCount?: number;
141-
}
142-
143127
// @public (undocumented)
144128
export type Dictionary<T = any> = Record<PropertyKey, T>;
145129

@@ -230,7 +214,7 @@ export interface KeyValueStoreClient {
230214
getMetadata(): Promise<KeyValueStoreInfo>;
231215
getPublicUrl(key: string): Promise<string | undefined>;
232216
getValue(key: string): Promise<KeyValueStoreRecord | undefined>;
233-
listKeys(options?: KeyValueStoreListKeysOptions): Promise<KeyValueStoreItemData[]>;
217+
listKeys(options?: KeyValueStoreListKeysOptions): Promise<KeyValueStoreListKeysResult>;
234218
purge(): Promise<void>;
235219
recordExists(key: string): Promise<boolean>;
236220
setValue(record: KeyValueStoreInputRecord): Promise<void>;
@@ -241,21 +225,13 @@ export interface KeyValueStoreInfo {
241225
// (undocumented)
242226
accessedAt: Date;
243227
// (undocumented)
244-
actId?: string;
245-
// (undocumented)
246-
actRunId?: string;
247-
// (undocumented)
248228
createdAt: Date;
249229
// (undocumented)
250230
id: string;
251231
// (undocumented)
252232
modifiedAt: Date;
253233
// (undocumented)
254234
name?: string;
255-
// (undocumented)
256-
stats?: KeyValueStoreStats;
257-
// (undocumented)
258-
userId?: string;
259235
}
260236

261237
// @public
@@ -270,6 +246,7 @@ export interface KeyValueStoreInputRecord {
270246

271247
// @public (undocumented)
272248
export interface KeyValueStoreItemData {
249+
contentType: string;
273250
// (undocumented)
274251
key: string;
275252
// (undocumented)
@@ -283,6 +260,16 @@ export interface KeyValueStoreListKeysOptions {
283260
prefix?: string;
284261
}
285262

263+
// @public
264+
export interface KeyValueStoreListKeysResult {
265+
count: number;
266+
exclusiveStartKey?: string;
267+
isTruncated: boolean;
268+
items: KeyValueStoreItemData[];
269+
limit: number;
270+
nextExclusiveStartKey?: string;
271+
}
272+
286273
// @public
287274
export interface KeyValueStoreRecord {
288275
// (undocumented)
@@ -296,20 +283,6 @@ export interface KeyValueStoreRecord {
296283
// @public
297284
export type KeyValueStoreRecordInputValue = Buffer | ArrayBuffer | ArrayBufferView | string | NodeJS.ReadableStream | ReadableStream;
298285

299-
// @public (undocumented)
300-
export interface KeyValueStoreStats {
301-
// (undocumented)
302-
deleteCount?: number;
303-
// (undocumented)
304-
listCount?: number;
305-
// (undocumented)
306-
readCount?: number;
307-
// (undocumented)
308-
storageBytes?: number;
309-
// (undocumented)
310-
writeCount?: number;
311-
}
312-
313286
// @public
314287
export interface NewPageOptions {
315288
id?: string;
@@ -321,7 +294,7 @@ export interface PageState {
321294
cookies: Cookie[];
322295
}
323296

324-
// @public (undocumented)
297+
// @public
325298
export interface PaginatedList<Data> {
326299
count: number;
327300
desc?: boolean;
@@ -366,44 +339,28 @@ export type RedirectHandler = (redirectResponse: Response, updatedRequest: {
366339
headers: Headers;
367340
}) => void;
368341

369-
// @public (undocumented)
370-
export interface RequestOptions {
371-
// (undocumented)
372-
[k: string]: unknown;
373-
// (undocumented)
374-
forefront?: boolean;
375-
}
376-
377342
// @public
378343
export interface RequestQueueClient {
379-
addBatchOfRequests(requests: RequestSchema[], options?: RequestOptions): Promise<BatchAddRequestsResult>;
344+
addBatchOfRequests(requests: RequestSchema[], options?: RequestQueueOperationOptions): Promise<BatchAddRequestsResult>;
380345
drop(): Promise<void>;
381-
fetchNextRequest(): Promise<RequestOptions | null>;
346+
fetchNextRequest(): Promise<UpdateRequestSchema | undefined>;
382347
getMetadata(): Promise<RequestQueueInfo>;
383-
getRequest(uniqueKey: string): Promise<RequestOptions | undefined>;
348+
getRequest(uniqueKey: string): Promise<UpdateRequestSchema | undefined>;
384349
isEmpty(): Promise<boolean>;
385350
isFinished(): Promise<boolean>;
386-
markRequestAsHandled(request: UpdateRequestSchema): Promise<QueueOperationInfo | null>;
351+
markRequestAsHandled(request: UpdateRequestSchema): Promise<QueueOperationInfo | undefined>;
387352
purge(): Promise<void>;
388-
reclaimRequest(request: UpdateRequestSchema, options?: RequestOptions): Promise<QueueOperationInfo | null>;
389-
setExpectedRequestProcessingTimeSecs?(secs: number): void;
353+
reclaimRequest(request: UpdateRequestSchema, options?: RequestQueueOperationOptions): Promise<QueueOperationInfo | undefined>;
354+
setExpectedRequestProcessingTimeSecs?(secs: number): Promise<void>;
390355
}
391356

392357
// @public (undocumented)
393358
export interface RequestQueueInfo {
394359
// (undocumented)
395360
accessedAt: Date;
396361
// (undocumented)
397-
actId?: string;
398-
// (undocumented)
399-
actRunId?: string;
400-
// (undocumented)
401362
createdAt: Date;
402363
// (undocumented)
403-
expireAt?: string;
404-
// (undocumented)
405-
hadMultipleClients?: boolean;
406-
// (undocumented)
407364
handledRequestCount: number;
408365
// (undocumented)
409366
id: string;
@@ -414,25 +371,12 @@ export interface RequestQueueInfo {
414371
// (undocumented)
415372
pendingRequestCount: number;
416373
// (undocumented)
417-
stats?: RequestQueueStats;
418-
// (undocumented)
419374
totalRequestCount: number;
420-
// (undocumented)
421-
userId?: string;
422375
}
423376

424-
// @public (undocumented)
425-
export interface RequestQueueStats {
426-
// (undocumented)
427-
deleteCount?: number;
428-
// (undocumented)
429-
headItemReadCount?: number;
430-
// (undocumented)
431-
readCount?: number;
432-
// (undocumented)
433-
storageBytes?: number;
434-
// (undocumented)
435-
writeCount?: number;
377+
// @public
378+
export interface RequestQueueOperationOptions {
379+
forefront?: boolean;
436380
}
437381

438382
// @public (undocumented)

docs/upgrading/upgrading_v4.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,7 @@ The sub-client interfaces (`DatasetClient`, `KeyValueStoreClient`, `RequestQueue
557557
| `setRecord(record, options?)` | `setValue(record)` |
558558
| `deleteRecord(key)` | `deleteValue(key)` |
559559
| `getRecordPublicUrl(key)` | `getPublicUrl(key)` |
560-
| `listKeys(options?)``KeyValueStoreClientListData` | `listKeys(options?)``KeyValueStoreItemData[]` |
560+
| `listKeys(options?)``KeyValueStoreClientListData` | `listKeys(options?)``KeyValueStoreListKeysResult` (a single self-describing page) |
561561
| `keys()`, `values()`, `entries()` | Removed (handled by `KeyValueStore` frontend) |
562562

563563
**`RequestQueueClient`:**
@@ -580,9 +580,11 @@ The request queue client was reduced from 12 methods to 10. The distributed-lock
580580

581581
The lifecycle is now: `fetchNextRequest()` hands out a pending request and marks it in progress; once processed, call `markRequestAsHandled(request)`; on failure call `reclaimRequest(request, { forefront? })` to return it to the queue.
582582

583+
Methods that may have "nothing" to return now consistently resolve to `undefined` rather than `null`. `fetchNextRequest()` resolves to `undefined` when there is nothing to fetch, and `markRequestAsHandled()` / `reclaimRequest()` resolve to `undefined` when the request is not something the client is currently processing (a no-op, not an error). This matches the `undefined` already returned by `getRequest()`, `KeyValueStoreClient.getValue()`, and `getPublicUrl()`, so the whole client family uses a single "absent" sentinel. If you implemented a custom client that returned `null` from these methods, return `undefined` instead.
584+
583585
`RequestQueueClient.isEmpty()` and `RequestQueueClient.isFinished()` answer two different questions:
584586

585-
- `isEmpty()` is the weak check — `true` when the next `fetchNextRequest()` would return `null`, i.e. there is nothing left to fetch right now. Requests that are currently in progress (fetched but not yet handled or reclaimed) are **not** counted, because they are not fetchable. This is what drives the crawler's task scheduling.
587+
- `isEmpty()` is the weak check — `true` when the next `fetchNextRequest()` would return `undefined`, i.e. there is nothing left to fetch right now. Requests that are currently in progress (fetched but not yet handled or reclaimed) are **not** counted, because they are not fetchable. This is what drives the crawler's task scheduling.
586588
- `isFinished()` is the strong check — `true` only when there are no pending requests **and** no requests currently in progress (including those locked by other clients sharing the queue). This is what determines whether crawling is actually done. An in-progress request keeps the queue *empty but not finished*, which is what stops a crawler from shutting down while a request is still being processed.
587589

588590
The separate `RequestQueueV1`/`RequestQueueV2` classes (and the `RequestProvider` base class) have been removed. They no longer differ in behavior — request coordination is now internal to the storage client — so they are merged into a single `RequestQueue` class. Replace any `RequestQueueV1`, `RequestQueueV2`, or `RequestProvider` imports with `RequestQueue`.
@@ -613,7 +615,9 @@ queue.setExpectedRequestProcessingTimeSecs(600);
613615

614616
The `RequestQueue.internalTimeoutMillis` property and the associated "stuck queue" self-recovery have been removed. In v3 the `RequestQueue` frontend kept its own copy of the queue head and in-progress set, which could drift out of sync with the backing storage (an eventual-consistency hazard on the Apify platform); `isFinished()` watched for inactivity exceeding `internalTimeoutMillis` and reset that frontend state to recover. In v4 the frontend no longer holds any such bookkeeping — the storage client is the single source of truth — so there is nothing for a reset to fix, and stuck request locks now self-heal on expiry. Any consistency-recovery logic that is genuinely specific to the Apify platform's distributed storage belongs in the Apify SDK's client implementation instead, and is tracked in [apify/crawlee#3328](https://github.com/apify/crawlee/issues/3328).
615617

616-
**Removed types** from `@crawlee/types`: `DatasetClientUpdateOptions`, `KeyValueStoreClientUpdateOptions`, `KeyValueStoreRecordOptions`, `KeyValueStoreClientListData`, `KeyValueStoreClientGetRecordOptions`, `QueueHead`, `RequestQueueHeadItem`, `ListOptions`, `ListAndLockOptions`, `ListAndLockHeadResult`, `ProlongRequestLockOptions`, `ProlongRequestLockResult`, `DeleteRequestLockOptions`. `KeyValueStoreClientListOptions` was renamed to `KeyValueStoreListKeysOptions`.
618+
**Apify-specific fields removed from storage metadata.** The metadata returned by `getMetadata()` (`DatasetInfo`, `KeyValueStoreInfo`, `RequestQueueInfo`) has been trimmed to what is meaningful for any storage backend. The following platform-specific fields were dropped: `actId`, `actRunId`, `userId`, and — on `RequestQueueInfo``expireAt` and `hadMultipleClients`. The per-storage `stats` field (and its `DatasetStats` / `KeyValueStoreStats` / `RequestQueueStats` types) was removed as well. If you consumed any of these, read them from the Apify API client directly; a custom `StorageClient` should simply stop returning them.
619+
620+
**Removed types** from `@crawlee/types`: `DatasetClientUpdateOptions`, `KeyValueStoreClientUpdateOptions`, `KeyValueStoreRecordOptions`, `KeyValueStoreClientListData`, `KeyValueStoreClientGetRecordOptions`, `QueueHead`, `RequestQueueHeadItem`, `ListOptions`, `ListAndLockOptions`, `ListAndLockHeadResult`, `ProlongRequestLockOptions`, `ProlongRequestLockResult`, `DeleteRequestLockOptions`, `DatasetStats`, `KeyValueStoreStats`, `RequestQueueStats`. `KeyValueStoreClientListOptions` was renamed to `KeyValueStoreListKeysOptions`.
617621

618622
The high-level storage classes (`Dataset`, `KeyValueStore`, `RequestQueue`) now receive their sub-client directly in the constructor options instead of receiving a `StorageClient` and calling its methods.
619623

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

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,13 @@ export class BasicCrawler<
582582
*/
583583
private ownedRequestManager?: IRequestManager;
584584

585+
/**
586+
* Whether the request-processing-time hint has already been forwarded to the request manager. The hint
587+
* derives only from `requestHandlerTimeoutMillis` (constant for the crawler's lifetime) and is raise-only,
588+
* so it only needs to be applied once, at the first async access of the manager.
589+
*/
590+
private requestManagerTimeoutsApplied = false;
591+
585592
/**
586593
* A reference to the underlying {@apilink AutoscaledPool} class that manages the concurrency of the crawler.
587594
* > *NOTE:* This property is only initialized after calling the {@apilink BasicCrawler.run|`crawler.run()`} function.
@@ -858,11 +865,6 @@ export class BasicCrawler<
858865
this.internalTimeoutMillis =
859866
tryEnv(process.env.CRAWLEE_INTERNAL_TIMEOUT) ?? Math.max(this.requestHandlerTimeoutMillis * 2, 300e3);
860867

861-
// override the default internal timeout of request queue to respect `requestHandlerTimeoutMillis`
862-
if (this.requestManager !== undefined) {
863-
this.applyRequestManagerTimeouts(this.requestManager);
864-
}
865-
866868
this.maxRequestRetries = maxRequestRetries;
867869
this.maxCrawlDepth = maxCrawlDepth;
868870
this.sameDomainDelayMillis = sameDomainDelaySecs * 1000;
@@ -1435,6 +1437,14 @@ export class BasicCrawler<
14351437
this.requestManager = await this.openOwnedRequestQueue();
14361438
}
14371439

1440+
// Apply the processing-time hint here (an async lifecycle point) rather than in the constructor,
1441+
// now that `setExpectedRequestProcessingTimeSecs` is async. The hint is raise-only and idempotent,
1442+
// but guard so we do not re-issue it on every call.
1443+
if (!this.requestManagerTimeoutsApplied) {
1444+
this.requestManagerTimeoutsApplied = true;
1445+
await this.applyRequestManagerTimeouts(this.requestManager);
1446+
}
1447+
14381448
return this.requestManager;
14391449
}
14401450

@@ -1458,7 +1468,6 @@ export class BasicCrawler<
14581468
this.crawlerInstanceIndex === 0 ? null : { alias: `__default_${this.crawlerInstanceIndex}__` };
14591469

14601470
const requestQueue = await RequestQueue.open(identifier, { config: serviceLocator.getConfiguration() });
1461-
this.applyRequestManagerTimeouts(requestQueue);
14621471
this.ownedRequestManager = requestQueue;
14631472
return requestQueue;
14641473
}
@@ -1470,8 +1479,8 @@ export class BasicCrawler<
14701479
* request from being handed out a second time while it is still being processed — and it works
14711480
* regardless of whether the manager is a plain {@apilink RequestQueue} or a `RequestManagerTandem`.
14721481
*/
1473-
private applyRequestManagerTimeouts(requestManager: IRequestManager): void {
1474-
requestManager.setExpectedRequestProcessingTimeSecs?.(
1482+
private async applyRequestManagerTimeouts(requestManager: IRequestManager): Promise<void> {
1483+
await requestManager.setExpectedRequestProcessingTimeSecs?.(
14751484
Math.max(this.requestHandlerTimeoutMillis / 1000 + 5, 60),
14761485
);
14771486
}

0 commit comments

Comments
 (0)