@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.
@@ -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 { checkStorageAccess } from './access_checking.js';
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
- checkStorageAccess();
133
+ tryCancel();
133
134
  ow(key, ow.string.nonEmpty);
134
- this.statsTracker.add('readCount');
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
- checkStorageAccess();
217
+ tryCancel();
172
218
  ow(key, ow.string.nonEmpty);
173
- this.statsTracker.add('readCount');
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
- checkStorageAccess();
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
- checkStorageAccess();
237
+ tryCancel();
195
238
  if (this.cache.has(key)) {
196
239
  return this.cache.get(key);
197
240
  }
198
- const value = await this.getValue(key, defaultValue);
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 await (const page of this.fetchKeyPages(options)) {
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
- this.statsTracker.add('readCount');
228
- const record = await this.backend.getValue(item.key);
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
- checkStorageAccess();
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
- checkStorageAccess();
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
- checkStorageAccess();
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
- checkStorageAccess();
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
- checkStorageAccess();
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
- checkStorageAccess();
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
- checkStorageAccess();
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
- checkStorageAccess();
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()`