@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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import ow, { ArgumentError } from 'ow';
|
|
2
2
|
import { KEY_VALUE_STORE_KEY_REGEX } from '@apify/consts';
|
|
3
|
+
import { tryCancel } from '@apify/timeout';
|
|
3
4
|
import { Configuration } from '../configuration.js';
|
|
4
5
|
import { serviceLocator } from '../service_locator.js';
|
|
5
|
-
import {
|
|
6
|
+
import { activeStorageTransaction, operationRejectedInTransaction, rejectOperationInTransaction, snapshotValue, withDirectStorageAccess, } from './transaction.js';
|
|
6
7
|
import { parseValue, serializeValue } from './key_value_store_codec.js';
|
|
7
8
|
import { StorageStatsTracker } from './storage_stats.js';
|
|
8
9
|
import { resolveStorageIdentifier } from './storage_instance_manager.js';
|
|
@@ -129,10 +130,9 @@ export class KeyValueStore {
|
|
|
129
130
|
* on the MIME content type of the record, or `null` if the key is missing from the store.
|
|
130
131
|
*/
|
|
131
132
|
async getValue(key, defaultValue) {
|
|
132
|
-
|
|
133
|
+
tryCancel();
|
|
133
134
|
ow(key, ow.string.nonEmpty);
|
|
134
|
-
this.
|
|
135
|
-
const record = await this.backend.getValue(key);
|
|
135
|
+
const record = await this.readRecord(key);
|
|
136
136
|
// A missing record falls back to the default; a record that parses to a falsy value (including
|
|
137
137
|
// a stored literal `null`) is returned verbatim, so callers can tell "stored null" from "absent".
|
|
138
138
|
if (!record) {
|
|
@@ -141,6 +141,52 @@ export class KeyValueStore {
|
|
|
141
141
|
// Storage backends are byte transports — the value is raw bytes; the frontend parses it here.
|
|
142
142
|
return parseValue(record.value, record.contentType ?? null);
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* The active transaction's last buffered write per key for this store, derived from its journal.
|
|
146
|
+
* An entry with a `null` value is a tombstone (an in-transaction deletion).
|
|
147
|
+
*/
|
|
148
|
+
bufferedJournalEntries() {
|
|
149
|
+
const transaction = activeStorageTransaction();
|
|
150
|
+
if (!transaction)
|
|
151
|
+
return undefined;
|
|
152
|
+
const lastWritePerKey = new Map();
|
|
153
|
+
for (const entry of transaction.journal) {
|
|
154
|
+
if (entry.type === 'keyValueStore' && entry.participant === this) {
|
|
155
|
+
lastWritePerKey.set(entry.key, entry);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return lastWritePerKey;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The single transaction-aware record read shared by `getValue`, `getRecord`, `recordExists` and the
|
|
162
|
+
* listing paths: buffered key → serialized through the standard codec (same fidelity as a real
|
|
163
|
+
* round-trip); tombstoned key → `null`; otherwise the backend.
|
|
164
|
+
*
|
|
165
|
+
* The per-key buffered lookup requires the whole journal to be reduced to a last-write-per-key map,
|
|
166
|
+
* which is O(journal). Single-record callers let it default (rebuilt per call); the listing paths,
|
|
167
|
+
* which read many keys, pass a map built once so the read stays O(1) per key instead of O(journal).
|
|
168
|
+
*/
|
|
169
|
+
async readRecord(key, buffered = this.bufferedJournalEntries()) {
|
|
170
|
+
const entry = buffered?.get(key);
|
|
171
|
+
if (entry) {
|
|
172
|
+
if (entry.value === null) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const serialized = serializeValue(entry.value, entry.options?.contentType);
|
|
176
|
+
return {
|
|
177
|
+
value: normalizeSerializedValue(serialized.value),
|
|
178
|
+
contentType: serialized.contentType ?? null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
this.statsTracker.add('readCount');
|
|
182
|
+
const record = await this.backend.getValue(key);
|
|
183
|
+
if (!record)
|
|
184
|
+
return null;
|
|
185
|
+
return {
|
|
186
|
+
value: record.value,
|
|
187
|
+
contentType: record.contentType ?? null,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
144
190
|
/**
|
|
145
191
|
* Reads a record from the key-value store without parsing the value.
|
|
146
192
|
*
|
|
@@ -168,16 +214,9 @@ export class KeyValueStore {
|
|
|
168
214
|
* of the following characters: `a`-`z`, `A`-`Z`, `0`-`9` and `!-_.'()`
|
|
169
215
|
*/
|
|
170
216
|
async getRecord(key) {
|
|
171
|
-
|
|
217
|
+
tryCancel();
|
|
172
218
|
ow(key, ow.string.nonEmpty);
|
|
173
|
-
this.
|
|
174
|
-
const record = await this.backend.getValue(key);
|
|
175
|
-
if (!record)
|
|
176
|
-
return null;
|
|
177
|
-
return {
|
|
178
|
-
value: record.value,
|
|
179
|
-
contentType: record.contentType ?? null,
|
|
180
|
-
};
|
|
219
|
+
return this.readRecord(key);
|
|
181
220
|
}
|
|
182
221
|
/**
|
|
183
222
|
* Tests whether a record with the given key exists in the key-value store without retrieving its value.
|
|
@@ -186,16 +225,22 @@ export class KeyValueStore {
|
|
|
186
225
|
* @returns `true` if the record exists, `false` if it does not.
|
|
187
226
|
*/
|
|
188
227
|
async recordExists(key) {
|
|
189
|
-
|
|
228
|
+
tryCancel();
|
|
190
229
|
ow(key, ow.string.nonEmpty);
|
|
230
|
+
const entry = this.bufferedJournalEntries()?.get(key);
|
|
231
|
+
if (entry) {
|
|
232
|
+
return entry.value !== null;
|
|
233
|
+
}
|
|
191
234
|
return this.backend.recordExists(key);
|
|
192
235
|
}
|
|
193
236
|
async getAutoSavedValue(key, defaultValue = {}) {
|
|
194
|
-
|
|
237
|
+
tryCancel();
|
|
195
238
|
if (this.cache.has(key)) {
|
|
196
239
|
return this.cache.get(key);
|
|
197
240
|
}
|
|
198
|
-
|
|
241
|
+
// Auto-saved state is deliberately *not* transactional. The direct read bypasses any active
|
|
242
|
+
// transaction - a buffered value seeded into this shared cache would survive a rollback forever.
|
|
243
|
+
const value = await withDirectStorageAccess(async () => this.getValue(key, defaultValue));
|
|
199
244
|
// The await above could have run in parallel with another call to this function. If the other call finished more quickly,
|
|
200
245
|
// the value will in cache at this point, and returning the new fetched value would introduce two different instances of
|
|
201
246
|
// the auto-saved object, and only the latter one would be persisted.
|
|
@@ -221,11 +266,14 @@ export class KeyValueStore {
|
|
|
221
266
|
this.persistStateEventStarted = true;
|
|
222
267
|
}
|
|
223
268
|
async *fetchKeyValuePages(options, mapRecord) {
|
|
224
|
-
for
|
|
269
|
+
// Reduce the journal once for the whole iteration, not once per key inside `readRecord`.
|
|
270
|
+
const buffered = this.bufferedJournalEntries();
|
|
271
|
+
for await (const page of this.fetchKeyPages(options, buffered)) {
|
|
225
272
|
const results = [];
|
|
226
273
|
for (const item of page) {
|
|
227
|
-
|
|
228
|
-
|
|
274
|
+
// The shared transaction-aware read, so a key that exists only in the transaction resolves
|
|
275
|
+
// here instead of being dropped (`values()` would disagree with `keys()` on length).
|
|
276
|
+
const record = await this.readRecord(item.key, buffered);
|
|
229
277
|
if (record) {
|
|
230
278
|
const parsed = parseValue(record.value, record.contentType ?? null);
|
|
231
279
|
results.push(mapRecord(item.key, parsed));
|
|
@@ -234,7 +282,25 @@ export class KeyValueStore {
|
|
|
234
282
|
yield results;
|
|
235
283
|
}
|
|
236
284
|
}
|
|
237
|
-
async *fetchKeyPages(options, limit = KVS_KEYS_DEFAULT_LIMIT) {
|
|
285
|
+
async *fetchKeyPages(options, buffered = this.bufferedJournalEntries(), limit = KVS_KEYS_DEFAULT_LIMIT) {
|
|
286
|
+
// Buffered keys are emitted first, then the real pages with any buffered (or tombstoned) key
|
|
287
|
+
// skipped - a merge-join is not an option, since `listKeys` promises no sort order.
|
|
288
|
+
const shadowedKeys = new Set();
|
|
289
|
+
if (buffered) {
|
|
290
|
+
const bufferedItems = [];
|
|
291
|
+
for (const [key, entry] of buffered) {
|
|
292
|
+
shadowedKeys.add(key);
|
|
293
|
+
if (entry.value === null)
|
|
294
|
+
continue;
|
|
295
|
+
if (options.prefix !== undefined && !key.startsWith(options.prefix))
|
|
296
|
+
continue;
|
|
297
|
+
bufferedItems.push(bufferedKeyItemData(key, entry));
|
|
298
|
+
}
|
|
299
|
+
if (bufferedItems.length > 0) {
|
|
300
|
+
bufferedItems.sort((a, b) => (a.key < b.key ? -1 : 1));
|
|
301
|
+
yield bufferedItems;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
238
304
|
let exclusiveStartKey;
|
|
239
305
|
while (true) {
|
|
240
306
|
this.statsTracker.add('listCount');
|
|
@@ -243,9 +309,10 @@ export class KeyValueStore {
|
|
|
243
309
|
exclusiveStartKey,
|
|
244
310
|
limit,
|
|
245
311
|
});
|
|
246
|
-
yield items;
|
|
312
|
+
yield shadowedKeys.size > 0 ? items.filter((item) => !shadowedKeys.has(item.key)) : items;
|
|
247
313
|
if (!isTruncated)
|
|
248
314
|
break;
|
|
315
|
+
// Paginate from the raw backend cursor - it may reject a key it did not hand out.
|
|
249
316
|
exclusiveStartKey = nextExclusiveStartKey;
|
|
250
317
|
}
|
|
251
318
|
}
|
|
@@ -293,7 +360,7 @@ export class KeyValueStore {
|
|
|
293
360
|
* @param [options] Record options.
|
|
294
361
|
*/
|
|
295
362
|
async setValue(key, value, options = {}) {
|
|
296
|
-
|
|
363
|
+
const transaction = activeStorageTransaction();
|
|
297
364
|
ow(key, 'key', ow.string.nonEmpty);
|
|
298
365
|
ow(key, ow.string.validate((k) => ({
|
|
299
366
|
validator: ow.isValid(k, ow.string.matches(KEY_VALUE_STORE_KEY_REGEX)),
|
|
@@ -307,6 +374,32 @@ export class KeyValueStore {
|
|
|
307
374
|
}));
|
|
308
375
|
// Make copy of options, don't update what user passed.
|
|
309
376
|
const optionsCopy = { ...options };
|
|
377
|
+
// The whole transaction branch sits *above* the auto-saved cache update below, so a buffered
|
|
378
|
+
// write touches nothing outside the journal. That cache is shared, process-lifetime frontend
|
|
379
|
+
// state, so mutating it here would survive a rollback and later be persisted by `persistState`.
|
|
380
|
+
// The commit replay re-enters this method with no active transaction and updates it then.
|
|
381
|
+
if (transaction) {
|
|
382
|
+
if (isStream(value)) {
|
|
383
|
+
// A stream cannot serve both a read-your-own-writes read and the commit replay. The
|
|
384
|
+
// transaction is known-active here, so throw directly rather than via the conditional guard.
|
|
385
|
+
throw operationRejectedInTransaction(`KeyValueStore.setValue() with a stream value (key "${key}")`, 'a stream can only be consumed once, so it cannot be buffered until commit.');
|
|
386
|
+
}
|
|
387
|
+
// Validation only, result discarded: the journal snapshot (`structuredClone`) accepts values
|
|
388
|
+
// JSON cannot, which would otherwise only throw at a later read or at commit.
|
|
389
|
+
if (value !== null) {
|
|
390
|
+
serializeValue(value, optionsCopy.contentType);
|
|
391
|
+
}
|
|
392
|
+
// One snapshot serves both the reads and the commit replay; `null` is a tombstone.
|
|
393
|
+
transaction.recordJournalEntry({
|
|
394
|
+
type: 'keyValueStore',
|
|
395
|
+
participant: this,
|
|
396
|
+
storageId: this.id,
|
|
397
|
+
key,
|
|
398
|
+
value: value === null ? null : snapshotValue(value),
|
|
399
|
+
options: optionsCopy,
|
|
400
|
+
});
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
310
403
|
// If we try to set the value of a cached state to a different reference, we need to update the cache accordingly.
|
|
311
404
|
const cachedValue = this.cache.get(key);
|
|
312
405
|
if (cachedValue && cachedValue !== value) {
|
|
@@ -336,18 +429,31 @@ export class KeyValueStore {
|
|
|
336
429
|
contentType: serialized.contentType,
|
|
337
430
|
});
|
|
338
431
|
}
|
|
432
|
+
/** @internal */
|
|
433
|
+
async commitJournalEntries(entries) {
|
|
434
|
+
// One `setValue` per key, last write wins - idempotent under retry.
|
|
435
|
+
const lastWritePerKey = new Map();
|
|
436
|
+
for (const entry of entries) {
|
|
437
|
+
if (entry.type === 'keyValueStore') {
|
|
438
|
+
lastWritePerKey.set(entry.key, { value: entry.value, options: entry.options });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
for (const [key, { value, options }] of lastWritePerKey) {
|
|
442
|
+
await this.setValue(key, value, options);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
339
445
|
/**
|
|
340
446
|
* Removes the key-value store either from the Apify cloud storage or from the local directory,
|
|
341
447
|
* depending on the mode of operation.
|
|
342
448
|
*/
|
|
343
449
|
async drop() {
|
|
344
|
-
|
|
450
|
+
rejectOperationInTransaction('KeyValueStore.drop()');
|
|
345
451
|
await this.backend.drop();
|
|
346
452
|
serviceLocator.getStorageInstanceManager().removeFromCache(this);
|
|
347
453
|
}
|
|
348
454
|
/** @internal */
|
|
349
455
|
clearCache() {
|
|
350
|
-
|
|
456
|
+
rejectOperationInTransaction('KeyValueStore.clearCache()');
|
|
351
457
|
this.cache.clear();
|
|
352
458
|
}
|
|
353
459
|
/**
|
|
@@ -372,7 +478,7 @@ export class KeyValueStore {
|
|
|
372
478
|
* @param [options] All `forEachKey()` parameters.
|
|
373
479
|
*/
|
|
374
480
|
async forEachKey(iteratee, options = {}) {
|
|
375
|
-
|
|
481
|
+
tryCancel();
|
|
376
482
|
ow(iteratee, ow.function);
|
|
377
483
|
ow(options, ow.object.exactShape({
|
|
378
484
|
prefix: ow.optional.string,
|
|
@@ -408,7 +514,7 @@ export class KeyValueStore {
|
|
|
408
514
|
* @param options Options for the iteration.
|
|
409
515
|
*/
|
|
410
516
|
keys(options = {}) {
|
|
411
|
-
|
|
517
|
+
tryCancel();
|
|
412
518
|
return createDualIterable({
|
|
413
519
|
createPages: () => this.fetchKeyPages(options),
|
|
414
520
|
extractItems: (page) => page.map((item) => item.key),
|
|
@@ -438,7 +544,7 @@ export class KeyValueStore {
|
|
|
438
544
|
* @param options Options for the iteration.
|
|
439
545
|
*/
|
|
440
546
|
values(options = {}) {
|
|
441
|
-
|
|
547
|
+
tryCancel();
|
|
442
548
|
return createDualIterable({
|
|
443
549
|
createPages: () => this.fetchKeyValuePages(options, (_key, value) => value),
|
|
444
550
|
extractItems: (page) => page,
|
|
@@ -468,7 +574,7 @@ export class KeyValueStore {
|
|
|
468
574
|
* @param options Options for the iteration.
|
|
469
575
|
*/
|
|
470
576
|
entries(options = {}) {
|
|
471
|
-
|
|
577
|
+
tryCancel();
|
|
472
578
|
return createDualIterable({
|
|
473
579
|
createPages: () => this.fetchKeyValuePages(options, (key, value) => [key, value]),
|
|
474
580
|
extractItems: (page) => page,
|
|
@@ -515,7 +621,7 @@ export class KeyValueStore {
|
|
|
515
621
|
* @param [options] Storage manager options.
|
|
516
622
|
*/
|
|
517
623
|
static async open(identifier, options = {}) {
|
|
518
|
-
|
|
624
|
+
tryCancel();
|
|
519
625
|
ow(options, ow.object.exactShape({
|
|
520
626
|
configuration: ow.optional.object.instanceOf(Configuration),
|
|
521
627
|
storageBackend: ow.optional.object,
|
|
@@ -652,3 +758,22 @@ export class KeyValueStore {
|
|
|
652
758
|
return store.getValue(store.configuration.inputKey);
|
|
653
759
|
}
|
|
654
760
|
}
|
|
761
|
+
/** Normalizes a codec-serialized value into the `Buffer | ArrayBuffer` shape raw record reads promise. */
|
|
762
|
+
function normalizeSerializedValue(value) {
|
|
763
|
+
if (typeof value === 'string') {
|
|
764
|
+
return Buffer.from(value);
|
|
765
|
+
}
|
|
766
|
+
if (ArrayBuffer.isView(value)) {
|
|
767
|
+
return Buffer.isBuffer(value) ? value : Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
768
|
+
}
|
|
769
|
+
return value;
|
|
770
|
+
}
|
|
771
|
+
/** Computes the key listing item (serialized byte size and content type) of a buffered entry. */
|
|
772
|
+
function bufferedKeyItemData(key, entry) {
|
|
773
|
+
const serialized = serializeValue(entry.value, entry.options?.contentType);
|
|
774
|
+
return {
|
|
775
|
+
key,
|
|
776
|
+
size: normalizeSerializedValue(serialized.value).byteLength,
|
|
777
|
+
contentType: serialized.contentType,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { CrawleeLogger } from '../log.js';
|
|
|
4
4
|
import type { IProxyConfiguration } from '../proxy_configuration.js';
|
|
5
5
|
import type { Source } from '../request.js';
|
|
6
6
|
import { Request } from '../request.js';
|
|
7
|
+
import type { JournalEntry } from './transaction.js';
|
|
7
8
|
import type { IRequestManager, RequestsLike } from './request_manager.js';
|
|
8
9
|
import type { RequestQueueStats } from './storage_stats.js';
|
|
9
10
|
import type { IStorage, StorageIdentifier } from './storage_instance_manager.js';
|
|
@@ -102,6 +103,24 @@ export declare class RequestQueue implements IStorage, IRequestManager {
|
|
|
102
103
|
* @param [options] Request queue operation options.
|
|
103
104
|
*/
|
|
104
105
|
addRequest(requestLike: Source, options?: RequestQueueOperationOptions): Promise<RequestQueueOperationInfo>;
|
|
106
|
+
/**
|
|
107
|
+
* Journals an addition for introspection only; these entries are never replayed. A no-op unless the
|
|
108
|
+
* transaction is open, so detached and outliving writers stay out of the journal.
|
|
109
|
+
*/
|
|
110
|
+
private recordRequestJournalEntry;
|
|
111
|
+
/**
|
|
112
|
+
* The requests buffered by the given transaction for this queue, keyed by `uniqueKey` — a dedup
|
|
113
|
+
* index derived from the transaction journal.
|
|
114
|
+
*/
|
|
115
|
+
private bufferedRequests;
|
|
116
|
+
/**
|
|
117
|
+
* Adds a request under the `deferred` policy: journaled now, really added by the commit replay.
|
|
118
|
+
* A new request's `requestId` is the local `uniqueKey` hash and is **provisional** — never write it
|
|
119
|
+
* to `request.id` or the dedup caches. Dedup is cheapest-first: buffer, caches, then a backend probe.
|
|
120
|
+
*/
|
|
121
|
+
private addRequestDeferred;
|
|
122
|
+
/** @internal */
|
|
123
|
+
commitJournalEntries(entries: JournalEntry[]): Promise<void>;
|
|
105
124
|
/**
|
|
106
125
|
* Adds requests to the queue in batches of 25. This method will wait till all the requests are added
|
|
107
126
|
* to the queue before resolving. You should prefer using `queue.addRequestsBatched()` or `crawler.addRequests()`
|