@crawlee/core 4.0.0-beta.166 → 4.0.0-beta.168

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,4 +1,4 @@
1
- import type { Dictionary, HttpRequestOptions, ISession, ProxyInfo, SendRequestOptions } from '@crawlee/types';
1
+ import type { Awaitable, Dictionary, HttpRequestOptions, ISession, ProxyInfo, SendRequestOptions } from '@crawlee/types';
2
2
  import type { ReadonlyDeep } from 'type-fest';
3
3
  import type { EnqueueUrlsOptions } from '../enqueue_links/enqueue_links.js';
4
4
  import type { CrawleeLogger } from '../log.js';
@@ -136,6 +136,46 @@ export interface CrawlingContext<UserData extends Dictionary = Dictionary> exten
136
136
  * here can land more than once for a single request. Push results from the request handler itself.
137
137
  */
138
138
  registerDeferredCleanup(cleanup: () => Promise<unknown>): void;
139
+ /**
140
+ * Registers `callback` to run once the request's storage writes have been committed, or once
141
+ * committing them has failed - the point where a write made in the handler can finally be reacted
142
+ * to where it was made. Two things belong here: side effects that must agree with what actually
143
+ * landed (result counters, progress reporting), and handling of a write that did not land at all.
144
+ *
145
+ * ```ts
146
+ * async requestHandler({ pushData, afterStorageCommit, useState }) {
147
+ * const state = await useState({ itemCount: 0 });
148
+ *
149
+ * await pushData(item);
150
+ * afterStorageCommit((error) => {
151
+ * if (error) {
152
+ * throw new NonRetryableError('The page is too big to store', { cause: error });
153
+ * }
154
+ * state.itemCount++;
155
+ * });
156
+ * }
157
+ * ```
158
+ *
159
+ * `useState()` is deliberately not transactional, which is what makes the counter case necessary:
160
+ * incrementing in the handler would keep the increment for a request that pushes an item and then
161
+ * fails, while the item itself is rolled back.
162
+ *
163
+ * Callbacks run in registration order, after the commit and before the request is marked as handled.
164
+ * The first one to throw propagates and the rest do not run; on a failed commit its error replaces
165
+ * the commit error, so a callback can decide what the request fails with. After a *successful*
166
+ * commit, a callback that throws fails the request **without a retry** whatever it throws - the
167
+ * writes are already durable and retrying would duplicate them, so anything that is not already a
168
+ * {@link NonRetryableError} is wrapped in an {@link AfterCommitError}.
169
+ *
170
+ * Callbacks are *not* run when the request handler itself fails - nothing was written, and
171
+ * `errorHandler` / `failedRequestHandler` cover that.
172
+ *
173
+ * Throws when storage is not transactional (`transactionalStorage: false`) - writes are then applied
174
+ * as they are made and throw at the call site on their own. In {@link AdaptivePlaywrightCrawler}
175
+ * the callback belongs to the current request handler attempt, so it runs only for the attempt whose
176
+ * writes are the ones being committed.
177
+ */
178
+ afterStorageCommit(callback: (error?: Error) => Awaitable<void>): void;
139
179
  /**
140
180
  * Gives the current request `secs` more seconds to finish, for when how long it needs is only apparent
141
181
  * once it is already running - a listing page that turns out to have far more to scroll through than
package/errors.d.ts CHANGED
@@ -99,6 +99,16 @@ export declare class ContextPipelineCleanupError extends CriticalError {
99
99
  export declare class RequestHandlerError extends Error {
100
100
  constructor(error: unknown, options?: ErrorOptions);
101
101
  }
102
+ /**
103
+ * Wraps the failure of a callback registered with {@link StorageTransaction.afterCommit|`afterCommit`}
104
+ * that ran after the request's writes had already been committed.
105
+ *
106
+ * Non-retryable by nature: the writes are durable, so re-running the request handler would duplicate
107
+ * them. A callback that throws a `NonRetryableError` of its own is left alone.
108
+ */
109
+ export declare class AfterCommitError extends NonRetryableError {
110
+ constructor(cause: unknown);
111
+ }
102
112
  /**
103
113
  * Thrown when attempting to set a different service instance after one has already been retrieved.
104
114
  */
package/errors.js CHANGED
@@ -124,6 +124,18 @@ export class RequestHandlerError extends Error {
124
124
  super(undefined, { cause: error, ...options });
125
125
  }
126
126
  }
127
+ /**
128
+ * Wraps the failure of a callback registered with {@link StorageTransaction.afterCommit|`afterCommit`}
129
+ * that ran after the request's writes had already been committed.
130
+ *
131
+ * Non-retryable by nature: the writes are durable, so re-running the request handler would duplicate
132
+ * them. A callback that throws a `NonRetryableError` of its own is left alone.
133
+ */
134
+ export class AfterCommitError extends NonRetryableError {
135
+ constructor(cause) {
136
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
137
+ }
138
+ }
127
139
  /**
128
140
  * Thrown when attempting to set a different service instance after one has already been retrieved.
129
141
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/core",
3
- "version": "4.0.0-beta.166",
3
+ "version": "4.0.0-beta.168",
4
4
  "description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
5
5
  "engines": {
6
6
  "node": ">=22.0.0"
@@ -52,10 +52,10 @@
52
52
  "@apify/log": "^3.0.1",
53
53
  "@apify/timeout": "^1.0.1",
54
54
  "@apify/utilities": "^3.0.1",
55
- "@crawlee/fs-storage": "4.0.0-beta.166",
56
- "@crawlee/http-client": "4.0.0-beta.166",
57
- "@crawlee/types": "4.0.0-beta.166",
58
- "@crawlee/utils": "4.0.0-beta.166",
55
+ "@crawlee/fs-storage": "4.0.0-beta.168",
56
+ "@crawlee/http-client": "4.0.0-beta.168",
57
+ "@crawlee/types": "4.0.0-beta.168",
58
+ "@crawlee/utils": "4.0.0-beta.168",
59
59
  "@sapphire/async-queue": "^1.5.5",
60
60
  "@standard-schema/spec": "^1.0.0",
61
61
  "@vladfrangu/async_event_emitter": "^2.4.6",
@@ -78,5 +78,5 @@
78
78
  }
79
79
  }
80
80
  },
81
- "gitHead": "5d34cf3b9da170f22cf5b346014ba44060dad827"
81
+ "gitHead": "fc75a7073fc330331ff2d68d81a8e6815ab4edf2"
82
82
  }
@@ -162,6 +162,13 @@ export declare class StorageTransaction implements StorageTransactionView {
162
162
  * @internal
163
163
  */
164
164
  recordJournalEntry(entry: JournalEntry): void;
165
+ /**
166
+ * Registers a callback to run once a commit has been attempted, whether it succeeded (`error` is
167
+ * `undefined`) or failed - the only point at which a deferred write can be reacted to where it was
168
+ * made. Callbacks run in registration order; the first to throw propagates, the rest do not run, and
169
+ * on a failed commit its error replaces the commit error. Not run on rollback: nothing was written.
170
+ */
171
+ afterCommit(callback: (error?: Error) => Awaitable<void>): void;
165
172
  /**
166
173
  * Replays the journaled writes into real storage. A no-op unless the transaction is `open`.
167
174
  *
@@ -179,9 +186,10 @@ export declare class StorageTransaction implements StorageTransactionView {
179
186
  */
180
187
  rollback(): void;
181
188
  /**
182
- * Releases the journal and the write-time snapshots it holds. Must be called for *every* terminal
183
- * state, `failed` included. Idempotent, never throws, and does not change `state`. Any
184
- * {@link StorageTransactionView} of this transaction is only valid until this is called.
189
+ * Releases the journal, the write-time snapshots it holds and any registered commit callbacks. Must
190
+ * be called for *every* terminal state, `failed` included. Idempotent, never throws, and does not
191
+ * change `state`. Any {@link StorageTransactionView} of this transaction is only valid until this
192
+ * is called.
185
193
  */
186
194
  dispose(): void;
187
195
  get datasetItems(): {
@@ -1,6 +1,7 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { addTimeoutToPromise, storage as timeoutStorage, tryCancel } from '@apify/timeout';
3
3
  import { serviceLocator } from '../service_locator.js';
4
+ import { AfterCommitError, NonRetryableError } from '../errors.js';
4
5
  const DEFAULT_STORAGE_WRITE_POLICY = { requestQueue: 'writeThrough' };
5
6
  const DEFAULT_COMMIT_TIMEOUT_MILLIS = 300_000;
6
7
  const transactionStorage = new AsyncLocalStorage();
@@ -24,6 +25,8 @@ export class StorageTransaction {
24
25
  #commitTimeoutMillis;
25
26
  #state = 'open';
26
27
  #disposed = false;
28
+ /** Callbacks registered via `afterCommit()`, run in registration order once a commit has been attempted. */
29
+ #afterCommitCallbacks = [];
27
30
  /** @internal */
28
31
  constructor(options = {}) {
29
32
  this.policy = { ...DEFAULT_STORAGE_WRITE_POLICY, ...options.policy };
@@ -53,6 +56,18 @@ export class StorageTransaction {
53
56
  }
54
57
  this.journal.push(entry);
55
58
  }
59
+ /**
60
+ * Registers a callback to run once a commit has been attempted, whether it succeeded (`error` is
61
+ * `undefined`) or failed - the only point at which a deferred write can be reacted to where it was
62
+ * made. Callbacks run in registration order; the first to throw propagates, the rest do not run, and
63
+ * on a failed commit its error replaces the commit error. Not run on rollback: nothing was written.
64
+ */
65
+ afterCommit(callback) {
66
+ if (!this.isActive) {
67
+ throw new Error(`Cannot register a commit callback on a transaction in the '${this.#state}' state`);
68
+ }
69
+ this.#afterCommitCallbacks.push(callback);
70
+ }
56
71
  /**
57
72
  * Replays the journaled writes into real storage. A no-op unless the transaction is `open`.
58
73
  *
@@ -72,12 +87,31 @@ export class StorageTransaction {
72
87
  // frames, so a request-handler timeout that already fired would abort the commit of a
73
88
  // handler that succeeded. Hence a fresh timeout context, which also provides the time bound.
74
89
  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
90
  }
77
91
  catch (error) {
78
92
  this.#state = 'failed';
79
- throw error;
93
+ // Callbacks are handed an `Error`, so a backend that rejects with anything else gets wrapped.
94
+ const commitError = error instanceof Error ? error : new Error(String(error), { cause: error });
95
+ // A callback that throws here propagates instead: it was handed the failure and decided what
96
+ // the request should fail with.
97
+ await this.#runAfterCommitCallbacks(commitError);
98
+ throw commitError;
80
99
  }
100
+ this.#state = 'committed';
101
+ try {
102
+ await this.#runAfterCommitCallbacks();
103
+ }
104
+ catch (error) {
105
+ throw error instanceof NonRetryableError ? error : new AfterCommitError(error);
106
+ }
107
+ }
108
+ /** Fresh timeout context for the same reason as the flush. */
109
+ async #runAfterCommitCallbacks(error) {
110
+ await timeoutStorage.exit(async () => {
111
+ for (const callback of this.#afterCommitCallbacks) {
112
+ await callback(error);
113
+ }
114
+ });
81
115
  }
82
116
  async flush() {
83
117
  // Each participating frontend replays all of its buffered entries in one call. Frontends are
@@ -112,9 +146,10 @@ export class StorageTransaction {
112
146
  this.#state = 'rolledBack';
113
147
  }
114
148
  /**
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.
149
+ * Releases the journal, the write-time snapshots it holds and any registered commit callbacks. Must
150
+ * be called for *every* terminal state, `failed` included. Idempotent, never throws, and does not
151
+ * change `state`. Any {@link StorageTransactionView} of this transaction is only valid until this
152
+ * is called.
118
153
  */
119
154
  dispose() {
120
155
  if (this.#disposed) {
@@ -134,6 +169,7 @@ export class StorageTransaction {
134
169
  }
135
170
  this.#disposed = true;
136
171
  this.journal.length = 0;
172
+ this.#afterCommitCallbacks.length = 0;
137
173
  }
138
174
  get datasetItems() {
139
175
  return this.journal.flatMap((entry) => entry.type === 'dataset' ? entry.items.map((item) => ({ item, datasetId: entry.storageId })) : []);