@crawlee/core 4.0.0-beta.104 → 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.
@@ -0,0 +1,251 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { addTimeoutToPromise, storage as timeoutStorage, tryCancel } from '@apify/timeout';
3
+ import { serviceLocator } from '../service_locator.js';
4
+ const DEFAULT_STORAGE_WRITE_POLICY = { requestQueue: 'writeThrough' };
5
+ const DEFAULT_COMMIT_TIMEOUT_MILLIS = 300_000;
6
+ const transactionStorage = new AsyncLocalStorage();
7
+ const COMMIT_ORDER = ['keyValueStore', 'requestQueue', 'dataset'];
8
+ /**
9
+ * A storage transaction scoped to a request's lifecycle. Writes made through the storage frontends
10
+ * ({@link Dataset}, {@link KeyValueStore}, {@link RequestQueue}) while the transaction is active
11
+ * are recorded rather than applied; on {@link StorageTransaction.commit|`commit()`} they are replayed
12
+ * into real storage, on {@link StorageTransaction.rollback|`rollback()`} they are dropped. Reads consult
13
+ * the recorded writes first, so a handler sees its own writes.
14
+ *
15
+ * Create one with {@link createStorageTransaction} (explicit commit/rollback) or
16
+ * {@link withStorageTransaction} (scoped sugar). Crawlers open one automatically around every request
17
+ * handler unless `transactionalStorage: false` is set.
18
+ */
19
+ export class StorageTransaction {
20
+ /** The ordered, append-only journal — the source of truth for commit, introspection and reads. */
21
+ journal = [];
22
+ /** Per-storage-type write policy. */
23
+ policy;
24
+ commitTimeoutMillis;
25
+ _state = 'open';
26
+ disposed = false;
27
+ /** @internal */
28
+ constructor(options = {}) {
29
+ this.policy = { ...DEFAULT_STORAGE_WRITE_POLICY, ...options.policy };
30
+ this.commitTimeoutMillis = options.commitTimeoutMillis ?? DEFAULT_COMMIT_TIMEOUT_MILLIS;
31
+ }
32
+ get state() {
33
+ return this._state;
34
+ }
35
+ /**
36
+ * `true` only while `state === 'open'`. This is the single predicate every storage operation
37
+ * consults — operations performed after the transaction is closed pass through to the real backend.
38
+ */
39
+ get isActive() {
40
+ return this._state === 'open';
41
+ }
42
+ /** Runs `callback` with this transaction installed in the async context. */
43
+ async run(callback) {
44
+ return transactionStorage.run(this, async () => callback());
45
+ }
46
+ /**
47
+ * Records a write operation in the journal.
48
+ * @internal
49
+ */
50
+ recordJournalEntry(entry) {
51
+ if (!this.isActive) {
52
+ throw new Error(`Cannot record a journal entry on a transaction in the '${this._state}' state`);
53
+ }
54
+ this.journal.push(entry);
55
+ }
56
+ /**
57
+ * Replays the journaled writes into real storage. A no-op unless the transaction is `open`.
58
+ *
59
+ * The transaction transitions to `committing` *before* anything is flushed, so a commit that throws
60
+ * partway lands in `failed` (never back in `open`) and subsequent storage operations pass through
61
+ * rather than recording into a dead transaction. Delivery is at-least-once — a commit that fails
62
+ * partway may have applied some of the writes already.
63
+ */
64
+ async commit() {
65
+ if (this._state !== 'open') {
66
+ return;
67
+ }
68
+ this._state = 'committing';
69
+ try {
70
+ // The replay re-drives the frontend write path, which checks for cancellation (`tryCancel`)
71
+ // on every operation - and `@apify/timeout` shares one `AbortController` across nested
72
+ // frames, so a request-handler timeout that already fired would abort the commit of a
73
+ // handler that succeeded. Hence a fresh timeout context, which also provides the time bound.
74
+ await timeoutStorage.exit(async () => addTimeoutToPromise(async () => this.flush(), this.commitTimeoutMillis, `Committing the storage transaction timed out after ${this.commitTimeoutMillis / 1000} seconds.`));
75
+ this._state = 'committed';
76
+ }
77
+ catch (error) {
78
+ this._state = 'failed';
79
+ throw error;
80
+ }
81
+ }
82
+ async flush() {
83
+ // Each participating frontend replays all of its buffered entries in one call. Frontends are
84
+ // ordered by storage type: key-value stores and request queues first (idempotent under retry),
85
+ // datasets last (not idempotent), minimizing the blast radius of a partial commit failure.
86
+ const groups = new Map();
87
+ for (const entry of this.journal) {
88
+ if (entry.type === 'requestQueue' && entry.writeThrough)
89
+ continue;
90
+ const group = groups.get(entry.participant);
91
+ if (group)
92
+ group.push(entry);
93
+ else
94
+ groups.set(entry.participant, [entry]);
95
+ }
96
+ // A participant only records entries of its own storage type, so the first entry determines
97
+ // the group's place in the commit order.
98
+ const orderedGroups = [...groups.values()].sort((a, b) => COMMIT_ORDER.indexOf(a[0].type) - COMMIT_ORDER.indexOf(b[0].type));
99
+ for (const entries of orderedGroups) {
100
+ await entries[0].participant.commitJournalEntries(entries);
101
+ }
102
+ }
103
+ /**
104
+ * Discards the journaled writes. A no-op unless the transaction is `open` — in particular, calling it
105
+ * after a successful `commit()` (which the crawler's error handling can legitimately do) does nothing
106
+ * and never throws.
107
+ */
108
+ rollback() {
109
+ if (this._state !== 'open') {
110
+ return;
111
+ }
112
+ this._state = 'rolledBack';
113
+ }
114
+ /**
115
+ * Releases the journal and the write-time snapshots it holds. Must be called for *every* terminal
116
+ * state, `failed` included. Idempotent, never throws, and does not change `state`. Any
117
+ * {@link StorageTransactionView} of this transaction is only valid until this is called.
118
+ */
119
+ dispose() {
120
+ if (this.disposed) {
121
+ return;
122
+ }
123
+ if (this._state === 'open') {
124
+ // Disposing an open transaction is an internal invariant violation - roll back first.
125
+ try {
126
+ serviceLocator
127
+ .getLogger()
128
+ .warning('Internal error: a storage transaction was disposed while still open; rolling it back.');
129
+ }
130
+ catch {
131
+ // Never throw from dispose.
132
+ }
133
+ this.rollback();
134
+ }
135
+ this.disposed = true;
136
+ this.journal.length = 0;
137
+ }
138
+ get datasetItems() {
139
+ return this.journal.flatMap((entry) => entry.type === 'dataset' ? entry.items.map((item) => ({ item, datasetId: entry.storageId })) : []);
140
+ }
141
+ get enqueuedUrls() {
142
+ return this.journal.flatMap((entry) => entry.type === 'requestQueue' ? entry.requests.map(({ url, label }) => ({ url, label })) : []);
143
+ }
144
+ get keyValueStoreChanges() {
145
+ const result = {};
146
+ for (const entry of this.journal) {
147
+ if (entry.type !== 'keyValueStore')
148
+ continue;
149
+ result[entry.storageId] ??= {};
150
+ result[entry.storageId][entry.key] = { changedValue: entry.value, options: entry.options };
151
+ }
152
+ return result;
153
+ }
154
+ }
155
+ /**
156
+ * Opens a {@link StorageTransaction} without running anything yet. The caller owns the outcome:
157
+ * `run()`, then `commit()` or `rollback()`, and always `dispose()` when done. For the common
158
+ * open-run-commit flow, prefer {@link withStorageTransaction}.
159
+ */
160
+ export function createStorageTransaction(options = {}) {
161
+ return new StorageTransaction(options);
162
+ }
163
+ /**
164
+ * Runs `callback` inside a new {@link StorageTransaction}: storage writes made in the callback are
165
+ * committed when it returns and rolled back when it throws. If a transaction is already active in the
166
+ * current async context, it is reused and its outcome is left to its owner (and `options` are ignored)
167
+ * — there are no nested transaction semantics.
168
+ */
169
+ export async function withStorageTransaction(callback, options = {}) {
170
+ const existing = transactionStorage.getStore();
171
+ if (existing?.isActive) {
172
+ return callback(existing);
173
+ }
174
+ const transaction = createStorageTransaction(options);
175
+ try {
176
+ const result = await transaction.run(async () => callback(transaction));
177
+ await transaction.commit();
178
+ return result;
179
+ }
180
+ catch (error) {
181
+ transaction.rollback();
182
+ throw error;
183
+ }
184
+ finally {
185
+ transaction.dispose();
186
+ }
187
+ }
188
+ /**
189
+ * Runs `callback` outside of any storage transaction — the per-call-site escape hatch. Storage operations
190
+ * made inside it hit the real backend directly, are not rolled back, and operations that a transaction
191
+ * rejects (`drop`, stream-valued `setValue`, request queue internals, ...) are permitted.
192
+ */
193
+ export async function withDirectStorageAccess(callback) {
194
+ return transactionStorage.exit(async () => callback());
195
+ }
196
+ /**
197
+ * The per-operation hook consulted by every storage frontend method: performs the cancellation check
198
+ * that aborts storage operations when the request handler times out, and returns the active storage
199
+ * transaction. Returns `undefined` when there is no transaction in the async context *or* when it is no
200
+ * longer open — operations on a closed transaction deliberately pass through to the real backend.
201
+ * @internal
202
+ */
203
+ export function activeStorageTransaction() {
204
+ tryCancel();
205
+ const transaction = transactionStorage.getStore();
206
+ return transaction?.isActive ? transaction : undefined;
207
+ }
208
+ /**
209
+ * Returns the transaction installed in the current async context, regardless of its state. Used by the
210
+ * crawler to drive the outcome of the transaction it opened.
211
+ * @internal
212
+ */
213
+ export function currentStorageTransaction() {
214
+ return transactionStorage.getStore();
215
+ }
216
+ /**
217
+ * Captures a value at write time, so that later mutations of the caller's object affect neither the
218
+ * read-your-own-writes reads nor the commit replay. `structuredClone` for fidelity (`Date`, `Map`, `Set`,
219
+ * typed arrays, `undefined`); values it cannot handle fall back to the JSON round-trip the storage
220
+ * backends perform anyway.
221
+ * @internal
222
+ */
223
+ export function snapshotValue(value) {
224
+ try {
225
+ return structuredClone(value);
226
+ }
227
+ catch {
228
+ return JSON.parse(JSON.stringify(value));
229
+ }
230
+ }
231
+ /**
232
+ * The guard for operations that cannot be performed inside a storage transaction: throws when one is
233
+ * active, and performs the per-operation cancellation check either way.
234
+ * @internal
235
+ */
236
+ export function rejectOperationInTransaction(operation, reason = 'it cannot be rolled back.') {
237
+ if (activeStorageTransaction() === undefined) {
238
+ return;
239
+ }
240
+ throw operationRejectedInTransaction(operation, reason);
241
+ }
242
+ /**
243
+ * Builds the "operation not allowed in a transaction" error, for a call site that has already
244
+ * established a transaction is active and so wants to `throw` unconditionally.
245
+ * @internal
246
+ */
247
+ export function operationRejectedInTransaction(operation, reason = 'it cannot be rolled back.') {
248
+ return new Error(`${operation} cannot be used inside a storage transaction: ${reason} ` +
249
+ 'If you really need it, wrap the call in withDirectStorageAccess(() => ...) - operations ' +
250
+ 'performed there are applied immediately and are not rolled back.');
251
+ }
@@ -1,12 +0,0 @@
1
- import type { Awaitable } from '@crawlee/types';
2
- /**
3
- * Invoke a storage access checker function defined using {@link withCheckedStorageAccess} higher up in the call stack.
4
- */
5
- export declare const checkStorageAccess: () => void | undefined;
6
- /**
7
- * Define a storage access checker function that should be used by calls to {@link checkStorageAccess} in the callbacks.
8
- *
9
- * @param checkFunction The check function that should be invoked by {@link checkStorageAccess} calls
10
- * @param callback The code that should be invoked with the `checkFunction` setting
11
- */
12
- export declare const withCheckedStorageAccess: <T>(checkFunction: () => void, callback: () => Awaitable<T>) => Promise<T>;
@@ -1,17 +0,0 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks';
2
- import { tryCancel } from '@apify/timeout';
3
- const storage = new AsyncLocalStorage();
4
- /**
5
- * Invoke a storage access checker function defined using {@link withCheckedStorageAccess} higher up in the call stack.
6
- */
7
- export const checkStorageAccess = () => {
8
- tryCancel();
9
- return storage.getStore()?.checkFunction();
10
- };
11
- /**
12
- * Define a storage access checker function that should be used by calls to {@link checkStorageAccess} in the callbacks.
13
- *
14
- * @param checkFunction The check function that should be invoked by {@link checkStorageAccess} calls
15
- * @param callback The code that should be invoked with the `checkFunction` setting
16
- */
17
- export const withCheckedStorageAccess = async (checkFunction, callback) => storage.run({ checkFunction }, callback);