@crawlee/core 4.0.0-beta.103 → 4.0.0-beta.105
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.
- package/crawlers/crawler_commons.d.ts +6 -56
- package/crawlers/crawler_commons.js +1 -107
- package/crawlers/index.d.ts +1 -1
- package/crawlers/index.js +0 -1
- package/package.json +5 -5
- package/storages/dataset.d.ts +11 -0
- package/storages/dataset.js +118 -19
- package/storages/index.d.ts +1 -1
- package/storages/index.js +1 -1
- package/storages/key_value_store.d.ts +18 -0
- package/storages/key_value_store.js +154 -29
- package/storages/request_queue.d.ts +19 -0
- package/storages/request_queue.js +222 -21
- package/storages/transaction.d.ts +254 -0
- package/storages/transaction.js +251 -0
- package/storages/access_checking.d.ts +0 -12
- package/storages/access_checking.js +0 -17
|
@@ -2,12 +2,13 @@ import { inspect } from 'node:util';
|
|
|
2
2
|
import { downloadListOfUrls, isAsyncIterable, isIterable, sleep } from '@crawlee/utils';
|
|
3
3
|
import ow from 'ow';
|
|
4
4
|
import { LruCache } from '@apify/datastructures';
|
|
5
|
+
import { tryCancel } from '@apify/timeout';
|
|
5
6
|
import { Configuration } from '../configuration.js';
|
|
6
7
|
import { getObjectType } from '../debug.js';
|
|
7
8
|
import { chunkedAsyncIterable, peekableAsyncIterable } from '../iterables.js';
|
|
8
9
|
import { Request } from '../request.js';
|
|
9
10
|
import { serviceLocator } from '../service_locator.js';
|
|
10
|
-
import {
|
|
11
|
+
import { activeStorageTransaction, rejectOperationInTransaction, withDirectStorageAccess } from './transaction.js';
|
|
11
12
|
import { StorageStatsTracker } from './storage_stats.js';
|
|
12
13
|
import { resolveStorageIdentifier } from './storage_instance_manager.js';
|
|
13
14
|
import { getRequestId, purgeDefaultStorages } from './utils.js';
|
|
@@ -134,7 +135,7 @@ export class RequestQueue {
|
|
|
134
135
|
* @param [options] Request queue operation options.
|
|
135
136
|
*/
|
|
136
137
|
async addRequest(requestLike, options = {}) {
|
|
137
|
-
|
|
138
|
+
const transaction = activeStorageTransaction();
|
|
138
139
|
ow(requestLike, ow.object);
|
|
139
140
|
ow(options, ow.object.exactShape({
|
|
140
141
|
forefront: ow.optional.boolean,
|
|
@@ -150,10 +151,14 @@ export class RequestQueue {
|
|
|
150
151
|
id: ow.undefined,
|
|
151
152
|
}));
|
|
152
153
|
const request = requestLike instanceof Request ? requestLike : new Request(requestLike);
|
|
154
|
+
if (transaction?.policy.requestQueue === 'deferred') {
|
|
155
|
+
return this.addRequestDeferred(transaction, request, forefront);
|
|
156
|
+
}
|
|
153
157
|
const cacheKey = getRequestId(request.uniqueKey);
|
|
154
158
|
const cachedInfo = this.requestCache.get(cacheKey);
|
|
155
159
|
if (cachedInfo) {
|
|
156
160
|
request.id = cachedInfo.id;
|
|
161
|
+
this.recordRequestJournalEntry(transaction, [request], forefront, true);
|
|
157
162
|
return {
|
|
158
163
|
wasAlreadyPresent: true,
|
|
159
164
|
// We may assume that if request is in local cache then also the information if the
|
|
@@ -166,6 +171,7 @@ export class RequestQueue {
|
|
|
166
171
|
}
|
|
167
172
|
this.statsTracker.add('writeCount');
|
|
168
173
|
const { processedRequests } = await this.backend.addBatchOfRequests([request], { forefront });
|
|
174
|
+
this.recordRequestJournalEntry(transaction, [request], forefront, true);
|
|
169
175
|
const queueOperationInfo = {
|
|
170
176
|
...processedRequests[0],
|
|
171
177
|
uniqueKey: request.uniqueKey,
|
|
@@ -175,6 +181,151 @@ export class RequestQueue {
|
|
|
175
181
|
this.requestSeenCache.add(cacheKey, request.id);
|
|
176
182
|
return queueOperationInfo;
|
|
177
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Journals an addition for introspection only; these entries are never replayed. A no-op unless the
|
|
186
|
+
* transaction is open, so detached and outliving writers stay out of the journal.
|
|
187
|
+
*/
|
|
188
|
+
recordRequestJournalEntry(transaction, requests, forefront, writeThrough) {
|
|
189
|
+
if (!transaction?.isActive || requests.length === 0)
|
|
190
|
+
return;
|
|
191
|
+
transaction.recordJournalEntry({
|
|
192
|
+
type: 'requestQueue',
|
|
193
|
+
participant: this,
|
|
194
|
+
requests: requests.map((request) => ({
|
|
195
|
+
url: request.url,
|
|
196
|
+
uniqueKey: request.uniqueKey,
|
|
197
|
+
label: request.label,
|
|
198
|
+
})),
|
|
199
|
+
forefront,
|
|
200
|
+
writeThrough,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The requests buffered by the given transaction for this queue, keyed by `uniqueKey` — a dedup
|
|
205
|
+
* index derived from the transaction journal.
|
|
206
|
+
*/
|
|
207
|
+
bufferedRequests(transaction) {
|
|
208
|
+
const buffered = new Map();
|
|
209
|
+
// Only `deferred` records snapshots, so scanning the journal under `writeThrough` never finds any.
|
|
210
|
+
if (transaction.policy.requestQueue !== 'deferred')
|
|
211
|
+
return buffered;
|
|
212
|
+
for (const entry of transaction.journal) {
|
|
213
|
+
if (entry.type !== 'requestQueue' || entry.participant !== this)
|
|
214
|
+
continue;
|
|
215
|
+
for (const request of entry.requests) {
|
|
216
|
+
if (request.snapshot !== undefined)
|
|
217
|
+
buffered.set(request.uniqueKey, request.snapshot);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return buffered;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Adds a request under the `deferred` policy: journaled now, really added by the commit replay.
|
|
224
|
+
* A new request's `requestId` is the local `uniqueKey` hash and is **provisional** — never write it
|
|
225
|
+
* to `request.id` or the dedup caches. Dedup is cheapest-first: buffer, caches, then a backend probe.
|
|
226
|
+
*/
|
|
227
|
+
async addRequestDeferred(transaction, request, forefront, buffered = this.bufferedRequests(transaction)) {
|
|
228
|
+
// This transaction's own buffered adds; the shared caches never see them (provisional ids).
|
|
229
|
+
if (buffered.has(request.uniqueKey)) {
|
|
230
|
+
this.recordRequestJournalEntry(transaction, [request], forefront, false);
|
|
231
|
+
return {
|
|
232
|
+
wasAlreadyPresent: true,
|
|
233
|
+
wasAlreadyHandled: false,
|
|
234
|
+
requestId: getRequestId(request.uniqueKey),
|
|
235
|
+
uniqueKey: request.uniqueKey,
|
|
236
|
+
forefront,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// The caches hold real backend ids. Only *writing* provisional ids to them would be wrong;
|
|
240
|
+
// reading saves a probe. Same lookup as the write-through path.
|
|
241
|
+
const cacheKey = getRequestId(request.uniqueKey);
|
|
242
|
+
const cachedInfo = this.requestCache.get(cacheKey);
|
|
243
|
+
const knownRequestId = cachedInfo?.id ?? this.requestSeenCache.get(cacheKey);
|
|
244
|
+
if (knownRequestId) {
|
|
245
|
+
this.recordRequestJournalEntry(transaction, [request], forefront, false);
|
|
246
|
+
return {
|
|
247
|
+
wasAlreadyPresent: true,
|
|
248
|
+
// The dedup cache doesn't track the handled state; only the full record does.
|
|
249
|
+
wasAlreadyHandled: cachedInfo?.isHandled ?? false,
|
|
250
|
+
requestId: knownRequestId,
|
|
251
|
+
uniqueKey: request.uniqueKey,
|
|
252
|
+
forefront,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
// The caches are bounded, so a miss is not proof of absence - probe for an accurate answer.
|
|
256
|
+
const existing = await this.backend.getRequest(request.uniqueKey);
|
|
257
|
+
if (existing) {
|
|
258
|
+
this.recordRequestJournalEntry(transaction, [request], forefront, false);
|
|
259
|
+
return {
|
|
260
|
+
wasAlreadyPresent: true,
|
|
261
|
+
wasAlreadyHandled: existing.handledAt != null,
|
|
262
|
+
requestId: existing.id,
|
|
263
|
+
uniqueKey: request.uniqueKey,
|
|
264
|
+
forefront,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
// The entry below *is* the write, so a transaction closed during the probe must not receive it -
|
|
268
|
+
// pass through instead, per the closed-transaction rule. Under `deferred` that can land an
|
|
269
|
+
// addition a rollback would have discarded; dedup bounds that cost, silent loss is unbounded.
|
|
270
|
+
if (!transaction.isActive) {
|
|
271
|
+
return await this.addRequest(request, { forefront });
|
|
272
|
+
}
|
|
273
|
+
const snapshot = JSON.parse(JSON.stringify(request));
|
|
274
|
+
// Strip-list, not allow-list: every user-facing field flows through, including ones added to
|
|
275
|
+
// `Request` in the future. The exceptions are `id` and `handledAt`, the two backend-owned
|
|
276
|
+
// lifecycle fields.
|
|
277
|
+
delete snapshot.id;
|
|
278
|
+
delete snapshot.handledAt;
|
|
279
|
+
transaction.recordJournalEntry({
|
|
280
|
+
type: 'requestQueue',
|
|
281
|
+
participant: this,
|
|
282
|
+
requests: [{ url: request.url, uniqueKey: request.uniqueKey, label: request.label, snapshot }],
|
|
283
|
+
forefront,
|
|
284
|
+
writeThrough: false,
|
|
285
|
+
});
|
|
286
|
+
buffered.set(request.uniqueKey, snapshot);
|
|
287
|
+
return {
|
|
288
|
+
wasAlreadyPresent: false,
|
|
289
|
+
wasAlreadyHandled: false,
|
|
290
|
+
requestId: getRequestId(request.uniqueKey),
|
|
291
|
+
uniqueKey: request.uniqueKey,
|
|
292
|
+
forefront,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/** @internal */
|
|
296
|
+
async commitJournalEntries(entries) {
|
|
297
|
+
// Replay through `backend.addBatchOfRequests`, *not* the batched frontend wrapper - the wrapper
|
|
298
|
+
// resolves after the first chunk and sleeps between the rest, neither of which commit may
|
|
299
|
+
// inherit. One call per `forefront` flag; the order of forefront additions is arbitrary anyway.
|
|
300
|
+
for (const forefront of [false, true]) {
|
|
301
|
+
const requests = entries.flatMap((entry) => entry.type === 'requestQueue' && entry.forefront === forefront
|
|
302
|
+
? // Requests without a snapshot were deduplicated or written through; nothing to replay.
|
|
303
|
+
entry.requests
|
|
304
|
+
.filter((journaled) => journaled.snapshot !== undefined)
|
|
305
|
+
.map((journaled) => new Request(journaled.snapshot))
|
|
306
|
+
: []);
|
|
307
|
+
if (requests.length === 0)
|
|
308
|
+
continue;
|
|
309
|
+
this.statsTracker.add('writeCount');
|
|
310
|
+
const { processedRequests, unprocessedRequests } = await this.backend.addBatchOfRequests(requests, {
|
|
311
|
+
forefront,
|
|
312
|
+
});
|
|
313
|
+
// Only now, with the real backend-assigned ids, may the shared dedup caches be populated.
|
|
314
|
+
for (const processed of processedRequests) {
|
|
315
|
+
const cacheKey = getRequestId(processed.uniqueKey);
|
|
316
|
+
this.cacheRequest(cacheKey, { ...processed, forefront });
|
|
317
|
+
this.requestSeenCache.add(cacheKey, processed.requestId);
|
|
318
|
+
}
|
|
319
|
+
if (unprocessedRequests.length > 0) {
|
|
320
|
+
// Warn and skip, rather than retry or fail. `unprocessedRequests` is what remains after
|
|
321
|
+
// the backend's own transient-error handling - a semantic rejection that retrying here
|
|
322
|
+
// would only re-poke. And failing the commit would let one malformed request hold the
|
|
323
|
+
// whole transaction hostage.
|
|
324
|
+
this.log.warning('Some requests were rejected by the request queue while committing a storage transaction and will be skipped. ' +
|
|
325
|
+
"This usually means the request data is malformed (e.g. an invalid 'userData' shape).", { unprocessedRequests });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
178
329
|
/**
|
|
179
330
|
* Adds requests to the queue in batches of 25. This method will wait till all the requests are added
|
|
180
331
|
* to the queue before resolving. You should prefer using `queue.addRequestsBatched()` or `crawler.addRequests()`
|
|
@@ -190,7 +341,7 @@ export class RequestQueue {
|
|
|
190
341
|
* @param [options] Request queue operation options.
|
|
191
342
|
*/
|
|
192
343
|
async addRequests(requestsLike, options = {}) {
|
|
193
|
-
|
|
344
|
+
const transaction = activeStorageTransaction();
|
|
194
345
|
ow(requestsLike, ow.object
|
|
195
346
|
.is((value) => isIterable(value) || isAsyncIterable(value))
|
|
196
347
|
.message((value) => `Expected an iterable or async iterable, got ${getObjectType(value)}`));
|
|
@@ -225,6 +376,14 @@ export class RequestQueue {
|
|
|
225
376
|
requests.push(requestLike instanceof Request ? requestLike : new Request(requestLike));
|
|
226
377
|
}
|
|
227
378
|
}
|
|
379
|
+
if (transaction?.policy.requestQueue === 'deferred') {
|
|
380
|
+
const buffered = this.bufferedRequests(transaction);
|
|
381
|
+
for (const request of requests) {
|
|
382
|
+
results.processedRequests.push(await this.addRequestDeferred(transaction, request, forefront, buffered));
|
|
383
|
+
}
|
|
384
|
+
return results;
|
|
385
|
+
}
|
|
386
|
+
this.recordRequestJournalEntry(transaction, requests, forefront, true);
|
|
228
387
|
const requestsToAdd = new Map();
|
|
229
388
|
for (const request of requests) {
|
|
230
389
|
const cacheKey = getCachedRequestId(request.uniqueKey);
|
|
@@ -276,7 +435,8 @@ export class RequestQueue {
|
|
|
276
435
|
* @param options Options for the request queue
|
|
277
436
|
*/
|
|
278
437
|
async addRequestsBatched(requests, options = {}) {
|
|
279
|
-
|
|
438
|
+
const transaction = activeStorageTransaction();
|
|
439
|
+
const deferred = transaction?.policy.requestQueue === 'deferred';
|
|
280
440
|
ow(requests, ow.object
|
|
281
441
|
.is((value) => isIterable(value) || isAsyncIterable(value))
|
|
282
442
|
.message((value) => `Expected an iterable or async iterable, got ${getObjectType(value)}`));
|
|
@@ -313,7 +473,9 @@ export class RequestQueue {
|
|
|
313
473
|
}
|
|
314
474
|
}
|
|
315
475
|
}
|
|
316
|
-
const { batchSize = 1000,
|
|
476
|
+
const { batchSize = 1000, maxNewRequests = undefined } = options;
|
|
477
|
+
// Under `deferred` no chunk performs backend I/O, so pacing them would only stall the handler.
|
|
478
|
+
const waitBetweenBatchesMillis = deferred ? 0 : (options.waitBetweenBatchesMillis ?? 1000);
|
|
317
479
|
let remainingBudget = maxNewRequests ?? Infinity;
|
|
318
480
|
const requestsOverLimit = [];
|
|
319
481
|
// If there's a limit on the number of added requests, do not send batches bigger than the limit
|
|
@@ -373,21 +535,38 @@ export class RequestQueue {
|
|
|
373
535
|
if ((await chunksIterator.peek()) === undefined) {
|
|
374
536
|
return buildResult(addedRequests, Promise.resolve([]), requestIterator);
|
|
375
537
|
}
|
|
376
|
-
|
|
377
|
-
const promise = new Promise(async (resolve) => {
|
|
538
|
+
const processRemainingChunks = async () => {
|
|
378
539
|
const finalAddedRequests = [];
|
|
379
540
|
for await (const requestChunk of chunks) {
|
|
380
541
|
finalAddedRequests.push(...(await processChunk(requestChunk, false)));
|
|
381
542
|
await sleep(waitBetweenBatchesMillis);
|
|
382
543
|
}
|
|
383
|
-
|
|
544
|
+
return finalAddedRequests;
|
|
545
|
+
};
|
|
546
|
+
// maxNewRequests needs all batches to report skipped requests accurately; `deferred` needs them
|
|
547
|
+
// too - a writer that finishes after commit would have nowhere to put its journal entries.
|
|
548
|
+
const awaitsRemainingChunks = options.waitForAllRequestsToBeAdded || maxNewRequests !== undefined || deferred;
|
|
549
|
+
// eslint-disable-next-line no-async-promise-executor
|
|
550
|
+
const promise = new Promise(async (resolve) => {
|
|
551
|
+
if (awaitsRemainingChunks) {
|
|
552
|
+
// Awaited below, i.e. still within the caller's transaction scope, so the additions are
|
|
553
|
+
// journaled like the initial chunk - introspection must not depend on where the chunk
|
|
554
|
+
// boundary happened to fall.
|
|
555
|
+
resolve(await processRemainingChunks());
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
// Nobody awaits this writer, so it outlives the transaction scope it inherits and must
|
|
559
|
+
// not record into a transaction that may already be closed. It writes directly - its
|
|
560
|
+
// write-through additions were never going to be rolled back anyway - which means the
|
|
561
|
+
// requests it adds are not journaled. See `StorageTransactionView.enqueuedUrls`.
|
|
562
|
+
resolve(await withDirectStorageAccess(processRemainingChunks));
|
|
563
|
+
}
|
|
384
564
|
});
|
|
385
565
|
this.inProgressRequestBatchCount += 1;
|
|
386
566
|
void promise.finally(() => {
|
|
387
567
|
this.inProgressRequestBatchCount -= 1;
|
|
388
568
|
});
|
|
389
|
-
|
|
390
|
-
if (options.waitForAllRequestsToBeAdded || maxNewRequests !== undefined) {
|
|
569
|
+
if (awaitsRemainingChunks) {
|
|
391
570
|
addedRequests.push(...(await promise));
|
|
392
571
|
}
|
|
393
572
|
return buildResult(addedRequests, promise, requestIterator);
|
|
@@ -399,8 +578,13 @@ export class RequestQueue {
|
|
|
399
578
|
* @returns Returns the request object, or `null` if it was not found.
|
|
400
579
|
*/
|
|
401
580
|
async getRequest(uniqueKey) {
|
|
402
|
-
|
|
581
|
+
const transaction = activeStorageTransaction();
|
|
403
582
|
ow(uniqueKey, ow.string);
|
|
583
|
+
// Requests buffered by the active transaction (under the `deferred` write policy) are visible to it.
|
|
584
|
+
const buffered = transaction && this.bufferedRequests(transaction).get(uniqueKey);
|
|
585
|
+
if (buffered) {
|
|
586
|
+
return new Request(buffered);
|
|
587
|
+
}
|
|
404
588
|
const requestOptions = await this.backend.getRequest(uniqueKey);
|
|
405
589
|
if (!requestOptions)
|
|
406
590
|
return null;
|
|
@@ -424,7 +608,7 @@ export class RequestQueue {
|
|
|
424
608
|
* Returns the request object or `null` if there are no more pending requests.
|
|
425
609
|
*/
|
|
426
610
|
async fetchNextRequest() {
|
|
427
|
-
|
|
611
|
+
rejectOperationInTransaction('RequestQueue.fetchNextRequest()', 'it is part of the crawler request-processing bookkeeping, which a transaction must not affect.');
|
|
428
612
|
if (this.queuePausedForMigration) {
|
|
429
613
|
return null;
|
|
430
614
|
}
|
|
@@ -441,7 +625,7 @@ export class RequestQueue {
|
|
|
441
625
|
* Handled requests will never again be returned by the `fetchNextRequest` function.
|
|
442
626
|
*/
|
|
443
627
|
async markRequestAsHandled(request) {
|
|
444
|
-
|
|
628
|
+
rejectOperationInTransaction('RequestQueue.markRequestAsHandled()', 'it is part of the crawler request-processing bookkeeping, which a transaction must not affect.');
|
|
445
629
|
ow(request, ow.object.partialShape({
|
|
446
630
|
id: ow.string,
|
|
447
631
|
uniqueKey: ow.string,
|
|
@@ -474,7 +658,7 @@ export class RequestQueue {
|
|
|
474
658
|
* For example, this lets you store the number of retries or error messages for the request.
|
|
475
659
|
*/
|
|
476
660
|
async reclaimRequest(request, options = {}) {
|
|
477
|
-
|
|
661
|
+
rejectOperationInTransaction('RequestQueue.reclaimRequest()', 'it is part of the crawler request-processing bookkeeping, which a transaction must not affect.');
|
|
478
662
|
ow(request, ow.object.partialShape({
|
|
479
663
|
id: ow.string,
|
|
480
664
|
uniqueKey: ow.string,
|
|
@@ -508,7 +692,11 @@ export class RequestQueue {
|
|
|
508
692
|
* {@link RequestQueue.isFinished}.
|
|
509
693
|
*/
|
|
510
694
|
async isEmpty() {
|
|
511
|
-
|
|
695
|
+
const transaction = activeStorageTransaction();
|
|
696
|
+
// Requests buffered by the active transaction count as pending from its point of view.
|
|
697
|
+
if (transaction && this.bufferedRequests(transaction).size > 0) {
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
512
700
|
return this.backend.isEmpty();
|
|
513
701
|
}
|
|
514
702
|
/**
|
|
@@ -520,11 +708,15 @@ export class RequestQueue {
|
|
|
520
708
|
* a false negative, but it shall never return a false positive.
|
|
521
709
|
*/
|
|
522
710
|
async isFinished() {
|
|
523
|
-
|
|
711
|
+
const transaction = activeStorageTransaction();
|
|
524
712
|
// We are not finished if we're still adding new requests in the background.
|
|
525
713
|
if (this.inProgressRequestBatchCount > 0) {
|
|
526
714
|
return false;
|
|
527
715
|
}
|
|
716
|
+
// Requests buffered by the active transaction count as pending from its point of view.
|
|
717
|
+
if (transaction && this.bufferedRequests(transaction).size > 0) {
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
528
720
|
return this.backend.isFinished();
|
|
529
721
|
}
|
|
530
722
|
/**
|
|
@@ -564,7 +756,7 @@ export class RequestQueue {
|
|
|
564
756
|
* depending on the mode of operation.
|
|
565
757
|
*/
|
|
566
758
|
async drop() {
|
|
567
|
-
|
|
759
|
+
rejectOperationInTransaction('RequestQueue.drop()');
|
|
568
760
|
await this.backend.drop();
|
|
569
761
|
serviceLocator.getStorageInstanceManager().removeFromCache(this);
|
|
570
762
|
}
|
|
@@ -573,7 +765,7 @@ export class RequestQueue {
|
|
|
573
765
|
* so it can be reused (e.g. across multiple `crawler.run()` calls).
|
|
574
766
|
*/
|
|
575
767
|
async purge() {
|
|
576
|
-
|
|
768
|
+
rejectOperationInTransaction('RequestQueue.purge()');
|
|
577
769
|
await this.backend.purge();
|
|
578
770
|
// Reset in-memory bookkeeping so the queue behaves as if freshly opened.
|
|
579
771
|
this.requestCache.clear();
|
|
@@ -630,8 +822,17 @@ export class RequestQueue {
|
|
|
630
822
|
* @throws If the underlying storage no longer exists (e.g. it was deleted externally).
|
|
631
823
|
*/
|
|
632
824
|
async getInfo() {
|
|
633
|
-
|
|
634
|
-
|
|
825
|
+
const transaction = activeStorageTransaction();
|
|
826
|
+
const metadata = await this.backend.getMetadata();
|
|
827
|
+
const bufferedCount = transaction ? this.bufferedRequests(transaction).size : 0;
|
|
828
|
+
if (bufferedCount > 0) {
|
|
829
|
+
return {
|
|
830
|
+
...metadata,
|
|
831
|
+
totalRequestCount: metadata.totalRequestCount + bufferedCount,
|
|
832
|
+
pendingRequestCount: metadata.pendingRequestCount + bufferedCount,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
return metadata;
|
|
635
836
|
}
|
|
636
837
|
/**
|
|
637
838
|
* Fetches URLs from requestsFromUrl and returns them in format of list of requests
|
|
@@ -700,7 +901,7 @@ export class RequestQueue {
|
|
|
700
901
|
* @param [options] Open Request Queue options.
|
|
701
902
|
*/
|
|
702
903
|
static async open(identifier, options = {}) {
|
|
703
|
-
|
|
904
|
+
tryCancel();
|
|
704
905
|
ow(options, ow.object.exactShape({
|
|
705
906
|
configuration: ow.optional.object.instanceOf(Configuration),
|
|
706
907
|
storageBackend: ow.optional.object,
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { Awaitable, Dictionary } from '@crawlee/types';
|
|
2
|
+
import type { RecordOptions } from './key_value_store.js';
|
|
3
|
+
/**
|
|
4
|
+
* Governs whether writes of a given storage type performed inside a {@link StorageTransaction} are
|
|
5
|
+
* applied immediately (`writeThrough`) or recorded and replayed on commit (`deferred`).
|
|
6
|
+
*/
|
|
7
|
+
export type StorageWriteMode = 'deferred' | 'writeThrough';
|
|
8
|
+
/**
|
|
9
|
+
* Per-storage-type write policy of a {@link StorageTransaction}. Datasets and key-value stores are
|
|
10
|
+
* always `deferred` and not configurable — deferring is the only safe mode for non-idempotent writes,
|
|
11
|
+
* and {@link withDirectStorageAccess} covers one-off immediate writes.
|
|
12
|
+
*/
|
|
13
|
+
export interface StorageWritePolicy {
|
|
14
|
+
/**
|
|
15
|
+
* Write mode for request queue additions. Note that this is a *write policy* for the queue, not the
|
|
16
|
+
* queue instance itself (which is the top-level `requestQueue` crawler option).
|
|
17
|
+
*
|
|
18
|
+
* - `writeThrough` (default): requests are added immediately and are **not** rolled back with the
|
|
19
|
+
* transaction. This is safe (additions are deduplicated by `uniqueKey`, so a retry is idempotent)
|
|
20
|
+
* and keeps new requests visible to the crawler while the handler still runs.
|
|
21
|
+
* - `deferred`: requests are only added when the transaction commits — strict all-or-nothing
|
|
22
|
+
* semantics, at the cost of the crawler not seeing them until the handler finishes.
|
|
23
|
+
*/
|
|
24
|
+
requestQueue: StorageWriteMode;
|
|
25
|
+
}
|
|
26
|
+
export type StorageTransactionState = 'open' | 'committing' | 'committed' | 'failed' | 'rolledBack';
|
|
27
|
+
/**
|
|
28
|
+
* A storage frontend that can record operations in a transaction journal.
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
export interface TransactionParticipant {
|
|
32
|
+
/**
|
|
33
|
+
* Replay the given buffered journal entries (all recorded by this participant) into the real storage
|
|
34
|
+
* backend. Called during commit, with the transaction already in the `committing` state, so the
|
|
35
|
+
* replayed operations pass through.
|
|
36
|
+
*/
|
|
37
|
+
commitJournalEntries(entries: JournalEntry[]): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A single dataset write (`pushData`) recorded in a transaction journal.
|
|
41
|
+
*/
|
|
42
|
+
export interface DatasetJournalEntry {
|
|
43
|
+
type: 'dataset';
|
|
44
|
+
/** @internal **/
|
|
45
|
+
participant: TransactionParticipant;
|
|
46
|
+
storageId: string;
|
|
47
|
+
/** The pushed items, captured by `structuredClone` at write time. */
|
|
48
|
+
items: Dictionary[];
|
|
49
|
+
recordedAt: Date;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A single key-value store write (`setValue`) recorded in a transaction journal.
|
|
53
|
+
*/
|
|
54
|
+
export interface KeyValueStoreJournalEntry {
|
|
55
|
+
type: 'keyValueStore';
|
|
56
|
+
/** @internal **/
|
|
57
|
+
participant: TransactionParticipant;
|
|
58
|
+
storageId: string;
|
|
59
|
+
key: string;
|
|
60
|
+
/** The original, pre-serialization value captured by `structuredClone`; `null` denotes a deletion. */
|
|
61
|
+
value: unknown;
|
|
62
|
+
options?: RecordOptions;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A request recorded in a transaction journal.
|
|
66
|
+
*/
|
|
67
|
+
export interface JournaledRequest {
|
|
68
|
+
url: string;
|
|
69
|
+
uniqueKey: string;
|
|
70
|
+
label?: string;
|
|
71
|
+
/**
|
|
72
|
+
* A full JSON snapshot of the request for the commit replay. Only present for buffered additions —
|
|
73
|
+
* deduplicated and write-through ones are journaled for introspection only.
|
|
74
|
+
*/
|
|
75
|
+
snapshot?: Dictionary;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A batch of request queue additions recorded in a transaction journal.
|
|
79
|
+
*/
|
|
80
|
+
export interface RequestQueueJournalEntry {
|
|
81
|
+
type: 'requestQueue';
|
|
82
|
+
/** @internal **/
|
|
83
|
+
participant: TransactionParticipant;
|
|
84
|
+
requests: JournaledRequest[];
|
|
85
|
+
forefront: boolean;
|
|
86
|
+
/** Write-through entries were applied immediately; they are never replayed. */
|
|
87
|
+
writeThrough: boolean;
|
|
88
|
+
}
|
|
89
|
+
export type JournalEntry = DatasetJournalEntry | KeyValueStoreJournalEntry | RequestQueueJournalEntry;
|
|
90
|
+
/**
|
|
91
|
+
* A read-only view of a {@link StorageTransaction}: only the journal-backed introspection accessors,
|
|
92
|
+
* without the lifecycle methods. The accessors are synchronous and expose the original pre-serialization
|
|
93
|
+
* values. They cover every write recorded while the transaction was open, under either write policy —
|
|
94
|
+
* with the one exception noted on {@link StorageTransactionView.enqueuedUrls|`enqueuedUrls`}. A view
|
|
95
|
+
* is valid until the transaction is disposed.
|
|
96
|
+
*/
|
|
97
|
+
export interface StorageTransactionView {
|
|
98
|
+
readonly state: StorageTransactionState;
|
|
99
|
+
/** Items pushed to datasets during the transaction, in push order. */
|
|
100
|
+
readonly datasetItems: {
|
|
101
|
+
item: Dictionary;
|
|
102
|
+
datasetId: string;
|
|
103
|
+
}[];
|
|
104
|
+
/**
|
|
105
|
+
* URLs enqueued to request queues during the transaction, under either write policy. Recorded as
|
|
106
|
+
* requested, so duplicate, already-present and backend-rejected URLs are included.
|
|
107
|
+
*
|
|
108
|
+
* One gap: unless a caller of `addRequestsBatched()` waits for every chunk
|
|
109
|
+
* (`waitForAllRequestsToBeAdded` or `maxNewRequests`, both of which {@link enqueueLinks} sets when
|
|
110
|
+
* a crawl limit applies), the chunks after the first are added by a background writer that outlives
|
|
111
|
+
* the transaction and is not recorded here.
|
|
112
|
+
*/
|
|
113
|
+
readonly enqueuedUrls: {
|
|
114
|
+
url: string;
|
|
115
|
+
label?: string;
|
|
116
|
+
}[];
|
|
117
|
+
/** Key-value store changes made during the transaction, keyed by store id, last write per key. */
|
|
118
|
+
readonly keyValueStoreChanges: Record<string, Record<string, {
|
|
119
|
+
changedValue: unknown;
|
|
120
|
+
options?: RecordOptions;
|
|
121
|
+
}>>;
|
|
122
|
+
}
|
|
123
|
+
export interface StorageTransactionOptions {
|
|
124
|
+
/** Overrides of the per-storage-type write policy. See {@link StorageWritePolicy}. */
|
|
125
|
+
policy?: Partial<StorageWritePolicy>;
|
|
126
|
+
/**
|
|
127
|
+
* How long a commit may take before it fails, in milliseconds. There is no automatic retry — the
|
|
128
|
+
* replay of dataset items is not idempotent.
|
|
129
|
+
* @default 300000
|
|
130
|
+
*/
|
|
131
|
+
commitTimeoutMillis?: number;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* A storage transaction scoped to a request's lifecycle. Writes made through the storage frontends
|
|
135
|
+
* ({@link Dataset}, {@link KeyValueStore}, {@link RequestQueue}) while the transaction is active
|
|
136
|
+
* are recorded rather than applied; on {@link StorageTransaction.commit|`commit()`} they are replayed
|
|
137
|
+
* into real storage, on {@link StorageTransaction.rollback|`rollback()`} they are dropped. Reads consult
|
|
138
|
+
* the recorded writes first, so a handler sees its own writes.
|
|
139
|
+
*
|
|
140
|
+
* Create one with {@link createStorageTransaction} (explicit commit/rollback) or
|
|
141
|
+
* {@link withStorageTransaction} (scoped sugar). Crawlers open one automatically around every request
|
|
142
|
+
* handler unless `transactionalStorage: false` is set.
|
|
143
|
+
*/
|
|
144
|
+
export declare class StorageTransaction implements StorageTransactionView {
|
|
145
|
+
/** The ordered, append-only journal — the source of truth for commit, introspection and reads. */
|
|
146
|
+
readonly journal: JournalEntry[];
|
|
147
|
+
/** Per-storage-type write policy. */
|
|
148
|
+
readonly policy: StorageWritePolicy;
|
|
149
|
+
private readonly commitTimeoutMillis;
|
|
150
|
+
private _state;
|
|
151
|
+
private disposed;
|
|
152
|
+
/** @internal */
|
|
153
|
+
constructor(options?: StorageTransactionOptions);
|
|
154
|
+
get state(): StorageTransactionState;
|
|
155
|
+
/**
|
|
156
|
+
* `true` only while `state === 'open'`. This is the single predicate every storage operation
|
|
157
|
+
* consults — operations performed after the transaction is closed pass through to the real backend.
|
|
158
|
+
*/
|
|
159
|
+
get isActive(): boolean;
|
|
160
|
+
/** Runs `callback` with this transaction installed in the async context. */
|
|
161
|
+
run<T>(callback: () => Awaitable<T>): Promise<T>;
|
|
162
|
+
/**
|
|
163
|
+
* Records a write operation in the journal.
|
|
164
|
+
* @internal
|
|
165
|
+
*/
|
|
166
|
+
recordJournalEntry(entry: JournalEntry): void;
|
|
167
|
+
/**
|
|
168
|
+
* Replays the journaled writes into real storage. A no-op unless the transaction is `open`.
|
|
169
|
+
*
|
|
170
|
+
* The transaction transitions to `committing` *before* anything is flushed, so a commit that throws
|
|
171
|
+
* partway lands in `failed` (never back in `open`) and subsequent storage operations pass through
|
|
172
|
+
* rather than recording into a dead transaction. Delivery is at-least-once — a commit that fails
|
|
173
|
+
* partway may have applied some of the writes already.
|
|
174
|
+
*/
|
|
175
|
+
commit(): Promise<void>;
|
|
176
|
+
private flush;
|
|
177
|
+
/**
|
|
178
|
+
* Discards the journaled writes. A no-op unless the transaction is `open` — in particular, calling it
|
|
179
|
+
* after a successful `commit()` (which the crawler's error handling can legitimately do) does nothing
|
|
180
|
+
* and never throws.
|
|
181
|
+
*/
|
|
182
|
+
rollback(): void;
|
|
183
|
+
/**
|
|
184
|
+
* Releases the journal and the write-time snapshots it holds. Must be called for *every* terminal
|
|
185
|
+
* state, `failed` included. Idempotent, never throws, and does not change `state`. Any
|
|
186
|
+
* {@link StorageTransactionView} of this transaction is only valid until this is called.
|
|
187
|
+
*/
|
|
188
|
+
dispose(): void;
|
|
189
|
+
get datasetItems(): {
|
|
190
|
+
item: Dictionary;
|
|
191
|
+
datasetId: string;
|
|
192
|
+
}[];
|
|
193
|
+
get enqueuedUrls(): {
|
|
194
|
+
url: string;
|
|
195
|
+
label?: string;
|
|
196
|
+
}[];
|
|
197
|
+
get keyValueStoreChanges(): Record<string, Record<string, {
|
|
198
|
+
changedValue: unknown;
|
|
199
|
+
options?: RecordOptions;
|
|
200
|
+
}>>;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Opens a {@link StorageTransaction} without running anything yet. The caller owns the outcome:
|
|
204
|
+
* `run()`, then `commit()` or `rollback()`, and always `dispose()` when done. For the common
|
|
205
|
+
* open-run-commit flow, prefer {@link withStorageTransaction}.
|
|
206
|
+
*/
|
|
207
|
+
export declare function createStorageTransaction(options?: StorageTransactionOptions): StorageTransaction;
|
|
208
|
+
/**
|
|
209
|
+
* Runs `callback` inside a new {@link StorageTransaction}: storage writes made in the callback are
|
|
210
|
+
* committed when it returns and rolled back when it throws. If a transaction is already active in the
|
|
211
|
+
* current async context, it is reused and its outcome is left to its owner (and `options` are ignored)
|
|
212
|
+
* — there are no nested transaction semantics.
|
|
213
|
+
*/
|
|
214
|
+
export declare function withStorageTransaction<T>(callback: (transaction: StorageTransaction) => Awaitable<T>, options?: StorageTransactionOptions): Promise<T>;
|
|
215
|
+
/**
|
|
216
|
+
* Runs `callback` outside of any storage transaction — the per-call-site escape hatch. Storage operations
|
|
217
|
+
* made inside it hit the real backend directly, are not rolled back, and operations that a transaction
|
|
218
|
+
* rejects (`drop`, stream-valued `setValue`, request queue internals, ...) are permitted.
|
|
219
|
+
*/
|
|
220
|
+
export declare function withDirectStorageAccess<T>(callback: () => Awaitable<T>): Promise<T>;
|
|
221
|
+
/**
|
|
222
|
+
* The per-operation hook consulted by every storage frontend method: performs the cancellation check
|
|
223
|
+
* that aborts storage operations when the request handler times out, and returns the active storage
|
|
224
|
+
* transaction. Returns `undefined` when there is no transaction in the async context *or* when it is no
|
|
225
|
+
* longer open — operations on a closed transaction deliberately pass through to the real backend.
|
|
226
|
+
* @internal
|
|
227
|
+
*/
|
|
228
|
+
export declare function activeStorageTransaction(): StorageTransaction | undefined;
|
|
229
|
+
/**
|
|
230
|
+
* Returns the transaction installed in the current async context, regardless of its state. Used by the
|
|
231
|
+
* crawler to drive the outcome of the transaction it opened.
|
|
232
|
+
* @internal
|
|
233
|
+
*/
|
|
234
|
+
export declare function currentStorageTransaction(): StorageTransaction | undefined;
|
|
235
|
+
/**
|
|
236
|
+
* Captures a value at write time, so that later mutations of the caller's object affect neither the
|
|
237
|
+
* read-your-own-writes reads nor the commit replay. `structuredClone` for fidelity (`Date`, `Map`, `Set`,
|
|
238
|
+
* typed arrays, `undefined`); values it cannot handle fall back to the JSON round-trip the storage
|
|
239
|
+
* backends perform anyway.
|
|
240
|
+
* @internal
|
|
241
|
+
*/
|
|
242
|
+
export declare function snapshotValue<T>(value: T): T;
|
|
243
|
+
/**
|
|
244
|
+
* The guard for operations that cannot be performed inside a storage transaction: throws when one is
|
|
245
|
+
* active, and performs the per-operation cancellation check either way.
|
|
246
|
+
* @internal
|
|
247
|
+
*/
|
|
248
|
+
export declare function rejectOperationInTransaction(operation: string, reason?: string): void;
|
|
249
|
+
/**
|
|
250
|
+
* Builds the "operation not allowed in a transaction" error, for a call site that has already
|
|
251
|
+
* established a transaction is active and so wants to `throw` unconditionally.
|
|
252
|
+
* @internal
|
|
253
|
+
*/
|
|
254
|
+
export declare function operationRejectedInTransaction(operation: string, reason?: string): Error;
|