@crawlee/core 4.0.0-beta.11 → 4.0.0-beta.111

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.
Files changed (271) hide show
  1. package/README.md +17 -13
  2. package/autoscaling/autoscaled_pool.d.ts +67 -172
  3. package/autoscaling/autoscaled_pool.js +165 -320
  4. package/autoscaling/client_load_signal.d.ts +55 -0
  5. package/autoscaling/client_load_signal.js +73 -0
  6. package/autoscaling/concurrency_system.d.ts +268 -0
  7. package/autoscaling/concurrency_system.js +351 -0
  8. package/autoscaling/cpu_load_signal.d.ts +43 -0
  9. package/autoscaling/cpu_load_signal.js +46 -0
  10. package/autoscaling/event_loop_load_signal.d.ts +51 -0
  11. package/autoscaling/event_loop_load_signal.js +60 -0
  12. package/autoscaling/index.d.ts +6 -1
  13. package/autoscaling/index.js +6 -1
  14. package/autoscaling/load_signal.d.ts +99 -0
  15. package/autoscaling/load_signal.js +104 -0
  16. package/autoscaling/memory_load_signal.d.ts +47 -0
  17. package/autoscaling/memory_load_signal.js +105 -0
  18. package/autoscaling/snapshotter.d.ts +58 -163
  19. package/autoscaling/snapshotter.js +45 -263
  20. package/autoscaling/system_status.d.ts +62 -84
  21. package/autoscaling/system_status.js +92 -122
  22. package/autoscaling/weighted_avg.d.ts +5 -0
  23. package/autoscaling/weighted_avg.js +14 -0
  24. package/byte_utils.d.ts +17 -0
  25. package/byte_utils.js +42 -0
  26. package/configuration.d.ts +96 -223
  27. package/configuration.js +170 -222
  28. package/cookie_utils.d.ts +4 -3
  29. package/cookie_utils.js +22 -13
  30. package/crawlers/context_pipeline.d.ts +10 -1
  31. package/crawlers/context_pipeline.js +31 -8
  32. package/crawlers/crawler_commons.d.ts +90 -83
  33. package/crawlers/crawler_commons.js +1 -116
  34. package/crawlers/error_snapshotter.d.ts +1 -4
  35. package/crawlers/error_snapshotter.js +3 -6
  36. package/crawlers/error_tracker.d.ts +0 -1
  37. package/crawlers/error_tracker.js +0 -1
  38. package/crawlers/index.d.ts +1 -3
  39. package/crawlers/index.js +0 -3
  40. package/crawlers/internals/types.d.ts +0 -1
  41. package/crawlers/internals/types.js +0 -1
  42. package/crawlers/statistics.d.ts +91 -41
  43. package/crawlers/statistics.js +83 -72
  44. package/debug.d.ts +36 -0
  45. package/debug.js +70 -0
  46. package/enqueue_links/enqueue_links.d.ts +59 -68
  47. package/enqueue_links/enqueue_links.js +57 -62
  48. package/enqueue_links/index.d.ts +0 -1
  49. package/enqueue_links/index.js +0 -1
  50. package/enqueue_links/shared.d.ts +40 -27
  51. package/enqueue_links/shared.js +90 -68
  52. package/errors.d.ts +41 -4
  53. package/errors.js +50 -5
  54. package/events/event_manager.d.ts +34 -8
  55. package/events/event_manager.js +8 -10
  56. package/events/index.d.ts +0 -1
  57. package/events/index.js +0 -1
  58. package/events/local_event_manager.d.ts +15 -3
  59. package/events/local_event_manager.js +37 -11
  60. package/index.d.ts +6 -4
  61. package/index.js +5 -3
  62. package/iterables.d.ts +79 -0
  63. package/iterables.js +134 -0
  64. package/log.d.ts +82 -3
  65. package/log.js +106 -1
  66. package/memory-storage/consts.d.ts +4 -0
  67. package/memory-storage/consts.js +4 -0
  68. package/memory-storage/index.d.ts +1 -0
  69. package/memory-storage/index.js +1 -0
  70. package/memory-storage/memory-storage.d.ts +42 -0
  71. package/memory-storage/memory-storage.js +136 -0
  72. package/memory-storage/resource-clients/common/base-client.d.ts +4 -0
  73. package/memory-storage/resource-clients/common/base-client.js +6 -0
  74. package/memory-storage/resource-clients/dataset.d.ts +40 -0
  75. package/memory-storage/resource-clients/dataset.js +114 -0
  76. package/memory-storage/resource-clients/key-value-store.d.ts +63 -0
  77. package/memory-storage/resource-clients/key-value-store.js +204 -0
  78. package/memory-storage/resource-clients/request-queue.d.ts +77 -0
  79. package/memory-storage/resource-clients/request-queue.js +422 -0
  80. package/memory-storage/utils.d.ts +16 -0
  81. package/memory-storage/utils.js +41 -0
  82. package/owned_or_injected.d.ts +58 -0
  83. package/owned_or_injected.js +98 -0
  84. package/package.json +13 -12
  85. package/proxy_configuration.d.ts +27 -152
  86. package/proxy_configuration.js +27 -179
  87. package/recoverable_state.d.ts +111 -0
  88. package/recoverable_state.js +143 -0
  89. package/request.d.ts +86 -17
  90. package/request.js +117 -41
  91. package/router.d.ts +193 -21
  92. package/router.js +188 -43
  93. package/serialization.d.ts +0 -1
  94. package/serialization.js +9 -11
  95. package/service_locator.d.ts +147 -0
  96. package/service_locator.js +244 -0
  97. package/session_pool/consts.d.ts +1 -2
  98. package/session_pool/consts.js +1 -2
  99. package/session_pool/errors.d.ts +0 -1
  100. package/session_pool/errors.js +0 -1
  101. package/session_pool/fingerprint.d.ts +9 -0
  102. package/session_pool/fingerprint.js +30 -0
  103. package/session_pool/index.d.ts +0 -2
  104. package/session_pool/index.js +0 -2
  105. package/session_pool/session.d.ts +37 -86
  106. package/session_pool/session.js +85 -138
  107. package/session_pool/session_pool.d.ts +77 -91
  108. package/session_pool/session_pool.js +159 -147
  109. package/storages/dataset.d.ts +114 -54
  110. package/storages/dataset.js +285 -144
  111. package/storages/index.d.ts +9 -8
  112. package/storages/index.js +7 -8
  113. package/storages/key_value_store.d.ts +185 -42
  114. package/storages/key_value_store.js +424 -151
  115. package/storages/key_value_store_codec.d.ts +32 -0
  116. package/storages/key_value_store_codec.js +113 -0
  117. package/storages/request_dedup_cache.d.ts +22 -0
  118. package/storages/request_dedup_cache.js +48 -0
  119. package/storages/request_list.d.ts +52 -116
  120. package/storages/request_list.js +158 -133
  121. package/storages/request_loader.d.ts +96 -0
  122. package/storages/request_loader.js +1 -0
  123. package/storages/request_manager.d.ts +33 -0
  124. package/storages/request_manager.js +1 -0
  125. package/storages/request_manager_tandem.d.ts +97 -0
  126. package/storages/request_manager_tandem.js +197 -0
  127. package/storages/request_queue.d.ts +290 -47
  128. package/storages/request_queue.js +825 -216
  129. package/storages/{sitemap_request_list.d.ts → sitemap_request_loader.d.ts} +37 -88
  130. package/storages/{sitemap_request_list.js → sitemap_request_loader.js} +136 -143
  131. package/storages/storage_instance_manager.d.ts +87 -0
  132. package/storages/storage_instance_manager.js +256 -0
  133. package/storages/storage_stats.d.ts +48 -0
  134. package/storages/storage_stats.js +29 -0
  135. package/storages/transaction.d.ts +252 -0
  136. package/storages/transaction.js +251 -0
  137. package/storages/utils.d.ts +54 -9
  138. package/storages/utils.js +64 -13
  139. package/system-info/cpu-info.d.ts +67 -0
  140. package/system-info/cpu-info.js +216 -0
  141. package/system-info/memory-info.d.ts +31 -0
  142. package/system-info/memory-info.js +115 -0
  143. package/system-info/ps-tree.d.ts +17 -0
  144. package/system-info/ps-tree.js +144 -0
  145. package/system-info/runtime.d.ts +14 -0
  146. package/system-info/runtime.js +80 -0
  147. package/typedefs.d.ts +0 -6
  148. package/typedefs.js +0 -1
  149. package/validators.d.ts +8 -1
  150. package/validators.js +10 -3
  151. package/autoscaling/autoscaled_pool.d.ts.map +0 -1
  152. package/autoscaling/autoscaled_pool.js.map +0 -1
  153. package/autoscaling/index.d.ts.map +0 -1
  154. package/autoscaling/index.js.map +0 -1
  155. package/autoscaling/snapshotter.d.ts.map +0 -1
  156. package/autoscaling/snapshotter.js.map +0 -1
  157. package/autoscaling/system_status.d.ts.map +0 -1
  158. package/autoscaling/system_status.js.map +0 -1
  159. package/configuration.d.ts.map +0 -1
  160. package/configuration.js.map +0 -1
  161. package/cookie_utils.d.ts.map +0 -1
  162. package/cookie_utils.js.map +0 -1
  163. package/crawlers/context_pipeline.d.ts.map +0 -1
  164. package/crawlers/context_pipeline.js.map +0 -1
  165. package/crawlers/crawler_commons.d.ts.map +0 -1
  166. package/crawlers/crawler_commons.js.map +0 -1
  167. package/crawlers/crawler_utils.d.ts +0 -10
  168. package/crawlers/crawler_utils.d.ts.map +0 -1
  169. package/crawlers/crawler_utils.js +0 -12
  170. package/crawlers/crawler_utils.js.map +0 -1
  171. package/crawlers/error_snapshotter.d.ts.map +0 -1
  172. package/crawlers/error_snapshotter.js.map +0 -1
  173. package/crawlers/error_tracker.d.ts.map +0 -1
  174. package/crawlers/error_tracker.js.map +0 -1
  175. package/crawlers/index.d.ts.map +0 -1
  176. package/crawlers/index.js.map +0 -1
  177. package/crawlers/internals/types.d.ts.map +0 -1
  178. package/crawlers/internals/types.js.map +0 -1
  179. package/crawlers/statistics.d.ts.map +0 -1
  180. package/crawlers/statistics.js.map +0 -1
  181. package/enqueue_links/enqueue_links.d.ts.map +0 -1
  182. package/enqueue_links/enqueue_links.js.map +0 -1
  183. package/enqueue_links/index.d.ts.map +0 -1
  184. package/enqueue_links/index.js.map +0 -1
  185. package/enqueue_links/shared.d.ts.map +0 -1
  186. package/enqueue_links/shared.js.map +0 -1
  187. package/errors.d.ts.map +0 -1
  188. package/errors.js.map +0 -1
  189. package/events/event_manager.d.ts.map +0 -1
  190. package/events/event_manager.js.map +0 -1
  191. package/events/index.d.ts.map +0 -1
  192. package/events/index.js.map +0 -1
  193. package/events/local_event_manager.d.ts.map +0 -1
  194. package/events/local_event_manager.js.map +0 -1
  195. package/http_clients/base-http-client.d.ts +0 -134
  196. package/http_clients/base-http-client.d.ts.map +0 -1
  197. package/http_clients/base-http-client.js +0 -33
  198. package/http_clients/base-http-client.js.map +0 -1
  199. package/http_clients/form-data-like.d.ts +0 -67
  200. package/http_clients/form-data-like.d.ts.map +0 -1
  201. package/http_clients/form-data-like.js +0 -5
  202. package/http_clients/form-data-like.js.map +0 -1
  203. package/http_clients/got-scraping-http-client.d.ts +0 -15
  204. package/http_clients/got-scraping-http-client.d.ts.map +0 -1
  205. package/http_clients/got-scraping-http-client.js +0 -69
  206. package/http_clients/got-scraping-http-client.js.map +0 -1
  207. package/http_clients/index.d.ts +0 -3
  208. package/http_clients/index.d.ts.map +0 -1
  209. package/http_clients/index.js +0 -3
  210. package/http_clients/index.js.map +0 -1
  211. package/index.d.ts.map +0 -1
  212. package/index.js.map +0 -1
  213. package/log.d.ts.map +0 -1
  214. package/log.js.map +0 -1
  215. package/proxy_configuration.d.ts.map +0 -1
  216. package/proxy_configuration.js.map +0 -1
  217. package/request.d.ts.map +0 -1
  218. package/request.js.map +0 -1
  219. package/router.d.ts.map +0 -1
  220. package/router.js.map +0 -1
  221. package/serialization.d.ts.map +0 -1
  222. package/serialization.js.map +0 -1
  223. package/session_pool/consts.d.ts.map +0 -1
  224. package/session_pool/consts.js.map +0 -1
  225. package/session_pool/errors.d.ts.map +0 -1
  226. package/session_pool/errors.js.map +0 -1
  227. package/session_pool/events.d.ts +0 -3
  228. package/session_pool/events.d.ts.map +0 -1
  229. package/session_pool/events.js +0 -3
  230. package/session_pool/events.js.map +0 -1
  231. package/session_pool/index.d.ts.map +0 -1
  232. package/session_pool/index.js.map +0 -1
  233. package/session_pool/session.d.ts.map +0 -1
  234. package/session_pool/session.js.map +0 -1
  235. package/session_pool/session_pool.d.ts.map +0 -1
  236. package/session_pool/session_pool.js.map +0 -1
  237. package/storages/access_checking.d.ts +0 -13
  238. package/storages/access_checking.d.ts.map +0 -1
  239. package/storages/access_checking.js +0 -14
  240. package/storages/access_checking.js.map +0 -1
  241. package/storages/dataset.d.ts.map +0 -1
  242. package/storages/dataset.js.map +0 -1
  243. package/storages/index.d.ts.map +0 -1
  244. package/storages/index.js.map +0 -1
  245. package/storages/key_value_store.d.ts.map +0 -1
  246. package/storages/key_value_store.js.map +0 -1
  247. package/storages/request_list.d.ts.map +0 -1
  248. package/storages/request_list.js.map +0 -1
  249. package/storages/request_provider.d.ts +0 -308
  250. package/storages/request_provider.d.ts.map +0 -1
  251. package/storages/request_provider.js +0 -555
  252. package/storages/request_provider.js.map +0 -1
  253. package/storages/request_queue.d.ts.map +0 -1
  254. package/storages/request_queue.js.map +0 -1
  255. package/storages/request_queue_v2.d.ts +0 -87
  256. package/storages/request_queue_v2.d.ts.map +0 -1
  257. package/storages/request_queue_v2.js +0 -438
  258. package/storages/request_queue_v2.js.map +0 -1
  259. package/storages/sitemap_request_list.d.ts.map +0 -1
  260. package/storages/sitemap_request_list.js.map +0 -1
  261. package/storages/storage_manager.d.ts +0 -58
  262. package/storages/storage_manager.d.ts.map +0 -1
  263. package/storages/storage_manager.js +0 -105
  264. package/storages/storage_manager.js.map +0 -1
  265. package/storages/utils.d.ts.map +0 -1
  266. package/storages/utils.js.map +0 -1
  267. package/tsconfig.build.tsbuildinfo +0 -1
  268. package/typedefs.d.ts.map +0 -1
  269. package/typedefs.js.map +0 -1
  270. package/validators.d.ts.map +0 -1
  271. package/validators.js.map +0 -1
@@ -1,16 +1,24 @@
1
- import { setTimeout as sleep } from 'node:timers/promises';
2
- import { REQUEST_QUEUE_HEAD_MAX_LIMIT } from '@apify/consts';
1
+ import { inspect } from 'node:util';
2
+ import { isAsyncIterable, isIterable } from '@crawlee/utils/internal';
3
+ import { downloadListOfUrls, sleep } from '@crawlee/utils';
4
+ import ow from 'ow';
5
+ import { LruCache } from '@apify/datastructures';
6
+ import { tryCancel } from '@apify/timeout';
3
7
  import { Configuration } from '../configuration.js';
4
- import { checkStorageAccess } from './access_checking.js';
5
- import { RequestProvider } from './request_provider.js';
6
- import { API_PROCESSED_REQUESTS_DELAY_MILLIS, getRequestId, MAX_QUERIES_FOR_CONSISTENCY, QUERY_HEAD_BUFFER, QUERY_HEAD_MIN_LENGTH, STORAGE_CONSISTENCY_DELAY_MILLIS, } from './utils.js';
7
- const MAX_CACHED_REQUESTS = 1_000_000;
8
+ import { getObjectType } from '../debug.js';
9
+ import { chunkedAsyncIterable, peekableAsyncIterable } from '../iterables.js';
10
+ import { Request } from '../request.js';
11
+ import { serviceLocator } from '../service_locator.js';
12
+ import { activeStorageTransaction, rejectOperationInTransaction, withDirectStorageAccess } from './transaction.js';
13
+ import { StorageStatsTracker } from './storage_stats.js';
14
+ import { resolveStorageIdentifier } from './storage_instance_manager.js';
15
+ import { getRequestId, purgeDefaultStorages } from './utils.js';
16
+ import { RequestDeduplicationCache } from './request_dedup_cache.js';
8
17
  /**
9
- * This number must be large enough so that processing of all these requests cannot be done in
10
- * a time lower than expected maximum latency of DynamoDB, but low enough not to waste too much memory.
18
+ * The maximum number of requests cached locally to avoid redundant calls to the storage backend.
11
19
  * @internal
12
20
  */
13
- const RECENTLY_HANDLED_CACHE_SIZE = 1000;
21
+ const MAX_CACHED_REQUESTS = 2_000_000;
14
22
  /**
15
23
  * Represents a queue of URLs to crawl, which is used for deep crawling of websites
16
24
  * where you start with several URLs and then recursively
@@ -29,18 +37,6 @@ const RECENTLY_HANDLED_CACHE_SIZE = 1000;
29
37
  * Unlike {@link RequestList}, `RequestQueue` supports dynamic adding and removing of requests.
30
38
  * On the other hand, the queue is not optimized for operations that add or remove a large number of URLs in a batch.
31
39
  *
32
- * `RequestQueue` stores its data either on local disk or in the Apify Cloud,
33
- * depending on whether the `APIFY_LOCAL_STORAGE_DIR` or `APIFY_TOKEN` environment variable is set.
34
- *
35
- * If the `APIFY_LOCAL_STORAGE_DIR` environment variable is set, the queue data is stored in
36
- * that directory in an SQLite database file.
37
- *
38
- * If the `APIFY_TOKEN` environment variable is set but `APIFY_LOCAL_STORAGE_DIR` is not, the data is stored in the
39
- * [Apify Request Queue](https://docs.apify.com/storage/request-queue)
40
- * cloud storage. Note that you can force usage of the cloud storage also by passing the `forceCloud`
41
- * option to {@link RequestQueue.open} function,
42
- * even if the `APIFY_LOCAL_STORAGE_DIR` variable is set.
43
- *
44
40
  * **Example usage:**
45
41
  *
46
42
  * ```javascript
@@ -56,34 +52,552 @@ const RECENTLY_HANDLED_CACHE_SIZE = 1000;
56
52
  * await queue.addRequest({ url: 'http://example.com/foo/bar' }, { forefront: true });
57
53
  * ```
58
54
  * @category Sources
59
- *
60
- * @deprecated RequestQueue v1 is deprecated and will be removed in the future. Please use {@link RequestQueue} instead.
61
55
  */
62
- class RequestQueue extends RequestProvider {
63
- queryQueueHeadPromise = null;
64
- inProgress = new Set();
56
+ export class RequestQueue {
57
+ id;
58
+ name;
59
+ backend;
60
+ #proxyConfiguration;
61
+ log;
62
+ // kept as TS-private: request_queue tests read this cache directly
63
+ requestCache;
65
64
  /**
66
- * @internal
65
+ * Remembers the `requestId` of every request already submitted to the client — including background
66
+ * batches that `requestCache` skips — so overlapping URL sets aren't re-submitted.
67
+ * See {@link RequestDeduplicationCache} for why this is a separate, cheaper cache.
67
68
  */
68
- constructor(options, config = Configuration.getGlobalConfig()) {
69
- super({
70
- ...options,
71
- logPrefix: 'RequestQueue',
72
- recentlyHandledRequestsMaxSize: RECENTLY_HANDLED_CACHE_SIZE,
73
- requestCacheMaxSize: MAX_CACHED_REQUESTS,
74
- }, config);
69
+ #requestSeenCache;
70
+ #queuePausedForMigration = false;
71
+ // kept as TS-private: packages/core/test request-queue tests write this counter directly
72
+ inProgressRequestBatchCount = 0;
73
+ /**
74
+ * The largest expected request-processing time (in seconds) seen so far via
75
+ * {@link setExpectedRequestProcessingTimeSecs}. Used to ensure that value is only ever raised, never
76
+ * lowered, before being forwarded to the storage backend.
77
+ */
78
+ #expectedRequestProcessingSecs = 0;
79
+ #httpClient;
80
+ #events;
81
+ #statsTracker = new StorageStatsTracker({
82
+ writeCount: 0,
83
+ headItemReadCount: 0,
84
+ });
85
+ /**
86
+ * Backend-independent usage counters tracked for this request queue (write operations and
87
+ * queue-head reads issued to the underlying storage backend). Counted per backend call.
88
+ */
89
+ get stats() {
90
+ return this.#statsTracker.current;
75
91
  }
76
92
  /**
77
93
  * @internal
78
94
  */
79
- inProgressCount() {
80
- return this.inProgress.size;
95
+ constructor(options) {
96
+ this.id = options.metadata.id;
97
+ this.name = options.metadata.name;
98
+ this.#events = serviceLocator.getEventManager();
99
+ this.backend = options.backend;
100
+ this.#proxyConfiguration = options.proxyConfiguration;
101
+ this.requestCache = new LruCache({ maxLength: MAX_CACHED_REQUESTS });
102
+ this.#requestSeenCache = new RequestDeduplicationCache();
103
+ this.log = serviceLocator.getLogger().child({ prefix: `RequestQueue(${this.id}, ${this.name ?? 'no-name'})` });
104
+ this.#events.on("migrating" /* EventType.MIGRATING */, async () => {
105
+ this.#queuePausedForMigration = true;
106
+ });
107
+ }
108
+ /**
109
+ * Returns the total number of requests in the queue (i.e. pending + handled).
110
+ *
111
+ * Survives restarts and actor migrations.
112
+ */
113
+ async getTotalCount() {
114
+ const { totalRequestCount } = await this.getInfo();
115
+ return totalRequestCount;
116
+ }
117
+ /**
118
+ * Returns the total number of pending requests in the queue.
119
+ *
120
+ * Survives restarts and Actor migrations.
121
+ */
122
+ async getPendingCount() {
123
+ const { totalRequestCount, handledRequestCount } = await this.getInfo();
124
+ return totalRequestCount - handledRequestCount;
125
+ }
126
+ /**
127
+ * Adds a request to the queue.
128
+ *
129
+ * If a request with the same `uniqueKey` property is already present in the queue,
130
+ * it will not be updated. You can find out whether this happened from the resulting
131
+ * {@link QueueOperationInfo} object.
132
+ *
133
+ * To add multiple requests to the queue by extracting links from a webpage,
134
+ * see the {@link enqueueLinks} helper function.
135
+ *
136
+ * @param requestLike {@link Request} object or vanilla object with request data.
137
+ * Note that the function sets the `uniqueKey` and `id` fields to the passed Request.
138
+ * @param [options] Request queue operation options.
139
+ */
140
+ async addRequest(requestLike, options = {}) {
141
+ const transaction = activeStorageTransaction();
142
+ ow(requestLike, ow.object);
143
+ ow(options, ow.object.exactShape({
144
+ forefront: ow.optional.boolean,
145
+ }));
146
+ const { forefront = false } = options;
147
+ if ('requestsFromUrl' in requestLike) {
148
+ const requests = await this.fetchRequestsFromUrl(requestLike);
149
+ const processedRequests = await this.addFetchedRequests(requestLike, requests, options);
150
+ return { ...processedRequests[0], forefront };
151
+ }
152
+ ow(requestLike, ow.object.partialShape({
153
+ url: ow.string,
154
+ id: ow.undefined,
155
+ }));
156
+ const request = requestLike instanceof Request ? requestLike : new Request(requestLike);
157
+ if (transaction?.policy.requestQueue === 'deferred') {
158
+ return this.addRequestDeferred(transaction, request, forefront);
159
+ }
160
+ const cacheKey = getRequestId(request.uniqueKey);
161
+ const cachedInfo = this.requestCache.get(cacheKey);
162
+ if (cachedInfo) {
163
+ request.id = cachedInfo.id;
164
+ this.recordRequestJournalEntry(transaction, [request], forefront, true);
165
+ return {
166
+ wasAlreadyPresent: true,
167
+ // We may assume that if request is in local cache then also the information if the
168
+ // request was already handled is there because just one client should be using one queue.
169
+ wasAlreadyHandled: cachedInfo.isHandled,
170
+ requestId: cachedInfo.id,
171
+ uniqueKey: cachedInfo.uniqueKey,
172
+ forefront,
173
+ };
174
+ }
175
+ this.#statsTracker.add('writeCount');
176
+ const { processedRequests } = await this.backend.addBatchOfRequests([request], { forefront });
177
+ this.recordRequestJournalEntry(transaction, [request], forefront, true);
178
+ const queueOperationInfo = {
179
+ ...processedRequests[0],
180
+ uniqueKey: request.uniqueKey,
181
+ forefront,
182
+ };
183
+ this.cacheRequest(cacheKey, queueOperationInfo);
184
+ this.#requestSeenCache.add(cacheKey, request.id);
185
+ return queueOperationInfo;
186
+ }
187
+ /**
188
+ * Journals an addition for introspection only; these entries are never replayed. A no-op unless the
189
+ * transaction is open, so detached and outliving writers stay out of the journal.
190
+ */
191
+ recordRequestJournalEntry(transaction, requests, forefront, writeThrough) {
192
+ if (!transaction?.isActive || requests.length === 0)
193
+ return;
194
+ transaction.recordJournalEntry({
195
+ type: 'requestQueue',
196
+ participant: this,
197
+ requests: requests.map((request) => ({
198
+ url: request.url,
199
+ uniqueKey: request.uniqueKey,
200
+ label: request.label,
201
+ })),
202
+ forefront,
203
+ writeThrough,
204
+ });
205
+ }
206
+ /**
207
+ * The requests buffered by the given transaction for this queue, keyed by `uniqueKey` — a dedup
208
+ * index derived from the transaction journal.
209
+ */
210
+ bufferedRequests(transaction) {
211
+ const buffered = new Map();
212
+ // Only `deferred` records snapshots, so scanning the journal under `writeThrough` never finds any.
213
+ if (transaction.policy.requestQueue !== 'deferred')
214
+ return buffered;
215
+ for (const entry of transaction.journal) {
216
+ if (entry.type !== 'requestQueue' || entry.participant !== this)
217
+ continue;
218
+ for (const request of entry.requests) {
219
+ if (request.snapshot !== undefined)
220
+ buffered.set(request.uniqueKey, request.snapshot);
221
+ }
222
+ }
223
+ return buffered;
224
+ }
225
+ /**
226
+ * Adds a request under the `deferred` policy: journaled now, really added by the commit replay.
227
+ * A new request's `requestId` is the local `uniqueKey` hash and is **provisional** — never write it
228
+ * to `request.id` or the dedup caches. Dedup is cheapest-first: buffer, caches, then a backend probe.
229
+ */
230
+ async addRequestDeferred(transaction, request, forefront, buffered = this.bufferedRequests(transaction)) {
231
+ // This transaction's own buffered adds; the shared caches never see them (provisional ids).
232
+ if (buffered.has(request.uniqueKey)) {
233
+ this.recordRequestJournalEntry(transaction, [request], forefront, false);
234
+ return {
235
+ wasAlreadyPresent: true,
236
+ wasAlreadyHandled: false,
237
+ requestId: getRequestId(request.uniqueKey),
238
+ uniqueKey: request.uniqueKey,
239
+ forefront,
240
+ };
241
+ }
242
+ // The caches hold real backend ids. Only *writing* provisional ids to them would be wrong;
243
+ // reading saves a probe. Same lookup as the write-through path.
244
+ const cacheKey = getRequestId(request.uniqueKey);
245
+ const cachedInfo = this.requestCache.get(cacheKey);
246
+ const knownRequestId = cachedInfo?.id ?? this.#requestSeenCache.get(cacheKey);
247
+ if (knownRequestId) {
248
+ this.recordRequestJournalEntry(transaction, [request], forefront, false);
249
+ return {
250
+ wasAlreadyPresent: true,
251
+ // The dedup cache doesn't track the handled state; only the full record does.
252
+ wasAlreadyHandled: cachedInfo?.isHandled ?? false,
253
+ requestId: knownRequestId,
254
+ uniqueKey: request.uniqueKey,
255
+ forefront,
256
+ };
257
+ }
258
+ // The caches are bounded, so a miss is not proof of absence - probe for an accurate answer.
259
+ const existing = await this.backend.getRequest(request.uniqueKey);
260
+ if (existing) {
261
+ this.recordRequestJournalEntry(transaction, [request], forefront, false);
262
+ return {
263
+ wasAlreadyPresent: true,
264
+ wasAlreadyHandled: existing.handledAt != null,
265
+ requestId: existing.id,
266
+ uniqueKey: request.uniqueKey,
267
+ forefront,
268
+ };
269
+ }
270
+ // The entry below *is* the write, so a transaction closed during the probe must not receive it -
271
+ // pass through instead, per the closed-transaction rule. Under `deferred` that can land an
272
+ // addition a rollback would have discarded; dedup bounds that cost, silent loss is unbounded.
273
+ if (!transaction.isActive) {
274
+ return await this.addRequest(request, { forefront });
275
+ }
276
+ const snapshot = JSON.parse(JSON.stringify(request));
277
+ // Strip-list, not allow-list: every user-facing field flows through, including ones added to
278
+ // `Request` in the future. The exceptions are `id` and `handledAt`, the two backend-owned
279
+ // lifecycle fields.
280
+ delete snapshot.id;
281
+ delete snapshot.handledAt;
282
+ transaction.recordJournalEntry({
283
+ type: 'requestQueue',
284
+ participant: this,
285
+ requests: [{ url: request.url, uniqueKey: request.uniqueKey, label: request.label, snapshot }],
286
+ forefront,
287
+ writeThrough: false,
288
+ });
289
+ buffered.set(request.uniqueKey, snapshot);
290
+ return {
291
+ wasAlreadyPresent: false,
292
+ wasAlreadyHandled: false,
293
+ requestId: getRequestId(request.uniqueKey),
294
+ uniqueKey: request.uniqueKey,
295
+ forefront,
296
+ };
297
+ }
298
+ /** @internal */
299
+ async commitJournalEntries(entries) {
300
+ // Replay through `backend.addBatchOfRequests`, *not* the batched frontend wrapper - the wrapper
301
+ // resolves after the first chunk and sleeps between the rest, neither of which commit may
302
+ // inherit. One call per `forefront` flag; the order of forefront additions is arbitrary anyway.
303
+ for (const forefront of [false, true]) {
304
+ const requests = entries.flatMap((entry) => entry.type === 'requestQueue' && entry.forefront === forefront
305
+ ? // Requests without a snapshot were deduplicated or written through; nothing to replay.
306
+ entry.requests
307
+ .filter((journaled) => journaled.snapshot !== undefined)
308
+ .map((journaled) => new Request(journaled.snapshot))
309
+ : []);
310
+ if (requests.length === 0)
311
+ continue;
312
+ this.#statsTracker.add('writeCount');
313
+ const { processedRequests, unprocessedRequests } = await this.backend.addBatchOfRequests(requests, {
314
+ forefront,
315
+ });
316
+ // Only now, with the real backend-assigned ids, may the shared dedup caches be populated.
317
+ for (const processed of processedRequests) {
318
+ const cacheKey = getRequestId(processed.uniqueKey);
319
+ this.cacheRequest(cacheKey, { ...processed, forefront });
320
+ this.#requestSeenCache.add(cacheKey, processed.requestId);
321
+ }
322
+ if (unprocessedRequests.length > 0) {
323
+ // Warn and skip, rather than retry or fail. `unprocessedRequests` is what remains after
324
+ // the backend's own transient-error handling - a semantic rejection that retrying here
325
+ // would only re-poke. And failing the commit would let one malformed request hold the
326
+ // whole transaction hostage.
327
+ this.log.warning('Some requests were rejected by the request queue while committing a storage transaction and will be skipped. ' +
328
+ "This usually means the request data is malformed (e.g. an invalid 'userData' shape).", { unprocessedRequests });
329
+ }
330
+ }
331
+ }
332
+ /**
333
+ * Adds requests to the queue in batches of 25. This method will wait till all the requests are added
334
+ * to the queue before resolving. You should prefer using `queue.addRequestsBatched()` or `crawler.addRequests()`
335
+ * if you don't want to block the processing, as those methods will only wait for the initial 1000 requests,
336
+ * start processing right after that happens, and continue adding more in the background.
337
+ *
338
+ * If a request passed in is already present due to its `uniqueKey` property being the same,
339
+ * it will not be updated. You can find out whether this happened by finding the request in the resulting
340
+ * {@link BatchAddRequestsResult} object.
341
+ *
342
+ * @param requestsLike {@link Request} objects or vanilla objects with request data.
343
+ * Note that the function sets the `uniqueKey` and `id` fields to the passed requests if missing.
344
+ * @param [options] Request queue operation options.
345
+ */
346
+ async addRequests(requestsLike, options = {}) {
347
+ const transaction = activeStorageTransaction();
348
+ ow(requestsLike, ow.object
349
+ .is((value) => isIterable(value) || isAsyncIterable(value))
350
+ .message((value) => `Expected an iterable or async iterable, got ${getObjectType(value)}`));
351
+ ow(options, ow.object.exactShape({
352
+ forefront: ow.optional.boolean,
353
+ cache: ow.optional.boolean,
354
+ }));
355
+ const { forefront = false, cache = true } = options;
356
+ const uniqueKeyToCacheKey = new Map();
357
+ const getCachedRequestId = (uniqueKey) => {
358
+ const cached = uniqueKeyToCacheKey.get(uniqueKey);
359
+ if (cached)
360
+ return cached;
361
+ const newCacheKey = getRequestId(uniqueKey);
362
+ uniqueKeyToCacheKey.set(uniqueKey, newCacheKey);
363
+ return newCacheKey;
364
+ };
365
+ const results = {
366
+ processedRequests: [],
367
+ unprocessedRequests: [],
368
+ };
369
+ const requests = [];
370
+ for await (const requestLike of requestsLike) {
371
+ if (typeof requestLike === 'string') {
372
+ requests.push(new Request({ url: requestLike }));
373
+ }
374
+ else if ('requestsFromUrl' in requestLike) {
375
+ const fetchedRequests = await this.fetchRequestsFromUrl(requestLike);
376
+ await this.addFetchedRequests(requestLike, fetchedRequests, options);
377
+ }
378
+ else {
379
+ requests.push(requestLike instanceof Request ? requestLike : new Request(requestLike));
380
+ }
381
+ }
382
+ if (transaction?.policy.requestQueue === 'deferred') {
383
+ const buffered = this.bufferedRequests(transaction);
384
+ for (const request of requests) {
385
+ results.processedRequests.push(await this.addRequestDeferred(transaction, request, forefront, buffered));
386
+ }
387
+ return results;
388
+ }
389
+ this.recordRequestJournalEntry(transaction, requests, forefront, true);
390
+ const requestsToAdd = new Map();
391
+ for (const request of requests) {
392
+ const cacheKey = getCachedRequestId(request.uniqueKey);
393
+ // Prefer the full `requestCache` record; fall back to the dedup cache for background batches it skips.
394
+ const cachedInfo = this.requestCache.get(cacheKey);
395
+ const knownRequestId = cachedInfo?.id ?? this.#requestSeenCache.get(cacheKey);
396
+ if (knownRequestId) {
397
+ request.id = knownRequestId;
398
+ results.processedRequests.push({
399
+ wasAlreadyPresent: true,
400
+ // The dedup cache doesn't track the handled state; only the full record does.
401
+ wasAlreadyHandled: cachedInfo?.isHandled ?? false,
402
+ requestId: knownRequestId,
403
+ uniqueKey: request.uniqueKey,
404
+ });
405
+ }
406
+ else if (!requestsToAdd.has(request.uniqueKey)) {
407
+ requestsToAdd.set(request.uniqueKey, request);
408
+ }
409
+ }
410
+ // Early exit if all provided requests were already added
411
+ if (!requestsToAdd.size) {
412
+ return results;
413
+ }
414
+ this.#statsTracker.add('writeCount');
415
+ const apiResults = await this.backend.addBatchOfRequests([...requestsToAdd.values()], { forefront });
416
+ // Report unprocessed requests
417
+ results.unprocessedRequests = apiResults.unprocessedRequests;
418
+ // Add all new requests to the requestCache
419
+ for (const newRequest of apiResults.processedRequests) {
420
+ // Add the new request to the processed list
421
+ results.processedRequests.push(newRequest);
422
+ const cacheKey = getCachedRequestId(newRequest.uniqueKey);
423
+ if (cache) {
424
+ this.cacheRequest(cacheKey, { ...newRequest, forefront });
425
+ }
426
+ // Unlike `requestCache`, populate this on every batch (including background ones).
427
+ this.#requestSeenCache.add(cacheKey, newRequest.requestId);
428
+ }
429
+ return results;
430
+ }
431
+ /**
432
+ * Adds requests to the queue in batches. By default, it will resolve after the initial batch is added, and continue
433
+ * adding the rest in the background. You can configure the batch size via `batchSize` option and the sleep time in between
434
+ * the batches via `waitBetweenBatchesMillis`. If you want to wait for all batches to be added to the queue, you can use
435
+ * the `waitForAllRequestsToBeAdded` promise you get in the response object.
436
+ *
437
+ * @param requests The requests to add
438
+ * @param options Options for the request queue
439
+ */
440
+ async addRequestsBatched(requests, options = {}) {
441
+ const transaction = activeStorageTransaction();
442
+ const deferred = transaction?.policy.requestQueue === 'deferred';
443
+ ow(requests, ow.object
444
+ .is((value) => isIterable(value) || isAsyncIterable(value))
445
+ .message((value) => `Expected an iterable or async iterable, got ${getObjectType(value)}`));
446
+ ow(options, ow.object.exactShape({
447
+ forefront: ow.optional.boolean,
448
+ waitForAllRequestsToBeAdded: ow.optional.boolean,
449
+ batchSize: ow.optional.number,
450
+ waitBetweenBatchesMillis: ow.optional.number,
451
+ maxNewRequests: ow.optional.number,
452
+ }));
453
+ const addRequest = this.addRequest.bind(this);
454
+ async function* generateRequests() {
455
+ for await (const opts of requests) {
456
+ // Validate the input
457
+ if (typeof opts === 'object' && opts !== null) {
458
+ if (opts.url !== undefined && typeof opts.url !== 'string') {
459
+ throw new Error(`Request options are not valid, the 'url' property is not a string. Input: ${inspect(opts)}`);
460
+ }
461
+ if (opts.id !== undefined) {
462
+ throw new Error(`Request options are not valid, the 'id' property must not be present. Input: ${inspect(opts)}`);
463
+ }
464
+ if (opts.requestsFromUrl !== undefined &&
465
+ typeof opts.requestsFromUrl !== 'string') {
466
+ throw new Error(`Request options are not valid, the 'requestsFromUrl' property is not a string. Input: ${inspect(opts)}`);
467
+ }
468
+ }
469
+ if (opts && typeof opts === 'object' && 'requestsFromUrl' in opts) {
470
+ // Handle URL lists right away
471
+ await addRequest(opts, { forefront: options.forefront });
472
+ }
473
+ else {
474
+ // Yield valid requests
475
+ yield typeof opts === 'string' ? { url: opts } : opts;
476
+ }
477
+ }
478
+ }
479
+ const { batchSize = 1000, maxNewRequests = undefined } = options;
480
+ // Under `deferred` no chunk performs backend I/O, so pacing them would only stall the handler.
481
+ const waitBetweenBatchesMillis = deferred ? 0 : (options.waitBetweenBatchesMillis ?? 1000);
482
+ let remainingBudget = maxNewRequests ?? Infinity;
483
+ const requestsOverLimit = [];
484
+ // If there's a limit on the number of added requests, do not send batches bigger than the limit
485
+ const effectiveChunkSize = maxNewRequests !== undefined ? () => Math.min(batchSize, remainingBudget) : batchSize;
486
+ // Hold onto the underlying iterator so we can drain leftovers from it in buildResult
487
+ const requestIterator = generateRequests();
488
+ const chunks = peekableAsyncIterable(chunkedAsyncIterable(requestIterator, effectiveChunkSize));
489
+ const chunksIterator = chunks[Symbol.asyncIterator]();
490
+ /**
491
+ * Process a chunk: send it to the queue, then update the remaining budget if maxNewRequests is active.
492
+ *
493
+ * Requests the backend reports as unprocessed are warned about and skipped rather than retried:
494
+ * `unprocessedRequests` is what remains after the backend's own transient-error handling - a
495
+ * semantic rejection (e.g. a malformed `userData` shape) that re-sending would only re-poke.
496
+ * Retrying transient failures is the storage backend's job, not the frontend's.
497
+ */
498
+ const processChunk = async (chunk, cache = true) => {
499
+ const { processedRequests, unprocessedRequests } = await this.addRequests(chunk, {
500
+ forefront: options.forefront,
501
+ cache,
502
+ });
503
+ if (unprocessedRequests.length > 0) {
504
+ this.log.warning('Some requests were rejected by the request queue and will be skipped. ' +
505
+ "This usually means the request data is malformed (e.g. an invalid 'userData' shape).", { unprocessedRequests });
506
+ }
507
+ if (maxNewRequests !== undefined) {
508
+ remainingBudget -= processedRequests.filter((r) => !r.wasAlreadyPresent).length;
509
+ }
510
+ return processedRequests;
511
+ };
512
+ /**
513
+ * Build the final result. When maxNewRequests is set, drains any remaining items
514
+ * from the underlying request iterator into requestsOverLimit.
515
+ *
516
+ * We accept the iterator explicitly (rather than closing over it) to make it obvious
517
+ * that this is the *same* iterator that `chunkedAsyncIterable` has been consuming —
518
+ * so only unconsumed items are drained. We drain `requestIterator` (not `chunks`)
519
+ * because `chunkedAsyncIterable` stops yielding when the budget-based chunk size
520
+ * drops to 0, leaving unconsumed items in the underlying iterator.
521
+ */
522
+ const buildResult = async (addedRequests, waitForAllRequestsToBeAdded, unconsumedIterator) => {
523
+ if (maxNewRequests !== undefined) {
524
+ for await (const request of unconsumedIterator) {
525
+ requestsOverLimit.push(request);
526
+ }
527
+ }
528
+ return { addedRequests, waitForAllRequestsToBeAdded, requestsOverLimit };
529
+ };
530
+ // Add initial batch to process right away
531
+ const initialChunk = await chunksIterator.peek();
532
+ if (initialChunk === undefined) {
533
+ return buildResult([], Promise.resolve([]), requestIterator);
534
+ }
535
+ const addedRequests = await processChunk(initialChunk);
536
+ await chunksIterator.next();
537
+ // If we have no more requests to add (either exhausted or budget hit), return immediately
538
+ if ((await chunksIterator.peek()) === undefined) {
539
+ return buildResult(addedRequests, Promise.resolve([]), requestIterator);
540
+ }
541
+ const processRemainingChunks = async () => {
542
+ const finalAddedRequests = [];
543
+ for await (const requestChunk of chunks) {
544
+ finalAddedRequests.push(...(await processChunk(requestChunk, false)));
545
+ await sleep(waitBetweenBatchesMillis);
546
+ }
547
+ return finalAddedRequests;
548
+ };
549
+ // maxNewRequests needs all batches to report skipped requests accurately; `deferred` needs them
550
+ // too - a writer that finishes after commit would have nowhere to put its journal entries.
551
+ const awaitsRemainingChunks = options.waitForAllRequestsToBeAdded || maxNewRequests !== undefined || deferred;
552
+ // eslint-disable-next-line no-async-promise-executor
553
+ const promise = new Promise(async (resolve) => {
554
+ if (awaitsRemainingChunks) {
555
+ // Awaited below, i.e. still within the caller's transaction scope, so the additions are
556
+ // journaled like the initial chunk - introspection must not depend on where the chunk
557
+ // boundary happened to fall.
558
+ resolve(await processRemainingChunks());
559
+ }
560
+ else {
561
+ // Nobody awaits this writer, so it outlives the transaction scope it inherits and must
562
+ // not record into a transaction that may already be closed. It writes directly - its
563
+ // write-through additions were never going to be rolled back anyway - which means the
564
+ // requests it adds are not journaled. See `StorageTransactionView.enqueuedUrls`.
565
+ resolve(await withDirectStorageAccess(processRemainingChunks));
566
+ }
567
+ });
568
+ this.inProgressRequestBatchCount += 1;
569
+ void promise.finally(() => {
570
+ this.inProgressRequestBatchCount -= 1;
571
+ });
572
+ if (awaitsRemainingChunks) {
573
+ addedRequests.push(...(await promise));
574
+ }
575
+ return buildResult(addedRequests, promise, requestIterator);
576
+ }
577
+ /**
578
+ * Gets the request from the queue specified by its `uniqueKey`.
579
+ *
580
+ * @param uniqueKey Unique key of the request.
581
+ * @returns Returns the request object, or `null` if it was not found.
582
+ */
583
+ async getRequest(uniqueKey) {
584
+ const transaction = activeStorageTransaction();
585
+ ow(uniqueKey, ow.string);
586
+ // Requests buffered by the active transaction (under the `deferred` write policy) are visible to it.
587
+ const buffered = transaction && this.bufferedRequests(transaction).get(uniqueKey);
588
+ if (buffered) {
589
+ return new Request(buffered);
590
+ }
591
+ const requestOptions = await this.backend.getRequest(uniqueKey);
592
+ if (!requestOptions)
593
+ return null;
594
+ return new Request(requestOptions);
81
595
  }
82
596
  /**
83
597
  * Returns a next request in the queue to be processed, or `null` if there are no more pending requests.
84
598
  *
85
599
  * Once you successfully finish processing of the request, you need to call
86
- * {@link RequestQueue.markRequestHandled}
600
+ * {@link RequestQueue.markRequestAsHandled}
87
601
  * to mark the request as handled in the queue. If there was some error in processing the request,
88
602
  * call {@link RequestQueue.reclaimRequest} instead,
89
603
  * so that the queue will give the request to some other consumer in another call to the `fetchNextRequest` function.
@@ -97,204 +611,280 @@ class RequestQueue extends RequestProvider {
97
611
  * Returns the request object or `null` if there are no more pending requests.
98
612
  */
99
613
  async fetchNextRequest() {
100
- checkStorageAccess();
101
- this.lastActivity = new Date();
102
- await this.ensureHeadIsNonEmpty();
103
- const nextRequestId = this.queueHeadIds.removeFirst();
104
- // We are likely done at this point.
105
- if (!nextRequestId)
614
+ rejectOperationInTransaction('RequestQueue.fetchNextRequest()', 'it is part of the crawler request-processing bookkeeping, which a transaction must not affect.');
615
+ if (this.#queuePausedForMigration) {
106
616
  return null;
107
- // This should never happen, but...
108
- if (this.inProgress.has(nextRequestId) || this.recentlyHandledRequestsCache.get(nextRequestId)) {
109
- this.log.warning('Queue head returned a request that is already in progress?!', {
110
- nextRequestId,
111
- inProgress: this.inProgress.has(nextRequestId),
112
- recentlyHandled: !!this.recentlyHandledRequestsCache.get(nextRequestId),
113
- });
114
- return null;
115
- }
116
- this.inProgress.add(nextRequestId);
117
- this.lastActivity = new Date();
118
- let request;
119
- try {
120
- request = await this.getRequest(nextRequestId);
121
- }
122
- catch (e) {
123
- // On error, remove the request from in progress, otherwise it would be there forever
124
- this.inProgress.delete(nextRequestId);
125
- throw e;
126
617
  }
127
- // NOTE: It can happen that the queue head index is inconsistent with the main queue table. This can occur in two situations:
128
- // 1) Queue head index is ahead of the main table and the request is not present in the main table yet (i.e. getRequest() returned null).
129
- // In this case, keep the request marked as in progress for a short while,
130
- // so that isFinished() doesn't return true and _ensureHeadIsNonEmpty() doesn't not load the request
131
- // into the queueHeadDict straight again. After the interval expires, fetchNextRequest()
132
- // will try to fetch this request again, until it eventually appears in the main table.
133
- if (!request) {
134
- this.log.debug('Cannot find a request from the beginning of queue, will be retried later', {
135
- nextRequestId,
136
- });
137
- setTimeout(() => {
138
- this.inProgress.delete(nextRequestId);
139
- }, STORAGE_CONSISTENCY_DELAY_MILLIS);
618
+ this.#statsTracker.add('headItemReadCount');
619
+ const requestOptions = await this.backend.fetchNextRequest();
620
+ if (!requestOptions)
140
621
  return null;
141
- }
142
- // 2) Queue head index is behind the main table and the underlying request was already handled
143
- // (by some other client, since we keep the track of handled requests in recentlyHandled dictionary).
144
- // We just add the request to the recentlyHandled dictionary so that next call to _ensureHeadIsNonEmpty()
145
- // will not put the request again to queueHeadDict.
146
- if (request.handledAt) {
147
- this.log.debug('Request fetched from the beginning of queue was already handled', { nextRequestId });
148
- this.recentlyHandledRequestsCache.add(nextRequestId, true);
622
+ return new Request(requestOptions);
623
+ }
624
+ /**
625
+ * Marks a request that was previously returned by the
626
+ * {@link RequestQueue.fetchNextRequest}
627
+ * function as handled after successful processing.
628
+ * Handled requests will never again be returned by the `fetchNextRequest` function.
629
+ */
630
+ async markRequestAsHandled(request) {
631
+ rejectOperationInTransaction('RequestQueue.markRequestAsHandled()', 'it is part of the crawler request-processing bookkeeping, which a transaction must not affect.');
632
+ ow(request, ow.object.partialShape({
633
+ id: ow.string,
634
+ uniqueKey: ow.string,
635
+ handledAt: ow.optional.string,
636
+ }));
637
+ const forefront = this.requestCache.get(getRequestId(request.uniqueKey))?.forefront ?? false;
638
+ const handledAt = request.handledAt ?? new Date().toISOString();
639
+ this.#statsTracker.add('writeCount');
640
+ const processedRequest = await this.backend.markRequestAsHandled({
641
+ ...request,
642
+ handledAt,
643
+ });
644
+ // The request was not in progress (e.g. already handled) — nothing to do.
645
+ if (!processedRequest) {
149
646
  return null;
150
647
  }
151
- return request;
648
+ request.handledAt = handledAt;
649
+ const queueOperationInfo = {
650
+ ...processedRequest,
651
+ uniqueKey: request.uniqueKey,
652
+ forefront,
653
+ };
654
+ this.cacheRequest(getRequestId(request.uniqueKey), queueOperationInfo);
655
+ return queueOperationInfo;
152
656
  }
153
- async ensureHeadIsNonEmpty() {
154
- // Alias for backwards compatibility
155
- await this._ensureHeadIsNonEmpty();
657
+ /**
658
+ * Reclaims a failed request back to the queue, so that it can be returned for processing later again
659
+ * by another call to {@link RequestQueue.fetchNextRequest}.
660
+ * The request record in the queue is updated using the provided `request` parameter.
661
+ * For example, this lets you store the number of retries or error messages for the request.
662
+ */
663
+ async reclaimRequest(request, options = {}) {
664
+ rejectOperationInTransaction('RequestQueue.reclaimRequest()', 'it is part of the crawler request-processing bookkeeping, which a transaction must not affect.');
665
+ ow(request, ow.object.partialShape({
666
+ id: ow.string,
667
+ uniqueKey: ow.string,
668
+ }));
669
+ ow(options, ow.object.exactShape({
670
+ forefront: ow.optional.boolean,
671
+ }));
672
+ const { forefront = false } = options;
673
+ this.#statsTracker.add('writeCount');
674
+ const processedRequest = await this.backend.reclaimRequest(request, { forefront });
675
+ // The request was not in progress — nothing to reclaim.
676
+ if (!processedRequest) {
677
+ return null;
678
+ }
679
+ const queueOperationInfo = {
680
+ ...processedRequest,
681
+ uniqueKey: request.uniqueKey,
682
+ forefront,
683
+ };
684
+ this.cacheRequest(getRequestId(request.uniqueKey), queueOperationInfo);
685
+ return queueOperationInfo;
156
686
  }
157
687
  /**
158
- * We always request more items than is in progress to ensure that something falls into head.
688
+ * Resolves to `true` if the next call to {@link RequestQueue.fetchNextRequest} would return
689
+ * `null`, i.e. there are no pending requests to fetch right now. Otherwise it resolves to `false`.
159
690
  *
160
- * @param [ensureConsistency] If true then query for queue head is retried until queueModifiedAt
161
- * is older than queryStartedAt by at least API_PROCESSED_REQUESTS_DELAY_MILLIS to ensure that queue
162
- * head is consistent.
163
- * @default false
164
- * @param [limit] How many queue head items will be fetched.
165
- * @param [iteration] Used when this function is called recursively to limit the recursion.
166
- * @returns Indicates if queue head is consistent (true) or inconsistent (false).
691
+ * Note that even if the queue is empty, there might be some requests currently being processed
692
+ * (fetched but not yet handled or reclaimed). An empty queue therefore does not mean crawling is
693
+ * finished — those in-progress requests may still be reclaimed, and background tasks may still be
694
+ * adding more requests. To check whether all activity in the queue has finished, use
695
+ * {@link RequestQueue.isFinished}.
167
696
  */
168
- async _ensureHeadIsNonEmpty(ensureConsistency = false, limit = Math.max(this.inProgressCount() * QUERY_HEAD_BUFFER, QUERY_HEAD_MIN_LENGTH), iteration = 0) {
169
- // If we are paused for migration, resolve immediately.
170
- if (this.queuePausedForMigration) {
171
- return true;
172
- }
173
- // If is nonempty resolve immediately.
174
- if (this.queueHeadIds.length() > 0) {
175
- return true;
176
- }
177
- if (!this.queryQueueHeadPromise) {
178
- const queryStartedAt = new Date();
179
- this.queryQueueHeadPromise = this.client
180
- .listHead({ limit })
181
- .then(({ items, queueModifiedAt, hadMultipleClients }) => {
182
- items.forEach(({ id: requestId, uniqueKey }) => {
183
- // Queue head index might be behind the main table, so ensure we don't recycle requests
184
- if (!requestId ||
185
- !uniqueKey ||
186
- this.inProgress.has(requestId) ||
187
- this.recentlyHandledRequestsCache.get(requestId))
188
- return;
189
- this.queueHeadIds.add(requestId, requestId, false);
190
- const forefront = this.requestCache.get(getRequestId(uniqueKey))?.forefront ?? false;
191
- this._cacheRequest(getRequestId(uniqueKey), {
192
- requestId,
193
- wasAlreadyHandled: false,
194
- wasAlreadyPresent: true,
195
- uniqueKey,
196
- forefront,
197
- });
198
- });
199
- // This is needed so that the next call to _ensureHeadIsNonEmpty() will fetch the queue head again.
200
- this.queryQueueHeadPromise = null;
201
- return {
202
- wasLimitReached: items.length >= limit,
203
- prevLimit: limit,
204
- queueModifiedAt: new Date(queueModifiedAt),
205
- queryStartedAt,
206
- hadMultipleClients,
207
- };
208
- });
209
- }
210
- const { queueModifiedAt, wasLimitReached, prevLimit, queryStartedAt, hadMultipleClients } = await this.queryQueueHeadPromise;
211
- // TODO: I feel this code below can be greatly simplified...
212
- // If queue is still empty then one of the following holds:
213
- // - the other calls waiting for this promise already consumed all the returned requests
214
- // - the limit was too low and contained only requests in progress
215
- // - the writes from other clients were not propagated yet
216
- // - the whole queue was processed and we are done
217
- // If limit was not reached in the call then there are no more requests to be returned.
218
- if (prevLimit >= REQUEST_QUEUE_HEAD_MAX_LIMIT) {
219
- this.log.warning(`Reached the maximum number of requests in progress: ${REQUEST_QUEUE_HEAD_MAX_LIMIT}.`);
220
- }
221
- const shouldRepeatWithHigherLimit = this.queueHeadIds.length() === 0 && wasLimitReached && prevLimit < REQUEST_QUEUE_HEAD_MAX_LIMIT;
222
- // If ensureConsistency=true then we must ensure that either:
223
- // - queueModifiedAt is older than queryStartedAt by at least API_PROCESSED_REQUESTS_DELAY_MILLIS
224
- // - hadMultipleClients=false and this.assumedTotalCount<=this.assumedHandledCount
225
- const isDatabaseConsistent = +queryStartedAt - +queueModifiedAt >= API_PROCESSED_REQUESTS_DELAY_MILLIS;
226
- const isLocallyConsistent = !hadMultipleClients && this.assumedTotalCount <= this.assumedHandledCount;
227
- // Consistent information from one source is enough to consider request queue finished.
228
- const shouldRepeatForConsistency = ensureConsistency && !isDatabaseConsistent && !isLocallyConsistent;
229
- // If both are false then head is consistent and we may exit.
230
- if (!shouldRepeatWithHigherLimit && !shouldRepeatForConsistency)
231
- return true;
232
- // If we are querying for consistency then we limit the number of queries to MAX_QUERIES_FOR_CONSISTENCY.
233
- // If this is reached then we return false so that empty() and finished() returns possibly false negative.
234
- if (!shouldRepeatWithHigherLimit && iteration > MAX_QUERIES_FOR_CONSISTENCY)
697
+ async isEmpty() {
698
+ const transaction = activeStorageTransaction();
699
+ // Requests buffered by the active transaction count as pending from its point of view.
700
+ if (transaction && this.bufferedRequests(transaction).size > 0) {
235
701
  return false;
236
- const nextLimit = shouldRepeatWithHigherLimit ? Math.round(prevLimit * 1.5) : prevLimit;
237
- // If we are repeating for consistency then wait required time.
238
- if (shouldRepeatForConsistency) {
239
- const delayMillis = API_PROCESSED_REQUESTS_DELAY_MILLIS - (Date.now() - +queueModifiedAt);
240
- this.log.info(`Waiting for ${delayMillis}ms before considering the queue as finished to ensure that the data is consistent.`);
241
- await sleep(delayMillis);
242
702
  }
243
- return this._ensureHeadIsNonEmpty(ensureConsistency, nextLimit, iteration + 1);
703
+ return this.backend.isEmpty();
244
704
  }
245
- // RequestQueue v1 behavior overrides below
705
+ /**
706
+ * Resolves to `true` if all requests were already handled and there are no more left — including no
707
+ * requests currently in progress (fetched but not yet handled or reclaimed, including requests
708
+ * locked by other clients sharing the same queue) and no background add operations still in flight.
709
+ *
710
+ * Due to the nature of distributed storage used by the queue, the function may occasionally return
711
+ * a false negative, but it shall never return a false positive.
712
+ */
246
713
  async isFinished() {
247
- checkStorageAccess();
248
- if (Date.now() - +this.lastActivity > this.internalTimeoutMillis) {
249
- const message = `The request queue seems to be stuck for ${this.internalTimeoutMillis / 1e3}s, resetting internal state.`;
250
- this.log.warning(message, { inProgress: [...this.inProgress] });
251
- this._reset();
252
- }
714
+ const transaction = activeStorageTransaction();
715
+ // We are not finished if we're still adding new requests in the background.
253
716
  if (this.inProgressRequestBatchCount > 0) {
254
717
  return false;
255
718
  }
256
- if (this.queueHeadIds.length() > 0 || this.inProgressCount() > 0)
719
+ // Requests buffered by the active transaction count as pending from its point of view.
720
+ if (transaction && this.bufferedRequests(transaction).size > 0) {
257
721
  return false;
258
- const isHeadConsistent = await this._ensureHeadIsNonEmpty(true);
259
- return isHeadConsistent && this.queueHeadIds.length() === 0 && this.inProgressCount() === 0;
722
+ }
723
+ return this.backend.isFinished();
260
724
  }
261
725
  /**
262
- * Reclaims a failed request back to the queue, so that it can be returned for processing later again
263
- * by another call to {@link RequestQueue.fetchNextRequest}.
264
- * The request record in the queue is updated using the provided `request` parameter.
265
- * For example, this lets you store the number of retries or error messages for the request.
726
+ * Tells the queue how long a consumer expects to hold a fetched request before marking it handled
727
+ * or reclaiming it (typically the request-handler timeout plus padding), so that a storage backend
728
+ * that reserves requests via locking does not hand the same request out again while it is still
729
+ * being processed.
730
+ *
731
+ * Several consumers may share one queue (and therefore one client) in a single process, so we only
732
+ * ever raise the reservation duration, never lower it — otherwise a short-lived consumer could cut
733
+ * short the reservation of a long-lived one and have its in-flight request stolen.
266
734
  */
267
- async reclaimRequest(...args) {
268
- checkStorageAccess();
269
- const [request, options] = args;
270
- const forefront = options?.forefront ?? false;
271
- const result = await super.reclaimRequest(...args);
272
- // Wait a little to increase a chance that the next call to fetchNextRequest() will return the request with updated data.
273
- // This is to compensate for the limitation of DynamoDB, where writes might not be immediately visible to subsequent reads.
274
- setTimeout(() => {
275
- if (!this.inProgress.has(request.id)) {
276
- this.log.debug('The request is no longer marked as in progress in the queue?!', {
277
- requestId: request.id,
278
- });
279
- return;
280
- }
281
- this.inProgress.delete(request.id);
282
- // Performance optimization: add request straight to head if possible
283
- this._maybeAddRequestToQueueHead(request.id, forefront);
284
- }, STORAGE_CONSISTENCY_DELAY_MILLIS);
285
- return result;
735
+ async setExpectedRequestProcessingTimeSecs(secs) {
736
+ if (secs <= this.#expectedRequestProcessingSecs) {
737
+ return;
738
+ }
739
+ this.#expectedRequestProcessingSecs = secs;
740
+ await this.backend.setExpectedRequestProcessingTimeSecs?.(secs);
741
+ }
742
+ /**
743
+ * Caches information about request to beware of unneeded addRequest() calls.
744
+ */
745
+ cacheRequest(cacheKey, queueOperationInfo) {
746
+ // Remove the previous entry, as otherwise our cache will never update 👀
747
+ this.requestCache.remove(cacheKey);
748
+ this.requestCache.add(cacheKey, {
749
+ id: queueOperationInfo.requestId,
750
+ isHandled: queueOperationInfo.wasAlreadyHandled,
751
+ uniqueKey: queueOperationInfo.uniqueKey,
752
+ hydrated: null,
753
+ lockExpiresAt: null,
754
+ forefront: queueOperationInfo.forefront,
755
+ });
756
+ }
757
+ /**
758
+ * Removes the queue either from the Apify Cloud storage or from the local database,
759
+ * depending on the mode of operation.
760
+ */
761
+ async drop() {
762
+ rejectOperationInTransaction('RequestQueue.drop()');
763
+ await this.backend.drop();
764
+ serviceLocator.getStorageInstanceManager().removeFromCache(this);
286
765
  }
287
766
  /**
767
+ * Remove all requests from the queue but keep the queue itself, resetting it
768
+ * so it can be reused (e.g. across multiple `crawler.run()` calls).
769
+ */
770
+ async purge() {
771
+ rejectOperationInTransaction('RequestQueue.purge()');
772
+ await this.backend.purge();
773
+ // Reset in-memory bookkeeping so the queue behaves as if freshly opened.
774
+ this.requestCache.clear();
775
+ this.#requestSeenCache.clear();
776
+ this.inProgressRequestBatchCount = 0;
777
+ // Reset the expected-processing-time high-water mark too, otherwise the monotonic-raise guard
778
+ // in `setExpectedRequestProcessingTimeSecs` would let a value raised in an earlier run leak into a
779
+ // later one and silently swallow a lower hint (the queue is meant to be reusable across runs).
780
+ this.#expectedRequestProcessingSecs = 0;
781
+ }
782
+ /**
783
+ * @inheritdoc
784
+ */
785
+ async *[Symbol.asyncIterator]() {
786
+ while (true) {
787
+ const req = await this.fetchNextRequest();
788
+ if (!req)
789
+ break;
790
+ yield req;
791
+ }
792
+ }
793
+ /**
794
+ * Returns the number of handled requests.
795
+ *
796
+ * This function is just a convenient shortcut for:
797
+ *
798
+ * ```javascript
799
+ * const { handledRequestCount } = await queue.getInfo();
800
+ * ```
288
801
  * @inheritdoc
289
802
  */
290
- async markRequestHandled(request) {
291
- const res = await super.markRequestHandled(request);
292
- this.inProgress.delete(request.id);
293
- return res;
803
+ async getHandledCount() {
804
+ // NOTE: We keep this function for compatibility with RequestList.getHandledCount()
805
+ const { handledRequestCount } = await this.getInfo();
806
+ return handledRequestCount;
294
807
  }
295
- _reset() {
296
- super._reset();
297
- this.inProgress.clear();
808
+ /**
809
+ * Returns an object containing general information about the request queue.
810
+ *
811
+ * **Example:**
812
+ * ```
813
+ * {
814
+ * id: "WkzbQMuFYuamGv3YF",
815
+ * name: "my-queue",
816
+ * createdAt: new Date("2015-12-12T07:34:14.202Z"),
817
+ * modifiedAt: new Date("2015-12-13T08:36:13.202Z"),
818
+ * accessedAt: new Date("2015-12-14T08:36:13.202Z"),
819
+ * totalRequestCount: 25,
820
+ * handledRequestCount: 5,
821
+ * pendingRequestCount: 20,
822
+ * }
823
+ * ```
824
+ *
825
+ * @throws If the underlying storage no longer exists (e.g. it was deleted externally).
826
+ */
827
+ async getInfo() {
828
+ const transaction = activeStorageTransaction();
829
+ const metadata = await this.backend.getMetadata();
830
+ const bufferedCount = transaction ? this.bufferedRequests(transaction).size : 0;
831
+ if (bufferedCount > 0) {
832
+ return {
833
+ ...metadata,
834
+ totalRequestCount: metadata.totalRequestCount + bufferedCount,
835
+ pendingRequestCount: metadata.pendingRequestCount + bufferedCount,
836
+ };
837
+ }
838
+ return metadata;
839
+ }
840
+ /**
841
+ * Fetches URLs from requestsFromUrl and returns them in format of list of requests
842
+ */
843
+ async fetchRequestsFromUrl(source) {
844
+ const { requestsFromUrl, regex, ...sharedOpts } = source;
845
+ // Download remote resource and parse URLs.
846
+ let urlsArr;
847
+ try {
848
+ urlsArr = await this.downloadListOfUrls({
849
+ url: requestsFromUrl,
850
+ urlRegExp: regex,
851
+ proxyUrl: (await this.#proxyConfiguration?.newProxyInfo())?.url,
852
+ });
853
+ }
854
+ catch (err) {
855
+ throw new Error(`Cannot fetch a request list from ${requestsFromUrl}: ${err}`);
856
+ }
857
+ // Skip if resource contained no URLs.
858
+ if (!urlsArr.length) {
859
+ this.log.warning('The fetched list contains no valid URLs.', { requestsFromUrl, regex });
860
+ return [];
861
+ }
862
+ return urlsArr.map((url) => ({ url, ...sharedOpts }));
863
+ }
864
+ /**
865
+ * Adds all fetched requests from a URL from a remote resource.
866
+ */
867
+ async addFetchedRequests(source, fetchedRequests, options) {
868
+ const { requestsFromUrl, regex } = source;
869
+ const { addedRequests } = await this.addRequestsBatched(fetchedRequests, options);
870
+ this.log.info('Fetched and loaded Requests from a remote resource.', {
871
+ requestsFromUrl,
872
+ regex,
873
+ fetchedCount: fetchedRequests.length,
874
+ importedCount: addedRequests.length,
875
+ duplicateCount: fetchedRequests.length - addedRequests.length,
876
+ sample: JSON.stringify(fetchedRequests.slice(0, 5)),
877
+ });
878
+ return addedRequests;
879
+ }
880
+ /**
881
+ * @internal wraps public utility for mocking purposes
882
+ */
883
+ async downloadListOfUrls(options) {
884
+ return downloadListOfUrls({
885
+ ...options,
886
+ httpClient: this.#httpClient,
887
+ });
298
888
  }
299
889
  /**
300
890
  * Opens a request queue and returns a promise resolving to an instance
@@ -307,14 +897,33 @@ class RequestQueue extends RequestProvider {
307
897
  *
308
898
  * For more details and code examples, see the {@link RequestQueue} class.
309
899
  *
310
- * @param [queueIdOrName]
311
- * ID or name of the request queue to be opened. If `null` or `undefined`,
312
- * the function returns the default request queue associated with the crawler run.
900
+ * @param [identifier]
901
+ * ID or name of the request queue to be opened. If a string is provided, it will first be
902
+ * looked up as an ID; if no such storage exists, it will be treated as a name.
903
+ * If `null` or `undefined`, the function returns the default request queue associated with the crawler run.
313
904
  * @param [options] Open Request Queue options.
314
905
  */
315
- static async open(...args) {
316
- return super.open(...args);
906
+ static async open(identifier, options = {}) {
907
+ tryCancel();
908
+ ow(options, ow.object.exactShape({
909
+ configuration: ow.optional.object.instanceOf(Configuration),
910
+ storageBackend: ow.optional.object,
911
+ proxyConfiguration: ow.optional.object,
912
+ httpClient: ow.optional.object,
913
+ }));
914
+ const storageBackend = options.storageBackend ?? serviceLocator.getStorageBackend();
915
+ const configuration = options.configuration ?? serviceLocator.getConfiguration();
916
+ await purgeDefaultStorages({ onlyPurgeOnce: true, storageBackend, configuration });
917
+ const resolved = await resolveStorageIdentifier(identifier, storageBackend, 'RequestQueue');
918
+ const queue = await serviceLocator
919
+ .getStorageInstanceManager()
920
+ .openStorage(this, {
921
+ ...resolved,
922
+ backendOpener: () => storageBackend.createRequestQueueBackend(resolved),
923
+ backendCacheKey: storageBackend.getStorageBackendCacheKey?.() ?? storageBackend.constructor.name,
924
+ });
925
+ queue.#proxyConfiguration = options.proxyConfiguration;
926
+ queue.#httpClient = options.httpClient;
927
+ return queue;
317
928
  }
318
929
  }
319
- export { RequestQueue as RequestQueueV1 };
320
- //# sourceMappingURL=request_queue.js.map