@crawlee/basic 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,7 +1,8 @@
1
- import type { AddRequestsBatchedOptions, AddRequestsBatchedResult, ConcurrencySystemOptions, Configuration, CrawleeLogger, CrawlingContext, DatasetExportOptions, EnqueueLinksOptions, EventManager, FinalStatistics, GetUserDataFromRequest, IConcurrencySystem, IProxyConfiguration, IRequestLoader, IRequestManager, IStatistics, Request, RequestsLike, RouterHandler, RouterRoutes, SkippedRequestCallback, Source, StatisticState, StorageIdentifier, TaskLoopPredicates, TypedRequestsLike } from '@crawlee/core';
1
+ import type { AddRequestsBatchedOptions, AddRequestsBatchedResult, ConcurrencySystemOptions, Configuration, CrawleeLogger, CrawlingContext, DatasetExportOptions, EnqueueLinksOptions, EventManager, FinalStatistics, GetUserDataFromRequest, IConcurrencySystem, IProxyConfiguration, IRequestLoader, IRequestManager, IStatistics, Request, RequestsLike, RouterHandler, RouterRoutes, SkippedRequestCallback, Source, StatisticState, StorageIdentifier, StorageWritePolicy, TaskLoopPredicates, TypedRequestsLike } from '@crawlee/core';
2
2
  import { ConcurrencySystem, ContextPipeline, Dataset, RequestQueue } from '@crawlee/core';
3
3
  import type { Awaitable, BaseHttpClient, BatchAddRequestsResult, Dictionary, ISession, ISessionPool, ProxyInfo, SetStatusMessageOptions, StorageBackend } from '@crawlee/types';
4
4
  import { RobotsTxtFile } from '@crawlee/utils';
5
+ import { type BasePredicate } from 'ow';
5
6
  import type { ReadonlyDeep, SetRequired } from 'type-fest';
6
7
  import { TimeoutError } from '@apify/timeout';
7
8
  export interface BasicCrawlingContext<UserData extends Dictionary = Dictionary> extends CrawlingContext<UserData> {
@@ -314,6 +315,20 @@ export interface BasicCrawlerOptions<Context extends CrawlingContext = CrawlingC
314
315
  *
315
316
  */
316
317
  id?: string;
318
+ /**
319
+ * Makes the storage writes performed while handling a request atomic with respect to the request
320
+ * succeeding: they are recorded in a {@link StorageTransaction} spanning the whole request
321
+ * lifecycle and only applied when the request handler succeeds, so a thrown handler leaves no partial
322
+ * writes behind and a retry does not double-write. Reads within the handler see its own writes.
323
+ *
324
+ * `false` disables the mechanism entirely; an object overrides the per-storage-type
325
+ * {@link StorageWritePolicy} (e.g. `{ requestQueue: 'deferred' }` for all-or-nothing enqueues).
326
+ * {@link withDirectStorageAccess} is the per-call-site escape hatch; `useState()` is deliberately
327
+ * *not* transactional.
328
+ *
329
+ * @default true
330
+ */
331
+ transactionalStorage?: boolean | Partial<StorageWritePolicy>;
317
332
  /**
318
333
  * An array of HTTP response [Status Codes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) to be excluded from error consideration.
319
334
  * By default, status codes >= 500 trigger errors.
@@ -538,6 +553,10 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
538
553
  protected readonly httpClient: BaseHttpClient;
539
554
  protected readonly retryOnBlocked: boolean;
540
555
  private respectRobotsTxtFile;
556
+ /** Whether `runInStorageTransaction()` opens a transaction at all. */
557
+ private readonly transactionalStorageEnabled;
558
+ /** The resolved per-storage-type write policy overrides forwarded to each request's transaction. */
559
+ private readonly storageWritePolicy;
541
560
  protected readonly onSkippedRequest?: SkippedRequestCallback;
542
561
  private _closeEvents?;
543
562
  private loggedPerRun;
@@ -546,41 +565,41 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
546
565
  private readonly contextPipelineOptions;
547
566
  protected static optionsShape: {
548
567
  // @ts-ignore optional peer dependency or compatibility with es2022
549
- contextPipelineBuilder: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
568
+ contextPipelineBuilder: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
550
569
  // @ts-ignore optional peer dependency or compatibility with es2022
551
- extendContext: import("ow").Predicate<Function> & import("ow").BasePredicate<Function | undefined>;
570
+ extendContext: import("ow").Predicate<Function> & BasePredicate<Function | undefined>;
552
571
  // @ts-ignore optional peer dependency or compatibility with es2022
553
- requestList: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
572
+ requestList: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
554
573
  // @ts-ignore optional peer dependency or compatibility with es2022
555
- requestQueue: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
574
+ requestQueue: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
556
575
  // @ts-ignore optional peer dependency or compatibility with es2022
557
- requestHandler: import("ow").Predicate<Function> & import("ow").BasePredicate<Function | undefined>;
576
+ requestHandler: import("ow").Predicate<Function> & BasePredicate<Function | undefined>;
558
577
  // @ts-ignore optional peer dependency or compatibility with es2022
559
- requestHandlerTimeoutSecs: import("ow").NumberPredicate & import("ow").BasePredicate<number | undefined>;
578
+ requestHandlerTimeoutSecs: import("ow").NumberPredicate & BasePredicate<number | undefined>;
560
579
  // @ts-ignore optional peer dependency or compatibility with es2022
561
- errorHandler: import("ow").Predicate<Function> & import("ow").BasePredicate<Function | undefined>;
580
+ errorHandler: import("ow").Predicate<Function> & BasePredicate<Function | undefined>;
562
581
  // @ts-ignore optional peer dependency or compatibility with es2022
563
- failedRequestHandler: import("ow").Predicate<Function> & import("ow").BasePredicate<Function | undefined>;
582
+ failedRequestHandler: import("ow").Predicate<Function> & BasePredicate<Function | undefined>;
564
583
  // @ts-ignore optional peer dependency or compatibility with es2022
565
- maxRequestRetries: import("ow").NumberPredicate & import("ow").BasePredicate<number | undefined>;
584
+ maxRequestRetries: import("ow").NumberPredicate & BasePredicate<number | undefined>;
566
585
  // @ts-ignore optional peer dependency or compatibility with es2022
567
- sameDomainDelaySecs: import("ow").NumberPredicate & import("ow").BasePredicate<number | undefined>;
586
+ sameDomainDelaySecs: import("ow").NumberPredicate & BasePredicate<number | undefined>;
568
587
  // @ts-ignore optional peer dependency or compatibility with es2022
569
- maxRequestsPerCrawl: import("ow").NumberPredicate & import("ow").BasePredicate<number | undefined>;
588
+ maxRequestsPerCrawl: import("ow").NumberPredicate & BasePredicate<number | undefined>;
570
589
  // @ts-ignore optional peer dependency or compatibility with es2022
571
- maxCrawlDepth: import("ow").NumberPredicate & import("ow").BasePredicate<number | undefined>;
590
+ maxCrawlDepth: import("ow").NumberPredicate & BasePredicate<number | undefined>;
572
591
  // @ts-ignore optional peer dependency or compatibility with es2022
573
- taskLoopOptions: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
592
+ taskLoopOptions: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
574
593
  // @ts-ignore optional peer dependency or compatibility with es2022
575
- concurrencySystem: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
594
+ concurrencySystem: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
576
595
  // @ts-ignore optional peer dependency or compatibility with es2022
577
- sessionPool: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
596
+ sessionPool: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
578
597
  // @ts-ignore optional peer dependency or compatibility with es2022
579
- proxyConfiguration: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
598
+ proxyConfiguration: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
580
599
  // @ts-ignore optional peer dependency or compatibility with es2022
581
- statusMessageLoggingInterval: import("ow").NumberPredicate & import("ow").BasePredicate<number | undefined>;
600
+ statusMessageLoggingInterval: import("ow").NumberPredicate & BasePredicate<number | undefined>;
582
601
  // @ts-ignore optional peer dependency or compatibility with es2022
583
- statusMessageCallback: import("ow").Predicate<Function> & import("ow").BasePredicate<Function | undefined>;
602
+ statusMessageCallback: import("ow").Predicate<Function> & BasePredicate<Function | undefined>;
584
603
  // @ts-ignore optional peer dependency or compatibility with es2022
585
604
  additionalHttpErrorStatusCodes: import("ow").ArrayPredicate<number>;
586
605
  // @ts-ignore optional peer dependency or compatibility with es2022
@@ -588,33 +607,34 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
588
607
  // @ts-ignore optional peer dependency or compatibility with es2022
589
608
  blockedStatusCodes: import("ow").ArrayPredicate<number>;
590
609
  // @ts-ignore optional peer dependency or compatibility with es2022
591
- retryOnBlocked: import("ow").BooleanPredicate & import("ow").BasePredicate<boolean | undefined>;
610
+ retryOnBlocked: import("ow").BooleanPredicate & BasePredicate<boolean | undefined>;
592
611
  // @ts-ignore optional peer dependency or compatibility with es2022
593
612
  respectRobotsTxtFile: import("ow").AnyPredicate<boolean | object>;
613
+ transactionalStorage: BasePredicate<boolean | Partial<StorageWritePolicy> | undefined>;
594
614
  // @ts-ignore optional peer dependency or compatibility with es2022
595
- onSkippedRequest: import("ow").Predicate<Function> & import("ow").BasePredicate<Function | undefined>;
615
+ onSkippedRequest: import("ow").Predicate<Function> & BasePredicate<Function | undefined>;
596
616
  // @ts-ignore optional peer dependency or compatibility with es2022
597
- httpClient: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
617
+ httpClient: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
598
618
  // @ts-ignore optional peer dependency or compatibility with es2022
599
- configuration: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
619
+ configuration: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
600
620
  // @ts-ignore optional peer dependency or compatibility with es2022
601
- storageBackend: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
621
+ storageBackend: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
602
622
  // @ts-ignore optional peer dependency or compatibility with es2022
603
- eventManager: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
623
+ eventManager: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
604
624
  // @ts-ignore optional peer dependency or compatibility with es2022
605
- logger: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
625
+ logger: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
606
626
  // @ts-ignore optional peer dependency or compatibility with es2022
607
- minConcurrency: import("ow").NumberPredicate & import("ow").BasePredicate<number | undefined>;
627
+ minConcurrency: import("ow").NumberPredicate & BasePredicate<number | undefined>;
608
628
  // @ts-ignore optional peer dependency or compatibility with es2022
609
- maxConcurrency: import("ow").NumberPredicate & import("ow").BasePredicate<number | undefined>;
629
+ maxConcurrency: import("ow").NumberPredicate & BasePredicate<number | undefined>;
610
630
  // @ts-ignore optional peer dependency or compatibility with es2022
611
- maxRequestsPerMinute: import("ow").NumberPredicate & import("ow").BasePredicate<number | undefined>;
631
+ maxRequestsPerMinute: import("ow").NumberPredicate & BasePredicate<number | undefined>;
612
632
  // @ts-ignore optional peer dependency or compatibility with es2022
613
- keepAlive: import("ow").BooleanPredicate & import("ow").BasePredicate<boolean | undefined>;
633
+ keepAlive: import("ow").BooleanPredicate & BasePredicate<boolean | undefined>;
614
634
  // @ts-ignore optional peer dependency or compatibility with es2022
615
- statistics: import("ow").ObjectPredicate<object> & import("ow").BasePredicate<object | undefined>;
635
+ statistics: import("ow").ObjectPredicate<object> & BasePredicate<object | undefined>;
616
636
  // @ts-ignore optional peer dependency or compatibility with es2022
617
- id: import("ow").StringPredicate & import("ow").BasePredicate<string | undefined>;
637
+ id: import("ow").StringPredicate & BasePredicate<string | undefined>;
618
638
  };
619
639
  /**
620
640
  * All `BasicCrawler` parameters are passed via an options object.
@@ -801,6 +821,12 @@ export declare class BasicCrawler<Context extends CrawlingContext = CrawlingCont
801
821
  */
802
822
  private getRouteTimeoutMillis;
803
823
  protected runRequestHandler(crawlingContext: ExtendedContext): Promise<void>;
824
+ /**
825
+ * Runs `callback` inside a {@link StorageTransaction}, unless transactional storage is disabled.
826
+ * Deliberately does **not** commit on return - `handleRequest` swallows request handler failures, so
827
+ * a normal return says nothing about success. `handleRequest` owns the outcome.
828
+ */
829
+ private runInStorageTransaction;
804
830
  /**
805
831
  * Handles blocked request
806
832
  */
@@ -1,6 +1,6 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
- import { AutoscaledPool, bindMethodsToServiceLocator, BLOCKED_STATUS_CODES, ConcurrencySystem, ContextPipeline, ContextPipelineCleanupError, ContextPipelineInitializationError, ContextPipelineInterruptedError, CriticalError, Dataset, enqueueLinks, EnqueueStrategy, getObjectType, KeyValueStore, log, LogLevel, mergeCookies, MissingSessionError, NavigationSkippedError, NonRetryableError, OwnedOrInjected, purgeDefaultStorages, RequestHandlerError, RequestManagerTandem, RequestQueue, RequestState, RetryRequestError, Router, ServiceLocator, serviceLocator, Session, SessionError, SessionPool, Statistics, validateUserData, validators, } from '@crawlee/core';
3
+ import { AutoscaledPool, bindMethodsToServiceLocator, BLOCKED_STATUS_CODES, ConcurrencySystem, ContextPipeline, ContextPipelineCleanupError, ContextPipelineInitializationError, ContextPipelineInterruptedError, createStorageTransaction, CriticalError, currentStorageTransaction, Dataset, enqueueLinks, EnqueueStrategy, getObjectType, KeyValueStore, log, LogLevel, mergeCookies, MissingSessionError, NavigationSkippedError, NonRetryableError, OwnedOrInjected, purgeDefaultStorages, RequestHandlerError, RequestManagerTandem, RequestQueue, RequestState, RetryRequestError, Router, ServiceLocator, serviceLocator, Session, SessionError, SessionPool, Statistics, validateUserData, validators, withDirectStorageAccess, } from '@crawlee/core';
4
4
  import { FetchHttpClient } from '@crawlee/http-client';
5
5
  import { isAsyncIterable, isIterable, RobotsTxtFile, ROTATE_PROXY_ERRORS } from '@crawlee/utils';
6
6
  import { stringify } from 'csv-stringify/sync';
@@ -186,6 +186,10 @@ export class BasicCrawler {
186
186
  httpClient;
187
187
  retryOnBlocked;
188
188
  respectRobotsTxtFile;
189
+ /** Whether `runInStorageTransaction()` opens a transaction at all. */
190
+ transactionalStorageEnabled;
191
+ /** The resolved per-storage-type write policy overrides forwarded to each request's transaction. */
192
+ storageWritePolicy;
189
193
  onSkippedRequest;
190
194
  _closeEvents;
191
195
  loggedPerRun = new Set();
@@ -219,6 +223,9 @@ export class BasicCrawler {
219
223
  blockedStatusCodes: ow.optional.array.ofType(ow.number),
220
224
  retryOnBlocked: ow.optional.boolean,
221
225
  respectRobotsTxtFile: ow.optional.any(ow.boolean, ow.object),
226
+ transactionalStorage: ow.optional.any(ow.boolean, ow.object.exactShape({
227
+ requestQueue: ow.optional.string.oneOf(['deferred', 'writeThrough']),
228
+ })),
222
229
  onSkippedRequest: ow.optional.function,
223
230
  httpClient: ow.optional.object,
224
231
  configuration: ow.optional.object,
@@ -246,7 +253,7 @@ export class BasicCrawler {
246
253
  // Service locator options
247
254
  configuration, storageBackend, eventManager, logger,
248
255
  // AutoscaledPool shorthands
249
- minConcurrency, maxConcurrency, maxRequestsPerMinute, blockedStatusCodes: blockedStatusCodesInput, retryOnBlocked = false, respectRobotsTxtFile = false, onSkippedRequest, requestHandler, requestHandlerTimeoutSecs, errorHandler, failedRequestHandler, statusMessageLoggingInterval = 10, statusMessageCallback, statistics, httpClient, id, } = options;
256
+ minConcurrency, maxConcurrency, maxRequestsPerMinute, blockedStatusCodes: blockedStatusCodesInput, retryOnBlocked = false, respectRobotsTxtFile = false, transactionalStorage, onSkippedRequest, requestHandler, requestHandlerTimeoutSecs, errorHandler, failedRequestHandler, statusMessageLoggingInterval = 10, statusMessageCallback, statistics, httpClient, id, } = options;
250
257
  // All concurrency configuration lives on the `ConcurrencySystem`, so the shortcuts have nowhere to go once
251
258
  // one is supplied - and silently dropping a `maxConcurrency` the user asked for is how crawls end up
252
259
  // hammering a site.
@@ -320,6 +327,10 @@ export class BasicCrawler {
320
327
  }
321
328
  this.retryOnBlocked = retryOnBlocked;
322
329
  this.respectRobotsTxtFile = respectRobotsTxtFile;
330
+ // The cast undoes ow's assertion signature, which mangles `boolean | object` unions.
331
+ const transactionalStorageOption = transactionalStorage;
332
+ this.transactionalStorageEnabled = transactionalStorageOption !== false;
333
+ this.storageWritePolicy = typeof transactionalStorageOption === 'object' ? transactionalStorageOption : {};
323
334
  this.onSkippedRequest = onSkippedRequest;
324
335
  // allow at least 5min for internal timeouts
325
336
  this.internalTimeoutMillis =
@@ -376,12 +387,15 @@ export class BasicCrawler {
376
387
  this.stats.startJob(request.id || request.uniqueKey);
377
388
  const crawlingContext = { request };
378
389
  try {
390
+ // The transaction spans the whole pipeline call, covering the navigation hooks
391
+ // and `extendContext` too; `handleRequest` drives its outcome explicitly.
392
+ await this.runInStorageTransaction(async () =>
379
393
  // Navigation, the navigation hooks and the request handler are timed individually, but the
380
394
  // phases between them are not, so a request could still get stuck indefinitely. This is the
381
395
  // catch-all for that - see `raceWithTimeout` for why it is a bare timer, not a timeout frame.
382
396
  await this.withRequestTimeout(crawlingContext, this.basicContextPipeline
383
397
  .chain(this.contextPipeline)
384
- .call(crawlingContext, (ctx) => this.handleRequest(ctx, source, request)));
398
+ .call(crawlingContext, (ctx) => this.handleRequest(ctx, source, request))));
385
399
  }
386
400
  catch (error) {
387
401
  // ContextPipelineInterruptedError means the request was intentionally skipped
@@ -944,14 +958,18 @@ export class BasicCrawler {
944
958
  return Math.min(limit, explicitLimit ?? Infinity);
945
959
  }
946
960
  async handleSkippedRequest(options) {
947
- if (options.reason === 'limit') {
948
- this.logOncePerRun('maxRequestsPerCrawl', 'The number of requests enqueued by the crawler reached the maxRequestsPerCrawl limit of ' +
949
- `${this.maxRequestsPerCrawl} requests and no further requests will be added.`);
950
- }
951
- if (options.reason === 'depth') {
952
- this.logOncePerRun('maxCrawlDepth', `The crawler reached the maxCrawlDepth limit of ${this.maxCrawlDepth} and no further requests will be enqueued.`);
953
- }
954
- await this.onSkippedRequest?.(options);
961
+ // A skipped request is a *successful* outcome, but the interrupt still unwinds through the
962
+ // transaction scope, which rolls back - so the skip bookkeeping must write directly.
963
+ await withDirectStorageAccess(async () => {
964
+ if (options.reason === 'limit') {
965
+ this.logOncePerRun('maxRequestsPerCrawl', 'The number of requests enqueued by the crawler reached the maxRequestsPerCrawl limit of ' +
966
+ `${this.maxRequestsPerCrawl} requests and no further requests will be added.`);
967
+ }
968
+ if (options.reason === 'depth') {
969
+ this.logOncePerRun('maxCrawlDepth', `The crawler reached the maxCrawlDepth limit of ${this.maxCrawlDepth} and no further requests will be enqueued.`);
970
+ }
971
+ await this.onSkippedRequest?.(options);
972
+ });
955
973
  }
956
974
  logOncePerRun(key, message) {
957
975
  if (!this.loggedPerRun.has(key)) {
@@ -1168,6 +1186,40 @@ export class BasicCrawler {
1168
1186
  const timeoutMillis = this.resolveRequestHandlerTimeoutMillis(crawlingContext.request.label);
1169
1187
  await addTimeoutToPromise(async () => this.requestHandler(crawlingContext), timeoutMillis, `requestHandler timed out after ${timeoutMillis / 1000} seconds (${crawlingContext.request.id}).`);
1170
1188
  }
1189
+ /**
1190
+ * Runs `callback` inside a {@link StorageTransaction}, unless transactional storage is disabled.
1191
+ * Deliberately does **not** commit on return - `handleRequest` swallows request handler failures, so
1192
+ * a normal return says nothing about success. `handleRequest` owns the outcome.
1193
+ */
1194
+ async runInStorageTransaction(callback) {
1195
+ if (!this.transactionalStorageEnabled) {
1196
+ return callback();
1197
+ }
1198
+ const transaction = createStorageTransaction({
1199
+ policy: this.storageWritePolicy,
1200
+ commitTimeoutMillis: this.internalTimeoutMillis,
1201
+ });
1202
+ let threw = true;
1203
+ try {
1204
+ const result = await transaction.run(callback);
1205
+ threw = false;
1206
+ return result;
1207
+ }
1208
+ finally {
1209
+ if (transaction.state === 'open') {
1210
+ // `handleRequest` commits or rolls back on every normal path, so an open transaction on
1211
+ // a normal return is a wiring bug; on a propagating throw (a pipeline-level failure) it is
1212
+ // expected. Either way, discard the unvalidated writes; only the former is worth flagging.
1213
+ if (!threw) {
1214
+ this.log.error('Internal error: a storage transaction was still open after the request pipeline ' +
1215
+ 'returned normally. Its writes are being discarded. Please report this.');
1216
+ }
1217
+ transaction.rollback();
1218
+ }
1219
+ // Unconditional: `failed` is a terminal state that the branch above never reaches.
1220
+ transaction.dispose();
1221
+ }
1222
+ }
1171
1223
  /**
1172
1224
  * Handles blocked request
1173
1225
  */
@@ -1281,10 +1333,15 @@ export class BasicCrawler {
1281
1333
  return;
1282
1334
  }
1283
1335
  const statisticsId = request.id || request.uniqueKey;
1336
+ // Opened by `runInStorageTransaction`; absent when disabled or when the subclass opens its own.
1337
+ const transaction = currentStorageTransaction();
1284
1338
  let isRequestLocked = true;
1285
1339
  try {
1286
1340
  request.state = RequestState.REQUEST_HANDLER;
1287
1341
  await this.runRequestHandler(crawlingContext);
1342
+ // Commit *before* marking the request as handled, so a commit failure fails the request and
1343
+ // it is retried. This also closes the transaction, so everything below passes through.
1344
+ await transaction?.commit();
1288
1345
  await this.timeoutAndRetry(async () => requestSource.markRequestAsHandled(request), this.internalTimeoutMillis, `Marking request ${request.url} (${request.id}) as handled timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
1289
1346
  isRequestLocked = false; // markRequestAsHandled succeeded and unlocked the request
1290
1347
  this.stats.finishJob(statisticsId, request.retryCount);
@@ -1293,6 +1350,9 @@ export class BasicCrawler {
1293
1350
  crawlingContext.session.markGood();
1294
1351
  }
1295
1352
  catch (rawError) {
1353
+ // Roll back *before* any error handler runs - error handlers write to real storage precisely
1354
+ // because the transaction is already closed. A no-op when the commit above succeeded.
1355
+ transaction?.rollback();
1296
1356
  const err = this.unwrapError(rawError);
1297
1357
  try {
1298
1358
  request.state = RequestState.ERROR_HANDLER;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/basic",
3
- "version": "4.0.0-beta.103",
3
+ "version": "4.0.0-beta.105",
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"
@@ -42,10 +42,10 @@
42
42
  "@apify/datastructures": "^2.0.0",
43
43
  "@apify/timeout": "^0.4.4",
44
44
  "@apify/utilities": "^2.15.5",
45
- "@crawlee/core": "4.0.0-beta.103",
46
- "@crawlee/http-client": "4.0.0-beta.103",
47
- "@crawlee/types": "4.0.0-beta.103",
48
- "@crawlee/utils": "4.0.0-beta.103",
45
+ "@crawlee/core": "4.0.0-beta.105",
46
+ "@crawlee/http-client": "4.0.0-beta.105",
47
+ "@crawlee/types": "4.0.0-beta.105",
48
+ "@crawlee/utils": "4.0.0-beta.105",
49
49
  "csv-stringify": "^6.5.2",
50
50
  "ow": "^2.0.0",
51
51
  "tldts": "^7.0.6",
@@ -53,7 +53,7 @@
53
53
  "type-fest": "^4.41.0"
54
54
  },
55
55
  "optionalDependencies": {
56
- "@crawlee/impit-client": "^4.0.0-beta.103"
56
+ "@crawlee/impit-client": "^4.0.0-beta.105"
57
57
  },
58
58
  "lerna": {
59
59
  "command": {
@@ -62,5 +62,5 @@
62
62
  }
63
63
  }
64
64
  },
65
- "gitHead": "fe5d0ae11067683e27a2d17b4edd226ccf112cf5"
65
+ "gitHead": "26073a822c7699ac487931383a39192bc3daae7a"
66
66
  }