@crawlee/core 4.0.0-beta.99 → 4.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/autoscaling/autoscaled_pool.d.ts +3 -21
  2. package/autoscaling/autoscaled_pool.js +105 -97
  3. package/autoscaling/concurrency_system.d.ts +7 -22
  4. package/autoscaling/concurrency_system.js +112 -100
  5. package/autoscaling/cpu_load_signal.d.ts +1 -2
  6. package/autoscaling/cpu_load_signal.js +11 -10
  7. package/autoscaling/event_loop_load_signal.d.ts +1 -4
  8. package/autoscaling/event_loop_load_signal.js +18 -18
  9. package/autoscaling/index.d.ts +1 -1
  10. package/autoscaling/index.js +1 -1
  11. package/autoscaling/load_signal.d.ts +8 -7
  12. package/autoscaling/load_signal.js +14 -12
  13. package/autoscaling/memory_load_signal.d.ts +3 -12
  14. package/autoscaling/memory_load_signal.js +41 -41
  15. package/autoscaling/snapshotter.d.ts +7 -10
  16. package/autoscaling/snapshotter.js +18 -18
  17. package/autoscaling/{client_load_signal.d.ts → storage_backend_load_signal.d.ts} +14 -17
  18. package/autoscaling/{client_load_signal.js → storage_backend_load_signal.js} +27 -27
  19. package/autoscaling/system_status.d.ts +9 -11
  20. package/autoscaling/system_status.js +13 -13
  21. package/configuration.d.ts +16 -16
  22. package/configuration.js +6 -6
  23. package/crawlers/context_pipeline.js +6 -6
  24. package/crawlers/crawler_commons.d.ts +14 -112
  25. package/crawlers/crawler_commons.js +1 -107
  26. package/crawlers/error_snapshotter.d.ts +2 -4
  27. package/crawlers/error_snapshotter.js +5 -5
  28. package/crawlers/index.d.ts +1 -1
  29. package/crawlers/index.js +0 -1
  30. package/crawlers/statistics.d.ts +164 -45
  31. package/crawlers/statistics.js +339 -153
  32. package/debug.js +4 -4
  33. package/enqueue_links/enqueue_links.d.ts +51 -159
  34. package/enqueue_links/enqueue_links.js +38 -231
  35. package/enqueue_links/shared.d.ts +34 -24
  36. package/enqueue_links/shared.js +54 -36
  37. package/errors.d.ts +43 -12
  38. package/errors.js +48 -9
  39. package/events/event_manager.d.ts +2 -2
  40. package/events/event_manager.js +7 -6
  41. package/events/local_event_manager.d.ts +1 -1
  42. package/events/local_event_manager.js +5 -5
  43. package/http.d.ts +9 -0
  44. package/http.js +28 -0
  45. package/index.d.ts +1 -1
  46. package/index.js +1 -1
  47. package/log.js +5 -1
  48. package/memory-storage/memory-storage.d.ts +3 -11
  49. package/memory-storage/memory-storage.js +42 -48
  50. package/memory-storage/resource-clients/dataset.d.ts +1 -1
  51. package/memory-storage/resource-clients/dataset.js +8 -13
  52. package/memory-storage/resource-clients/key-value-store.d.ts +1 -1
  53. package/memory-storage/resource-clients/key-value-store.js +36 -38
  54. package/memory-storage/resource-clients/request-queue.d.ts +4 -23
  55. package/memory-storage/resource-clients/request-queue.js +68 -80
  56. package/owned_or_injected.d.ts +1 -3
  57. package/owned_or_injected.js +17 -17
  58. package/package.json +8 -9
  59. package/proxy_configuration.d.ts +1 -3
  60. package/proxy_configuration.js +18 -14
  61. package/recoverable_state.d.ts +80 -45
  62. package/recoverable_state.js +163 -75
  63. package/request.d.ts +3 -4
  64. package/request.js +57 -44
  65. package/router.d.ts +6 -9
  66. package/router.js +23 -23
  67. package/serialization.js +14 -13
  68. package/service_locator.d.ts +19 -10
  69. package/service_locator.js +57 -48
  70. package/session_pool/session.d.ts +3 -14
  71. package/session_pool/session.js +76 -74
  72. package/session_pool/session_pool.d.ts +6 -12
  73. package/session_pool/session_pool.js +85 -77
  74. package/storages/batched_adds.d.ts +37 -0
  75. package/storages/batched_adds.js +73 -0
  76. package/storages/dataset.d.ts +12 -1
  77. package/storages/dataset.js +133 -32
  78. package/storages/index.d.ts +2 -1
  79. package/storages/index.js +2 -1
  80. package/storages/key_value_store.d.ts +19 -4
  81. package/storages/key_value_store.js +204 -75
  82. package/storages/request_dedup_cache.d.ts +1 -2
  83. package/storages/request_dedup_cache.js +9 -9
  84. package/storages/request_list.d.ts +4 -23
  85. package/storages/request_list.js +103 -96
  86. package/storages/request_loader.d.ts +5 -0
  87. package/storages/request_manager_tandem.d.ts +1 -10
  88. package/storages/request_manager_tandem.js +27 -27
  89. package/storages/request_queue.d.ts +21 -18
  90. package/storages/request_queue.js +318 -177
  91. package/storages/sitemap_request_loader.d.ts +22 -70
  92. package/storages/sitemap_request_loader.js +122 -116
  93. package/storages/storage_instance_manager.d.ts +1 -2
  94. package/storages/storage_instance_manager.js +17 -17
  95. package/storages/storage_stats.d.ts +1 -1
  96. package/storages/storage_stats.js +4 -4
  97. package/storages/throttling_request_manager.d.ts +239 -0
  98. package/storages/throttling_request_manager.js +646 -0
  99. package/storages/transaction.d.ts +252 -0
  100. package/storages/transaction.js +251 -0
  101. package/storages/utils.d.ts +6 -3
  102. package/storages/utils.js +11 -2
  103. package/system-info/runtime.js +7 -7
  104. package/url.d.ts +9 -0
  105. package/url.js +11 -0
  106. package/validators.d.ts +22 -25
  107. package/validators.js +13 -25
  108. package/storages/access_checking.d.ts +0 -12
  109. package/storages/access_checking.js +0 -17
@@ -1,13 +1,17 @@
1
1
  import { inspect } from 'node:util';
2
- import { downloadListOfUrls, isAsyncIterable, isIterable, sleep } from '@crawlee/utils';
3
- import ow from 'ow';
2
+ import { isAsyncIterable, isIterable } from '@crawlee/utils/internal';
3
+ import { downloadListOfUrls } from '@crawlee/utils';
4
+ import { z } from 'zod';
4
5
  import { LruCache } from '@apify/datastructures';
6
+ import { tryCancel } from '@apify/timeout';
5
7
  import { Configuration } from '../configuration.js';
6
8
  import { getObjectType } from '../debug.js';
7
- import { chunkedAsyncIterable, peekableAsyncIterable } from '../iterables.js';
9
+ import { EventType } from '../events/event_manager.js';
8
10
  import { Request } from '../request.js';
9
11
  import { serviceLocator } from '../service_locator.js';
10
- import { checkStorageAccess } from './access_checking.js';
12
+ import { parseArgument, schemas, validators } from '../validators.js';
13
+ import { activeStorageTransaction, rejectOperationInTransaction } from './transaction.js';
14
+ import { drainRequestBatches } from './batched_adds.js';
11
15
  import { StorageStatsTracker } from './storage_stats.js';
12
16
  import { resolveStorageIdentifier } from './storage_instance_manager.js';
13
17
  import { getRequestId, purgeDefaultStorages } from './utils.js';
@@ -17,6 +21,43 @@ import { RequestDeduplicationCache } from './request_dedup_cache.js';
17
21
  * @internal
18
22
  */
19
23
  const MAX_CACHED_REQUESTS = 2_000_000;
24
+ const iterableSchema = z.custom((value) => isIterable(value) || isAsyncIterable(value), {
25
+ error: (issue) => `Expected an iterable or async iterable, got ${getObjectType(issue.input)}`,
26
+ });
27
+ const operationOptionsSchema = z.strictObject({
28
+ forefront: z.boolean().default(false),
29
+ });
30
+ const addRequestsOptionsSchema = z.strictObject({
31
+ forefront: z.boolean().default(false),
32
+ cache: z.boolean().default(true),
33
+ });
34
+ const addRequestsBatchedOptionsSchema = z.strictObject({
35
+ forefront: z.boolean().optional(),
36
+ waitForAllRequestsToBeAdded: z.boolean().default(false),
37
+ batchSize: schemas.anyNumber.default(1000),
38
+ waitBetweenBatchesMillis: schemas.anyNumber.default(1000),
39
+ maxNewRequests: schemas.anyNumber.optional(),
40
+ });
41
+ const newRequestLikeSchema = z.looseObject({
42
+ url: z.string(),
43
+ id: z.undefined().optional(),
44
+ });
45
+ const handledRequestSchema = z.looseObject({
46
+ id: z.string(),
47
+ uniqueKey: z.string(),
48
+ handledAt: z.string().optional(),
49
+ });
50
+ const reclaimedRequestSchema = z.looseObject({
51
+ id: z.string(),
52
+ uniqueKey: z.string(),
53
+ });
54
+ const uniqueKeySchema = z.string();
55
+ const openOptionsSchema = z.strictObject({
56
+ configuration: z.instanceof(Configuration).optional(),
57
+ storageBackend: validators.storageBackend.optional(),
58
+ proxyConfiguration: validators.proxyConfiguration.optional(),
59
+ httpClient: schemas.httpClient.optional(),
60
+ });
20
61
  /**
21
62
  * Represents a queue of URLs to crawl, which is used for deep crawling of websites
22
63
  * where you start with several URLs and then recursively
@@ -55,26 +96,28 @@ export class RequestQueue {
55
96
  id;
56
97
  name;
57
98
  backend;
58
- proxyConfiguration;
99
+ #proxyConfiguration;
59
100
  log;
101
+ // kept as TS-private: request_queue tests read this cache directly
60
102
  requestCache;
61
103
  /**
62
104
  * Remembers the `requestId` of every request already submitted to the client — including background
63
105
  * batches that `requestCache` skips — so overlapping URL sets aren't re-submitted.
64
106
  * See {@link RequestDeduplicationCache} for why this is a separate, cheaper cache.
65
107
  */
66
- requestSeenCache;
67
- queuePausedForMigration = false;
108
+ #requestSeenCache;
109
+ #queuePausedForMigration = false;
110
+ // kept as TS-private: packages/core/test request-queue tests write this counter directly
68
111
  inProgressRequestBatchCount = 0;
69
112
  /**
70
113
  * The largest expected request-processing time (in seconds) seen so far via
71
114
  * {@link setExpectedRequestProcessingTimeSecs}. Used to ensure that value is only ever raised, never
72
115
  * lowered, before being forwarded to the storage backend.
73
116
  */
74
- expectedRequestProcessingSecs = 0;
75
- httpClient;
76
- events;
77
- statsTracker = new StorageStatsTracker({
117
+ #expectedRequestProcessingSecs = 0;
118
+ #httpClient;
119
+ #events;
120
+ #statsTracker = new StorageStatsTracker({
78
121
  writeCount: 0,
79
122
  headItemReadCount: 0,
80
123
  });
@@ -83,7 +126,7 @@ export class RequestQueue {
83
126
  * queue-head reads issued to the underlying storage backend). Counted per backend call.
84
127
  */
85
128
  get stats() {
86
- return this.statsTracker.current;
129
+ return this.#statsTracker.current;
87
130
  }
88
131
  /**
89
132
  * @internal
@@ -91,14 +134,14 @@ export class RequestQueue {
91
134
  constructor(options) {
92
135
  this.id = options.metadata.id;
93
136
  this.name = options.metadata.name;
94
- this.events = serviceLocator.getEventManager();
137
+ this.#events = serviceLocator.getEventManager();
95
138
  this.backend = options.backend;
96
- this.proxyConfiguration = options.proxyConfiguration;
139
+ this.#proxyConfiguration = options.proxyConfiguration;
97
140
  this.requestCache = new LruCache({ maxLength: MAX_CACHED_REQUESTS });
98
- this.requestSeenCache = new RequestDeduplicationCache();
141
+ this.#requestSeenCache = new RequestDeduplicationCache();
99
142
  this.log = serviceLocator.getLogger().child({ prefix: `RequestQueue(${this.id}, ${this.name ?? 'no-name'})` });
100
- this.events.on("migrating" /* EventType.MIGRATING */, async () => {
101
- this.queuePausedForMigration = true;
143
+ this.#events.on(EventType.MIGRATING, async () => {
144
+ this.#queuePausedForMigration = true;
102
145
  });
103
146
  }
104
147
  /**
@@ -134,26 +177,24 @@ export class RequestQueue {
134
177
  * @param [options] Request queue operation options.
135
178
  */
136
179
  async addRequest(requestLike, options = {}) {
137
- checkStorageAccess();
138
- ow(requestLike, ow.object);
139
- ow(options, ow.object.exactShape({
140
- forefront: ow.optional.boolean,
141
- }));
142
- const { forefront = false } = options;
180
+ const transaction = activeStorageTransaction();
181
+ parseArgument(requestLike, schemas.anyObject);
182
+ const { forefront } = parseArgument(options, operationOptionsSchema);
143
183
  if ('requestsFromUrl' in requestLike) {
144
184
  const requests = await this.fetchRequestsFromUrl(requestLike);
145
185
  const processedRequests = await this.addFetchedRequests(requestLike, requests, options);
146
186
  return { ...processedRequests[0], forefront };
147
187
  }
148
- ow(requestLike, ow.object.partialShape({
149
- url: ow.string,
150
- id: ow.undefined,
151
- }));
188
+ parseArgument(requestLike, newRequestLikeSchema);
152
189
  const request = requestLike instanceof Request ? requestLike : new Request(requestLike);
190
+ if (transaction?.policy.requestQueue === 'deferred') {
191
+ return this.addRequestDeferred(transaction, request, forefront);
192
+ }
153
193
  const cacheKey = getRequestId(request.uniqueKey);
154
194
  const cachedInfo = this.requestCache.get(cacheKey);
155
195
  if (cachedInfo) {
156
196
  request.id = cachedInfo.id;
197
+ this.recordRequestJournalEntry(transaction, [request], forefront, true);
157
198
  return {
158
199
  wasAlreadyPresent: true,
159
200
  // We may assume that if request is in local cache then also the information if the
@@ -164,17 +205,163 @@ export class RequestQueue {
164
205
  forefront,
165
206
  };
166
207
  }
167
- this.statsTracker.add('writeCount');
208
+ this.#statsTracker.add('writeCount');
168
209
  const { processedRequests } = await this.backend.addBatchOfRequests([request], { forefront });
210
+ this.recordRequestJournalEntry(transaction, [request], forefront, true);
169
211
  const queueOperationInfo = {
170
212
  ...processedRequests[0],
171
213
  uniqueKey: request.uniqueKey,
172
214
  forefront,
173
215
  };
174
216
  this.cacheRequest(cacheKey, queueOperationInfo);
175
- this.requestSeenCache.add(cacheKey, request.id);
217
+ this.#requestSeenCache.add(cacheKey, request.id);
176
218
  return queueOperationInfo;
177
219
  }
220
+ /**
221
+ * Journals an addition for introspection only; these entries are never replayed. A no-op unless the
222
+ * transaction is open, so detached and outliving writers stay out of the journal.
223
+ */
224
+ recordRequestJournalEntry(transaction, requests, forefront, writeThrough) {
225
+ if (!transaction?.isActive || requests.length === 0)
226
+ return;
227
+ transaction.recordJournalEntry({
228
+ type: 'requestQueue',
229
+ participant: this,
230
+ requests: requests.map((request) => ({
231
+ url: request.url,
232
+ uniqueKey: request.uniqueKey,
233
+ label: request.label,
234
+ })),
235
+ forefront,
236
+ writeThrough,
237
+ });
238
+ }
239
+ /**
240
+ * The requests buffered by the given transaction for this queue, keyed by `uniqueKey` — a dedup
241
+ * index derived from the transaction journal.
242
+ */
243
+ bufferedRequests(transaction) {
244
+ const buffered = new Map();
245
+ // Only `deferred` records snapshots, so scanning the journal under `writeThrough` never finds any.
246
+ if (transaction.policy.requestQueue !== 'deferred')
247
+ return buffered;
248
+ for (const entry of transaction.journal) {
249
+ if (entry.type !== 'requestQueue' || entry.participant !== this)
250
+ continue;
251
+ for (const request of entry.requests) {
252
+ if (request.snapshot !== undefined)
253
+ buffered.set(request.uniqueKey, request.snapshot);
254
+ }
255
+ }
256
+ return buffered;
257
+ }
258
+ /**
259
+ * Adds a request under the `deferred` policy: journaled now, really added by the commit replay.
260
+ * A new request's `requestId` is the local `uniqueKey` hash and is **provisional** — never write it
261
+ * to `request.id` or the dedup caches. Dedup is cheapest-first: buffer, caches, then a backend probe.
262
+ */
263
+ async addRequestDeferred(transaction, request, forefront, buffered = this.bufferedRequests(transaction)) {
264
+ // This transaction's own buffered adds; the shared caches never see them (provisional ids).
265
+ if (buffered.has(request.uniqueKey)) {
266
+ this.recordRequestJournalEntry(transaction, [request], forefront, false);
267
+ return {
268
+ wasAlreadyPresent: true,
269
+ wasAlreadyHandled: false,
270
+ requestId: getRequestId(request.uniqueKey),
271
+ uniqueKey: request.uniqueKey,
272
+ forefront,
273
+ };
274
+ }
275
+ // The caches hold real backend ids. Only *writing* provisional ids to them would be wrong;
276
+ // reading saves a probe. Same lookup as the write-through path.
277
+ const cacheKey = getRequestId(request.uniqueKey);
278
+ const cachedInfo = this.requestCache.get(cacheKey);
279
+ const knownRequestId = cachedInfo?.id ?? this.#requestSeenCache.get(cacheKey);
280
+ if (knownRequestId) {
281
+ this.recordRequestJournalEntry(transaction, [request], forefront, false);
282
+ return {
283
+ wasAlreadyPresent: true,
284
+ // The dedup cache doesn't track the handled state; only the full record does.
285
+ wasAlreadyHandled: cachedInfo?.isHandled ?? false,
286
+ requestId: knownRequestId,
287
+ uniqueKey: request.uniqueKey,
288
+ forefront,
289
+ };
290
+ }
291
+ // The caches are bounded, so a miss is not proof of absence - probe for an accurate answer.
292
+ const existing = await this.backend.getRequest(request.uniqueKey);
293
+ if (existing) {
294
+ this.recordRequestJournalEntry(transaction, [request], forefront, false);
295
+ return {
296
+ wasAlreadyPresent: true,
297
+ wasAlreadyHandled: existing.handledAt != null,
298
+ requestId: existing.id,
299
+ uniqueKey: request.uniqueKey,
300
+ forefront,
301
+ };
302
+ }
303
+ // The entry below *is* the write, so a transaction closed during the probe must not receive it -
304
+ // pass through instead, per the closed-transaction rule. Under `deferred` that can land an
305
+ // addition a rollback would have discarded; dedup bounds that cost, silent loss is unbounded.
306
+ if (!transaction.isActive) {
307
+ return await this.addRequest(request, { forefront });
308
+ }
309
+ const snapshot = JSON.parse(JSON.stringify(request));
310
+ // Strip-list, not allow-list: every user-facing field flows through, including ones added to
311
+ // `Request` in the future. The exceptions are `id` and `handledAt`, the two backend-owned
312
+ // lifecycle fields.
313
+ delete snapshot.id;
314
+ delete snapshot.handledAt;
315
+ transaction.recordJournalEntry({
316
+ type: 'requestQueue',
317
+ participant: this,
318
+ requests: [{ url: request.url, uniqueKey: request.uniqueKey, label: request.label, snapshot }],
319
+ forefront,
320
+ writeThrough: false,
321
+ });
322
+ buffered.set(request.uniqueKey, snapshot);
323
+ return {
324
+ wasAlreadyPresent: false,
325
+ wasAlreadyHandled: false,
326
+ requestId: getRequestId(request.uniqueKey),
327
+ uniqueKey: request.uniqueKey,
328
+ forefront,
329
+ };
330
+ }
331
+ /** @internal */
332
+ async commitJournalEntries(entries) {
333
+ // Replay through `backend.addBatchOfRequests`, *not* the batched frontend wrapper - the wrapper
334
+ // resolves after the first chunk and sleeps between the rest, neither of which commit may
335
+ // inherit. One call per `forefront` flag; the order of forefront additions is arbitrary anyway.
336
+ for (const forefront of [false, true]) {
337
+ const requests = entries.flatMap((entry) => entry.type === 'requestQueue' && entry.forefront === forefront
338
+ ? // Requests without a snapshot were deduplicated or written through; nothing to replay.
339
+ entry.requests
340
+ .filter((journaled) => journaled.snapshot !== undefined)
341
+ .map((journaled) => new Request(journaled.snapshot))
342
+ : []);
343
+ if (requests.length === 0)
344
+ continue;
345
+ this.#statsTracker.add('writeCount');
346
+ const { processedRequests, unprocessedRequests } = await this.backend.addBatchOfRequests(requests, {
347
+ forefront,
348
+ });
349
+ // Only now, with the real backend-assigned ids, may the shared dedup caches be populated.
350
+ for (const processed of processedRequests) {
351
+ const cacheKey = getRequestId(processed.uniqueKey);
352
+ this.cacheRequest(cacheKey, { ...processed, forefront });
353
+ this.#requestSeenCache.add(cacheKey, processed.requestId);
354
+ }
355
+ if (unprocessedRequests.length > 0) {
356
+ // Warn and skip, rather than retry or fail. `unprocessedRequests` is what remains after
357
+ // the backend's own transient-error handling - a semantic rejection that retrying here
358
+ // would only re-poke. And failing the commit would let one malformed request hold the
359
+ // whole transaction hostage.
360
+ this.log.warning('Some requests were rejected by the request queue while committing a storage transaction and will be skipped. ' +
361
+ "This usually means the request data is malformed (e.g. an invalid 'userData' shape).", { unprocessedRequests });
362
+ }
363
+ }
364
+ }
178
365
  /**
179
366
  * Adds requests to the queue in batches of 25. This method will wait till all the requests are added
180
367
  * to the queue before resolving. You should prefer using `queue.addRequestsBatched()` or `crawler.addRequests()`
@@ -190,15 +377,9 @@ export class RequestQueue {
190
377
  * @param [options] Request queue operation options.
191
378
  */
192
379
  async addRequests(requestsLike, options = {}) {
193
- checkStorageAccess();
194
- ow(requestsLike, ow.object
195
- .is((value) => isIterable(value) || isAsyncIterable(value))
196
- .message((value) => `Expected an iterable or async iterable, got ${getObjectType(value)}`));
197
- ow(options, ow.object.exactShape({
198
- forefront: ow.optional.boolean,
199
- cache: ow.optional.boolean,
200
- }));
201
- const { forefront = false, cache = true } = options;
380
+ const transaction = activeStorageTransaction();
381
+ parseArgument(requestsLike, iterableSchema);
382
+ const { forefront, cache } = parseArgument(options, addRequestsOptionsSchema);
202
383
  const uniqueKeyToCacheKey = new Map();
203
384
  const getCachedRequestId = (uniqueKey) => {
204
385
  const cached = uniqueKeyToCacheKey.get(uniqueKey);
@@ -225,12 +406,20 @@ export class RequestQueue {
225
406
  requests.push(requestLike instanceof Request ? requestLike : new Request(requestLike));
226
407
  }
227
408
  }
409
+ if (transaction?.policy.requestQueue === 'deferred') {
410
+ const buffered = this.bufferedRequests(transaction);
411
+ for (const request of requests) {
412
+ results.processedRequests.push(await this.addRequestDeferred(transaction, request, forefront, buffered));
413
+ }
414
+ return results;
415
+ }
416
+ this.recordRequestJournalEntry(transaction, requests, forefront, true);
228
417
  const requestsToAdd = new Map();
229
418
  for (const request of requests) {
230
419
  const cacheKey = getCachedRequestId(request.uniqueKey);
231
420
  // Prefer the full `requestCache` record; fall back to the dedup cache for background batches it skips.
232
421
  const cachedInfo = this.requestCache.get(cacheKey);
233
- const knownRequestId = cachedInfo?.id ?? this.requestSeenCache.get(cacheKey);
422
+ const knownRequestId = cachedInfo?.id ?? this.#requestSeenCache.get(cacheKey);
234
423
  if (knownRequestId) {
235
424
  request.id = knownRequestId;
236
425
  results.processedRequests.push({
@@ -249,7 +438,7 @@ export class RequestQueue {
249
438
  if (!requestsToAdd.size) {
250
439
  return results;
251
440
  }
252
- this.statsTracker.add('writeCount');
441
+ this.#statsTracker.add('writeCount');
253
442
  const apiResults = await this.backend.addBatchOfRequests([...requestsToAdd.values()], { forefront });
254
443
  // Report unprocessed requests
255
444
  results.unprocessedRequests = apiResults.unprocessedRequests;
@@ -262,7 +451,7 @@ export class RequestQueue {
262
451
  this.cacheRequest(cacheKey, { ...newRequest, forefront });
263
452
  }
264
453
  // Unlike `requestCache`, populate this on every batch (including background ones).
265
- this.requestSeenCache.add(cacheKey, newRequest.requestId);
454
+ this.#requestSeenCache.add(cacheKey, newRequest.requestId);
266
455
  }
267
456
  return results;
268
457
  }
@@ -276,17 +465,8 @@ export class RequestQueue {
276
465
  * @param options Options for the request queue
277
466
  */
278
467
  async addRequestsBatched(requests, options = {}) {
279
- checkStorageAccess();
280
- ow(requests, ow.object
281
- .is((value) => isIterable(value) || isAsyncIterable(value))
282
- .message((value) => `Expected an iterable or async iterable, got ${getObjectType(value)}`));
283
- ow(options, ow.object.exactShape({
284
- forefront: ow.optional.boolean,
285
- waitForAllRequestsToBeAdded: ow.optional.boolean,
286
- batchSize: ow.optional.number,
287
- waitBetweenBatchesMillis: ow.optional.number,
288
- maxNewRequests: ow.optional.number,
289
- }));
468
+ parseArgument(requests, iterableSchema);
469
+ const { forefront, waitForAllRequestsToBeAdded, batchSize, waitBetweenBatchesMillis, maxNewRequests } = parseArgument(options, addRequestsBatchedOptionsSchema);
290
470
  const addRequest = this.addRequest.bind(this);
291
471
  async function* generateRequests() {
292
472
  for await (const opts of requests) {
@@ -305,7 +485,7 @@ export class RequestQueue {
305
485
  }
306
486
  if (opts && typeof opts === 'object' && 'requestsFromUrl' in opts) {
307
487
  // Handle URL lists right away
308
- await addRequest(opts, { forefront: options.forefront });
488
+ await addRequest(opts, { forefront });
309
489
  }
310
490
  else {
311
491
  // Yield valid requests
@@ -313,84 +493,36 @@ export class RequestQueue {
313
493
  }
314
494
  }
315
495
  }
316
- const { batchSize = 1000, waitBetweenBatchesMillis = 1000, maxNewRequests = undefined } = options;
317
- let remainingBudget = maxNewRequests ?? Infinity;
318
- const requestsOverLimit = [];
319
- // If there's a limit on the number of added requests, do not send batches bigger than the limit
320
- const effectiveChunkSize = maxNewRequests !== undefined ? () => Math.min(batchSize, remainingBudget) : batchSize;
321
- // Hold onto the underlying iterator so we can drain leftovers from it in buildResult
322
- const requestIterator = generateRequests();
323
- const chunks = peekableAsyncIterable(chunkedAsyncIterable(requestIterator, effectiveChunkSize));
324
- const chunksIterator = chunks[Symbol.asyncIterator]();
325
- /**
326
- * Process a chunk: send it to the queue, then update the remaining budget if maxNewRequests is active.
327
- *
328
- * Requests the backend reports as unprocessed are warned about and skipped rather than retried:
329
- * `unprocessedRequests` is what remains after the backend's own transient-error handling - a
330
- * semantic rejection (e.g. a malformed `userData` shape) that re-sending would only re-poke.
331
- * Retrying transient failures is the storage backend's job, not the frontend's.
332
- */
333
- const processChunk = async (chunk, cache = true) => {
334
- const { processedRequests, unprocessedRequests } = await this.addRequests(chunk, {
335
- forefront: options.forefront,
336
- cache,
337
- });
338
- if (unprocessedRequests.length > 0) {
339
- this.log.warning('Some requests were rejected by the request queue and will be skipped. ' +
340
- "This usually means the request data is malformed (e.g. an invalid 'userData' shape).", { unprocessedRequests });
341
- }
342
- if (maxNewRequests !== undefined) {
343
- remainingBudget -= processedRequests.filter((r) => !r.wasAlreadyPresent).length;
344
- }
345
- return processedRequests;
346
- };
347
- /**
348
- * Build the final result. When maxNewRequests is set, drains any remaining items
349
- * from the underlying request iterator into requestsOverLimit.
350
- *
351
- * We accept the iterator explicitly (rather than closing over it) to make it obvious
352
- * that this is the *same* iterator that `chunkedAsyncIterable` has been consuming —
353
- * so only unconsumed items are drained. We drain `requestIterator` (not `chunks`)
354
- * because `chunkedAsyncIterable` stops yielding when the budget-based chunk size
355
- * drops to 0, leaving unconsumed items in the underlying iterator.
356
- */
357
- const buildResult = async (addedRequests, waitForAllRequestsToBeAdded, unconsumedIterator) => {
358
- if (maxNewRequests !== undefined) {
359
- for await (const request of unconsumedIterator) {
360
- requestsOverLimit.push(request);
496
+ return drainRequestBatches({
497
+ items: generateRequests(),
498
+ batchSize,
499
+ waitBetweenBatchesMillis,
500
+ waitForAllRequestsToBeAdded,
501
+ maxNewRequests,
502
+ /**
503
+ * Requests the backend reports as unprocessed are warned about and skipped rather than retried:
504
+ * `unprocessedRequests` is what remains after the backend's own transient-error handling - a
505
+ * semantic rejection (e.g. a malformed `userData` shape) that re-sending would only re-poke.
506
+ * Retrying transient failures is the storage backend's job, not the frontend's.
507
+ */
508
+ processChunk: async (chunk, isInitial) => {
509
+ const { processedRequests, unprocessedRequests } = await this.addRequests(chunk, {
510
+ forefront,
511
+ cache: isInitial,
512
+ });
513
+ if (unprocessedRequests.length > 0) {
514
+ this.log.warning('Some requests were rejected by the request queue and will be skipped. ' +
515
+ "This usually means the request data is malformed (e.g. an invalid 'userData' shape).", { unprocessedRequests });
361
516
  }
362
- }
363
- return { addedRequests, waitForAllRequestsToBeAdded, requestsOverLimit };
364
- };
365
- // Add initial batch to process right away
366
- const initialChunk = await chunksIterator.peek();
367
- if (initialChunk === undefined) {
368
- return buildResult([], Promise.resolve([]), requestIterator);
369
- }
370
- const addedRequests = await processChunk(initialChunk);
371
- await chunksIterator.next();
372
- // If we have no more requests to add (either exhausted or budget hit), return immediately
373
- if ((await chunksIterator.peek()) === undefined) {
374
- return buildResult(addedRequests, Promise.resolve([]), requestIterator);
375
- }
376
- // eslint-disable-next-line no-async-promise-executor
377
- const promise = new Promise(async (resolve) => {
378
- const finalAddedRequests = [];
379
- for await (const requestChunk of chunks) {
380
- finalAddedRequests.push(...(await processChunk(requestChunk, false)));
381
- await sleep(waitBetweenBatchesMillis);
382
- }
383
- resolve(finalAddedRequests);
384
- });
385
- this.inProgressRequestBatchCount += 1;
386
- void promise.finally(() => {
387
- this.inProgressRequestBatchCount -= 1;
517
+ return processedRequests;
518
+ },
519
+ trackBackgroundBatches: (batches) => {
520
+ this.inProgressRequestBatchCount += 1;
521
+ void batches.finally(() => {
522
+ this.inProgressRequestBatchCount -= 1;
523
+ });
524
+ },
388
525
  });
389
- // When maxNewRequests is set, we must wait for all batches so we can accurately report skipped requests.
390
- if (options.waitForAllRequestsToBeAdded || maxNewRequests !== undefined) {
391
- addedRequests.push(...(await promise));
392
- }
393
- return buildResult(addedRequests, promise, requestIterator);
394
526
  }
395
527
  /**
396
528
  * Gets the request from the queue specified by its `uniqueKey`.
@@ -399,8 +531,13 @@ export class RequestQueue {
399
531
  * @returns Returns the request object, or `null` if it was not found.
400
532
  */
401
533
  async getRequest(uniqueKey) {
402
- checkStorageAccess();
403
- ow(uniqueKey, ow.string);
534
+ const transaction = activeStorageTransaction();
535
+ parseArgument(uniqueKey, uniqueKeySchema);
536
+ // Requests buffered by the active transaction (under the `deferred` write policy) are visible to it.
537
+ const buffered = transaction && this.bufferedRequests(transaction).get(uniqueKey);
538
+ if (buffered) {
539
+ return new Request(buffered);
540
+ }
404
541
  const requestOptions = await this.backend.getRequest(uniqueKey);
405
542
  if (!requestOptions)
406
543
  return null;
@@ -424,11 +561,11 @@ export class RequestQueue {
424
561
  * Returns the request object or `null` if there are no more pending requests.
425
562
  */
426
563
  async fetchNextRequest() {
427
- checkStorageAccess();
428
- if (this.queuePausedForMigration) {
564
+ rejectOperationInTransaction('RequestQueue.fetchNextRequest()', 'it is part of the crawler request-processing bookkeeping, which a transaction must not affect.');
565
+ if (this.#queuePausedForMigration) {
429
566
  return null;
430
567
  }
431
- this.statsTracker.add('headItemReadCount');
568
+ this.#statsTracker.add('headItemReadCount');
432
569
  const requestOptions = await this.backend.fetchNextRequest();
433
570
  if (!requestOptions)
434
571
  return null;
@@ -441,15 +578,11 @@ export class RequestQueue {
441
578
  * Handled requests will never again be returned by the `fetchNextRequest` function.
442
579
  */
443
580
  async markRequestAsHandled(request) {
444
- checkStorageAccess();
445
- ow(request, ow.object.partialShape({
446
- id: ow.string,
447
- uniqueKey: ow.string,
448
- handledAt: ow.optional.string,
449
- }));
581
+ rejectOperationInTransaction('RequestQueue.markRequestAsHandled()', 'it is part of the crawler request-processing bookkeeping, which a transaction must not affect.');
582
+ parseArgument(request, handledRequestSchema);
450
583
  const forefront = this.requestCache.get(getRequestId(request.uniqueKey))?.forefront ?? false;
451
584
  const handledAt = request.handledAt ?? new Date().toISOString();
452
- this.statsTracker.add('writeCount');
585
+ this.#statsTracker.add('writeCount');
453
586
  const processedRequest = await this.backend.markRequestAsHandled({
454
587
  ...request,
455
588
  handledAt,
@@ -474,17 +607,13 @@ export class RequestQueue {
474
607
  * For example, this lets you store the number of retries or error messages for the request.
475
608
  */
476
609
  async reclaimRequest(request, options = {}) {
477
- checkStorageAccess();
478
- ow(request, ow.object.partialShape({
479
- id: ow.string,
480
- uniqueKey: ow.string,
481
- }));
482
- ow(options, ow.object.exactShape({
483
- forefront: ow.optional.boolean,
484
- }));
485
- const { forefront = false } = options;
486
- this.statsTracker.add('writeCount');
487
- const processedRequest = await this.backend.reclaimRequest(request, { forefront });
610
+ rejectOperationInTransaction('RequestQueue.reclaimRequest()', 'it is part of the crawler request-processing bookkeeping, which a transaction must not affect.');
611
+ parseArgument(request, reclaimedRequestSchema);
612
+ const { forefront } = parseArgument(options, operationOptionsSchema);
613
+ this.#statsTracker.add('writeCount');
614
+ const processedRequest = await this.backend.reclaimRequest(request, {
615
+ forefront,
616
+ });
488
617
  // The request was not in progress — nothing to reclaim.
489
618
  if (!processedRequest) {
490
619
  return null;
@@ -508,7 +637,11 @@ export class RequestQueue {
508
637
  * {@link RequestQueue.isFinished}.
509
638
  */
510
639
  async isEmpty() {
511
- checkStorageAccess();
640
+ const transaction = activeStorageTransaction();
641
+ // Requests buffered by the active transaction count as pending from its point of view.
642
+ if (transaction && this.bufferedRequests(transaction).size > 0) {
643
+ return false;
644
+ }
512
645
  return this.backend.isEmpty();
513
646
  }
514
647
  /**
@@ -520,11 +653,15 @@ export class RequestQueue {
520
653
  * a false negative, but it shall never return a false positive.
521
654
  */
522
655
  async isFinished() {
523
- checkStorageAccess();
656
+ const transaction = activeStorageTransaction();
524
657
  // We are not finished if we're still adding new requests in the background.
525
658
  if (this.inProgressRequestBatchCount > 0) {
526
659
  return false;
527
660
  }
661
+ // Requests buffered by the active transaction count as pending from its point of view.
662
+ if (transaction && this.bufferedRequests(transaction).size > 0) {
663
+ return false;
664
+ }
528
665
  return this.backend.isFinished();
529
666
  }
530
667
  /**
@@ -538,10 +675,10 @@ export class RequestQueue {
538
675
  * short the reservation of a long-lived one and have its in-flight request stolen.
539
676
  */
540
677
  async setExpectedRequestProcessingTimeSecs(secs) {
541
- if (secs <= this.expectedRequestProcessingSecs) {
678
+ if (secs <= this.#expectedRequestProcessingSecs) {
542
679
  return;
543
680
  }
544
- this.expectedRequestProcessingSecs = secs;
681
+ this.#expectedRequestProcessingSecs = secs;
545
682
  await this.backend.setExpectedRequestProcessingTimeSecs?.(secs);
546
683
  }
547
684
  /**
@@ -564,7 +701,7 @@ export class RequestQueue {
564
701
  * depending on the mode of operation.
565
702
  */
566
703
  async drop() {
567
- checkStorageAccess();
704
+ rejectOperationInTransaction('RequestQueue.drop()');
568
705
  await this.backend.drop();
569
706
  serviceLocator.getStorageInstanceManager().removeFromCache(this);
570
707
  }
@@ -573,16 +710,16 @@ export class RequestQueue {
573
710
  * so it can be reused (e.g. across multiple `crawler.run()` calls).
574
711
  */
575
712
  async purge() {
576
- checkStorageAccess();
713
+ rejectOperationInTransaction('RequestQueue.purge()');
577
714
  await this.backend.purge();
578
715
  // Reset in-memory bookkeeping so the queue behaves as if freshly opened.
579
716
  this.requestCache.clear();
580
- this.requestSeenCache.clear();
717
+ this.#requestSeenCache.clear();
581
718
  this.inProgressRequestBatchCount = 0;
582
719
  // Reset the expected-processing-time high-water mark too, otherwise the monotonic-raise guard
583
720
  // in `setExpectedRequestProcessingTimeSecs` would let a value raised in an earlier run leak into a
584
721
  // later one and silently swallow a lower hint (the queue is meant to be reusable across runs).
585
- this.expectedRequestProcessingSecs = 0;
722
+ this.#expectedRequestProcessingSecs = 0;
586
723
  }
587
724
  /**
588
725
  * @inheritdoc
@@ -630,8 +767,17 @@ export class RequestQueue {
630
767
  * @throws If the underlying storage no longer exists (e.g. it was deleted externally).
631
768
  */
632
769
  async getInfo() {
633
- checkStorageAccess();
634
- return this.backend.getMetadata();
770
+ const transaction = activeStorageTransaction();
771
+ const metadata = await this.backend.getMetadata();
772
+ const bufferedCount = transaction ? this.bufferedRequests(transaction).size : 0;
773
+ if (bufferedCount > 0) {
774
+ return {
775
+ ...metadata,
776
+ totalRequestCount: metadata.totalRequestCount + bufferedCount,
777
+ pendingRequestCount: metadata.pendingRequestCount + bufferedCount,
778
+ };
779
+ }
780
+ return metadata;
635
781
  }
636
782
  /**
637
783
  * Fetches URLs from requestsFromUrl and returns them in format of list of requests
@@ -641,10 +787,10 @@ export class RequestQueue {
641
787
  // Download remote resource and parse URLs.
642
788
  let urlsArr;
643
789
  try {
644
- urlsArr = await this._downloadListOfUrls({
790
+ urlsArr = await this.downloadListOfUrls({
645
791
  url: requestsFromUrl,
646
792
  urlRegExp: regex,
647
- proxyUrl: (await this.proxyConfiguration?.newProxyInfo())?.url,
793
+ proxyUrl: (await this.#proxyConfiguration?.newProxyInfo())?.url,
648
794
  });
649
795
  }
650
796
  catch (err) {
@@ -676,10 +822,10 @@ export class RequestQueue {
676
822
  /**
677
823
  * @internal wraps public utility for mocking purposes
678
824
  */
679
- async _downloadListOfUrls(options) {
825
+ async downloadListOfUrls(options) {
680
826
  return downloadListOfUrls({
681
827
  ...options,
682
- httpClient: this.httpClient,
828
+ httpClient: this.#httpClient,
683
829
  });
684
830
  }
685
831
  /**
@@ -700,15 +846,10 @@ export class RequestQueue {
700
846
  * @param [options] Open Request Queue options.
701
847
  */
702
848
  static async open(identifier, options = {}) {
703
- checkStorageAccess();
704
- ow(options, ow.object.exactShape({
705
- configuration: ow.optional.object.instanceOf(Configuration),
706
- storageBackend: ow.optional.object,
707
- proxyConfiguration: ow.optional.object,
708
- httpClient: ow.optional.object,
709
- }));
710
- const storageBackend = options.storageBackend ?? serviceLocator.getStorageBackend();
711
- const configuration = options.configuration ?? serviceLocator.getConfiguration();
849
+ tryCancel();
850
+ const parsedOptions = parseArgument(options, openOptionsSchema);
851
+ const storageBackend = parsedOptions.storageBackend ?? serviceLocator.getStorageBackend();
852
+ const configuration = parsedOptions.configuration ?? serviceLocator.getConfiguration();
712
853
  await purgeDefaultStorages({ onlyPurgeOnce: true, storageBackend, configuration });
713
854
  const resolved = await resolveStorageIdentifier(identifier, storageBackend, 'RequestQueue');
714
855
  const queue = await serviceLocator
@@ -718,8 +859,8 @@ export class RequestQueue {
718
859
  backendOpener: () => storageBackend.createRequestQueueBackend(resolved),
719
860
  backendCacheKey: storageBackend.getStorageBackendCacheKey?.() ?? storageBackend.constructor.name,
720
861
  });
721
- queue.proxyConfiguration = options.proxyConfiguration;
722
- queue.httpClient = options.httpClient;
862
+ queue.#proxyConfiguration = parsedOptions.proxyConfiguration;
863
+ queue.#httpClient = parsedOptions.httpClient;
723
864
  return queue;
724
865
  }
725
866
  }