@crawlee/memory-storage 4.0.0-beta.65 → 4.0.0-beta.67

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 (80) hide show
  1. package/consts.d.ts +0 -12
  2. package/consts.d.ts.map +1 -1
  3. package/consts.js +0 -13
  4. package/consts.js.map +1 -1
  5. package/memory-storage.d.ts +16 -36
  6. package/memory-storage.d.ts.map +1 -1
  7. package/memory-storage.js +59 -239
  8. package/memory-storage.js.map +1 -1
  9. package/package.json +3 -6
  10. package/resource-clients/common/base-client.d.ts +0 -3
  11. package/resource-clients/common/base-client.d.ts.map +1 -1
  12. package/resource-clients/common/base-client.js +0 -6
  13. package/resource-clients/common/base-client.js.map +1 -1
  14. package/resource-clients/dataset.d.ts +9 -11
  15. package/resource-clients/dataset.d.ts.map +1 -1
  16. package/resource-clients/dataset.js +8 -42
  17. package/resource-clients/dataset.js.map +1 -1
  18. package/resource-clients/key-value-store.d.ts +18 -15
  19. package/resource-clients/key-value-store.d.ts.map +1 -1
  20. package/resource-clients/key-value-store.js +31 -47
  21. package/resource-clients/key-value-store.js.map +1 -1
  22. package/resource-clients/request-queue.d.ts +50 -20
  23. package/resource-clients/request-queue.d.ts.map +1 -1
  24. package/resource-clients/request-queue.js +251 -281
  25. package/resource-clients/request-queue.js.map +1 -1
  26. package/utils.d.ts +0 -14
  27. package/utils.d.ts.map +1 -1
  28. package/utils.js.map +1 -1
  29. package/background-handler/fs-utils.d.ts +0 -6
  30. package/background-handler/fs-utils.d.ts.map +0 -1
  31. package/background-handler/fs-utils.js +0 -63
  32. package/background-handler/fs-utils.js.map +0 -1
  33. package/background-handler/index.d.ts +0 -13
  34. package/background-handler/index.d.ts.map +0 -1
  35. package/background-handler/index.js +0 -29
  36. package/background-handler/index.js.map +0 -1
  37. package/cache-helpers.d.ts +0 -9
  38. package/cache-helpers.d.ts.map +0 -1
  39. package/cache-helpers.js +0 -319
  40. package/cache-helpers.js.map +0 -1
  41. package/fs/common.d.ts +0 -6
  42. package/fs/common.d.ts.map +0 -1
  43. package/fs/common.js +0 -2
  44. package/fs/common.js.map +0 -1
  45. package/fs/dataset/fs.d.ts +0 -11
  46. package/fs/dataset/fs.d.ts.map +0 -1
  47. package/fs/dataset/fs.js +0 -37
  48. package/fs/dataset/fs.js.map +0 -1
  49. package/fs/dataset/index.d.ts +0 -10
  50. package/fs/dataset/index.d.ts.map +0 -1
  51. package/fs/dataset/index.js +0 -9
  52. package/fs/dataset/index.js.map +0 -1
  53. package/fs/dataset/memory.d.ts +0 -8
  54. package/fs/dataset/memory.d.ts.map +0 -1
  55. package/fs/dataset/memory.js +0 -13
  56. package/fs/dataset/memory.js.map +0 -1
  57. package/fs/key-value-store/fs.d.ts +0 -17
  58. package/fs/key-value-store/fs.d.ts.map +0 -1
  59. package/fs/key-value-store/fs.js +0 -83
  60. package/fs/key-value-store/fs.js.map +0 -1
  61. package/fs/key-value-store/index.d.ts +0 -11
  62. package/fs/key-value-store/index.d.ts.map +0 -1
  63. package/fs/key-value-store/index.js +0 -9
  64. package/fs/key-value-store/index.js.map +0 -1
  65. package/fs/key-value-store/memory.d.ts +0 -9
  66. package/fs/key-value-store/memory.d.ts.map +0 -1
  67. package/fs/key-value-store/memory.js +0 -13
  68. package/fs/key-value-store/memory.js.map +0 -1
  69. package/fs/request-queue/fs.d.ts +0 -21
  70. package/fs/request-queue/fs.d.ts.map +0 -1
  71. package/fs/request-queue/fs.js +0 -72
  72. package/fs/request-queue/fs.js.map +0 -1
  73. package/fs/request-queue/index.d.ts +0 -9
  74. package/fs/request-queue/index.d.ts.map +0 -1
  75. package/fs/request-queue/index.js +0 -9
  76. package/fs/request-queue/index.js.map +0 -1
  77. package/fs/request-queue/memory.d.ts +0 -10
  78. package/fs/request-queue/memory.d.ts.map +0 -1
  79. package/fs/request-queue/memory.js +0 -15
  80. package/fs/request-queue/memory.js.map +0 -1
@@ -1,10 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { rm } from 'node:fs/promises';
3
- import { resolve } from 'node:path';
4
2
  import { AsyncQueue } from '@sapphire/async-queue';
5
3
  import { s } from '@sapphire/shapeshift';
6
- import { scheduleBackgroundTask } from '../background-handler/index.js';
7
- import { createRequestQueueStorageImplementation } from '../fs/request-queue/index.js';
8
4
  import { purgeNullsFromObject, uniqueKeyToRequestId } from '../utils.js';
9
5
  import { BaseClient } from './common/base-client.js';
10
6
  const requestShape = s
@@ -25,25 +21,40 @@ const requestOptionsShape = s.object({
25
21
  export class RequestQueueClient extends BaseClient {
26
22
  name;
27
23
  /**
28
- * The key used for directory naming and cache lookup. For named storages, this equals
29
- * the name. For alias (unnamed) storages, this is the alias string. Falls back to id.
24
+ * The key used for cache lookup. For named storages, this equals the name. For alias (unnamed)
25
+ * storages, this is the alias string. Falls back to id.
30
26
  */
31
- directoryName;
27
+ cacheKey;
32
28
  createdAt = new Date();
33
29
  accessedAt = new Date();
34
30
  modifiedAt = new Date();
35
31
  handledRequestCount = 0;
36
32
  pendingRequestCount = 0;
37
- requestQueueDirectory;
38
- mutex = new AsyncQueue();
33
+ /**
34
+ * Serializes every operation that reads-then-writes this client's shared queue state — the
35
+ * `requests` map, the `forefrontRequestIds` array, the `inProgressRequestIds` set and the request
36
+ * counts. Those mutations span `await` points, so without this mutex a concurrent operation could
37
+ * interleave and corrupt them (e.g. a head scan pruning `forefrontRequestIds` while
38
+ * `addBatchOfRequests` pushes to it). Held by every mutating method as well as by `isEmpty`/
39
+ * `isFinished`, whose head scan also prunes `forefrontRequestIds`.
40
+ */
41
+ queueStateMutex = new AsyncQueue();
39
42
  forefrontRequestIds = [];
43
+ /**
44
+ * IDs of requests currently fetched but not yet handled or reclaimed. A request in this set is
45
+ * "in progress" and will not be handed out again by {@link fetchNextRequest}.
46
+ *
47
+ * Unlike the file-system / platform clients, the in-memory queue lives entirely within a single
48
+ * process and is never shared with another consumer, so there is no need for an expiring,
49
+ * cross-process-visible lock — tracking in-progress requests in this set is enough.
50
+ */
51
+ inProgressRequestIds = new Set();
40
52
  requests = new Map();
41
53
  client;
42
54
  constructor(options) {
43
55
  super(options.id ?? randomUUID());
44
56
  this.name = options.name;
45
- this.directoryName = options.directoryName ?? this.name ?? this.id;
46
- this.requestQueueDirectory = resolve(options.baseStorageDirectory, this.directoryName);
57
+ this.cacheKey = options.cacheKey ?? this.name ?? this.id;
47
58
  this.client = options.client;
48
59
  }
49
60
  async getMetadata() {
@@ -51,324 +62,296 @@ export class RequestQueueClient extends BaseClient {
51
62
  return this.toRequestQueueInfo();
52
63
  }
53
64
  async drop() {
54
- const storeIndex = this.client.requestQueueCache.findIndex((queue) => queue.id === this.id);
55
- if (storeIndex !== -1) {
56
- const [oldClient] = this.client.requestQueueCache.splice(storeIndex, 1);
57
- oldClient.pendingRequestCount = 0;
58
- oldClient.requests.clear();
59
- await rm(oldClient.requestQueueDirectory, { recursive: true, force: true });
65
+ // Serialize against other mutators (and the head scans in `isEmpty`/`isFinished`) so a concurrent
66
+ // operation cannot observe half-cleared state — e.g. a forefront id whose request has already been
67
+ // removed, which `listPendingHead` would then dereference as `undefined`.
68
+ await this.queueStateMutex.wait();
69
+ try {
70
+ const storeIndex = this.client.requestQueueCache.findIndex((queue) => queue.id === this.id);
71
+ if (storeIndex !== -1) {
72
+ const [oldClient] = this.client.requestQueueCache.splice(storeIndex, 1);
73
+ oldClient.pendingRequestCount = 0;
74
+ // Clear all in-memory state, consistent with `purge`. Clearing `requests` alone would
75
+ // leave dangling ids in `forefrontRequestIds`/`inProgressRequestIds`, which a later head
76
+ // scan would resolve to a missing request and dereference.
77
+ oldClient.requests.clear();
78
+ oldClient.forefrontRequestIds = [];
79
+ oldClient.inProgressRequestIds.clear();
80
+ }
81
+ }
82
+ finally {
83
+ this.queueStateMutex.shift();
60
84
  }
61
85
  }
62
86
  async purge() {
63
- // Clear all in-memory state
64
- this.requests.clear();
65
- this.forefrontRequestIds = [];
66
- this.handledRequestCount = 0;
67
- this.pendingRequestCount = 0;
68
- // Remove request files from disk but keep the directory
69
- if (this.client.persistStorage) {
70
- const { readdir } = await import('node:fs/promises');
71
- const entries = await readdir(this.requestQueueDirectory).catch(() => []);
72
- for (const entry of entries) {
73
- if (entry !== '__metadata__.json') {
74
- await rm(resolve(this.requestQueueDirectory, entry), { force: true });
75
- }
76
- }
87
+ // Serialize against other mutators (and the head scans in `isEmpty`/`isFinished`) so a concurrent
88
+ // operation cannot observe or repopulate half-cleared state across the `await` below.
89
+ await this.queueStateMutex.wait();
90
+ try {
91
+ // Clear all in-memory state
92
+ this.requests.clear();
93
+ this.forefrontRequestIds = [];
94
+ this.inProgressRequestIds.clear();
95
+ this.handledRequestCount = 0;
96
+ this.pendingRequestCount = 0;
97
+ this.updateTimestamps(true);
98
+ }
99
+ finally {
100
+ this.queueStateMutex.shift();
77
101
  }
78
- this.updateTimestamps(true);
79
102
  }
80
- *requestKeyIterator(rqClient) {
103
+ *requestKeyIterator() {
81
104
  for (let i = this.forefrontRequestIds.length - 1; i >= 0; i--) {
82
105
  yield this.forefrontRequestIds[i];
83
106
  }
84
- for (const key of rqClient.requests.keys()) {
107
+ for (const key of this.requests.keys()) {
85
108
  yield key;
86
109
  }
87
110
  }
88
- async listHead(options = {}) {
89
- const { limit } = s
90
- .object({
91
- limit: s.number().optional().default(100),
92
- })
93
- .parse(options);
94
- this.updateTimestamps(false);
111
+ /**
112
+ * Scans the queue and returns the pending head — requests that are neither handled nor currently
113
+ * in progress — ordered by `orderNo`, deduplicated.
114
+ *
115
+ * When `detectInProgressRequests` is set, the result also carries an `hasInProgressRequests` flag
116
+ * telling whether any unhandled-but-in-progress request was skipped along the way. It lets
117
+ * {@link isFinished} distinguish "no work left at all" from "work remains, but it is currently being
118
+ * processed". Without it, a consumer with concurrency could consider the queue finished and shut the
119
+ * crawler down while it is still handling the last requests.
120
+ *
121
+ * Computing the flag is expensive: because an in-progress request may sit anywhere in the queue, it
122
+ * forces a scan of every pending entry even when only `limit` items are wanted. Callers that only
123
+ * need the head (e.g. {@link fetchNextRequest}, {@link isEmpty}) leave it off so the scan can stop as
124
+ * soon as the page is filled, keeping those calls O(head) instead of O(N).
125
+ */
126
+ async listPendingHead(limit, detectInProgressRequests = false) {
95
127
  const items = [];
96
- // Tracks processed request IDs to avoid duplicates when a request is in both `forefrontRequestIds` and `requests`.
128
+ let hasInProgressRequests = false;
129
+ // Tracks processed request IDs to avoid duplicates (request in both `forefrontRequestIds` and `requests`).
97
130
  const seenRequestIds = new Set();
98
131
  // Tracks handled request IDs from `forefrontRequestIds` to be removed.
99
132
  const handledForefrontIds = new Set();
100
- for (const requestId of this.requestKeyIterator(this)) {
101
- if (items.length === limit) {
133
+ for (const requestId of this.requestKeyIterator()) {
134
+ // Once the requested page is filled we can stop — unless the caller asked us to detect
135
+ // in-progress requests and we have not yet seen one, in which case we must keep scanning.
136
+ if (items.length >= limit && (!detectInProgressRequests || hasInProgressRequests)) {
102
137
  break;
103
138
  }
104
139
  if (seenRequestIds.has(requestId)) {
105
140
  continue;
106
141
  }
107
142
  seenRequestIds.add(requestId);
108
- const storageEntry = this.requests.get(requestId);
109
- let { orderNo } = storageEntry;
110
- let loaded;
111
- // Uncached entry
112
- if (typeof orderNo === 'undefined') {
113
- loaded = await storageEntry.get();
114
- orderNo = loaded.orderNo;
143
+ const request = this.requests.get(requestId);
144
+ // Permanently-handled requests (`orderNo === null`) are in a terminal state and can be skipped.
145
+ if (request.orderNo === null) {
146
+ if (this.forefrontRequestIds.includes(requestId)) {
147
+ handledForefrontIds.add(requestId);
148
+ }
149
+ continue;
115
150
  }
116
- // Have an order no -> fetch from fs/memory and return
117
- if (orderNo) {
118
- items.push(await storageEntry.get());
151
+ // In progress (fetched but not yet handled or reclaimed) — skip it, but remember that the
152
+ // queue is not truly empty.
153
+ if (this.inProgressRequestIds.has(requestId)) {
154
+ hasInProgressRequests = true;
155
+ continue;
119
156
  }
120
- else if (this.forefrontRequestIds.includes(requestId)) {
121
- handledForefrontIds.add(requestId);
157
+ if (items.length < limit) {
158
+ items.push(request);
122
159
  }
123
160
  }
124
161
  this.forefrontRequestIds = this.forefrontRequestIds.filter((id) => !handledForefrontIds.has(id));
125
162
  return {
126
- limit,
127
- hadMultipleClients: false,
128
- queueModifiedAt: this.modifiedAt,
129
- items: items.sort((a, b) => a.orderNo - b.orderNo).map(({ json }) => this._jsonToRequest(json)),
163
+ items: items.sort((a, b) => a.orderNo - b.orderNo),
164
+ hasInProgressRequests: detectInProgressRequests ? hasInProgressRequests : undefined,
130
165
  };
131
166
  }
132
- async listAndLockHead(options) {
133
- const { limit, lockSecs } = s
134
- .object({
135
- limit: s.number().lessThanOrEqual(25).optional().default(25),
136
- lockSecs: s.number(),
137
- })
138
- .parse(options);
167
+ async fetchNextRequest() {
139
168
  this.updateTimestamps(false);
140
- const start = Date.now();
141
- const isLocked = (request) => !request.orderNo || request.orderNo > start || request.orderNo < -start;
142
- const items = [];
143
- await this.mutex.wait();
169
+ await this.queueStateMutex.wait();
144
170
  try {
145
- // Tracks processed request IDs to avoid duplicates (when a request is in both `forefrontRequestIds` and `requests`).
146
- const seenRequestIds = new Set();
147
- // Tracks handled request IDs from `forefrontRequestIds` (to be all removed at once).
148
- const handledForefrontIds = new Set();
149
- for (const requestId of this.requestKeyIterator(this)) {
150
- if (items.length === limit) {
151
- break;
152
- }
153
- if (seenRequestIds.has(requestId)) {
171
+ const { items: [head], } = await this.listPendingHead(1);
172
+ if (!head) {
173
+ return null;
174
+ }
175
+ // Mark the request as in progress so it is not handed out again until it is handled or
176
+ // reclaimed. The request keeps its `orderNo` (and thus its forefront / normal ordering).
177
+ this.inProgressRequestIds.add(head.id);
178
+ return this._jsonToRequest(head.json) ?? null;
179
+ }
180
+ finally {
181
+ this.queueStateMutex.shift();
182
+ }
183
+ }
184
+ async addBatchOfRequests(requests, options = {}) {
185
+ batchRequestShapeWithoutId.parse(requests);
186
+ requestOptionsShape.parse(options);
187
+ // Serialize against other mutators (and the head scans in `isEmpty`/`isFinished`) so that the
188
+ // shared `requests` map, `forefrontRequestIds` array and request counts are not corrupted by a
189
+ // concurrent operation interleaving at one of the `await` points below.
190
+ await this.queueStateMutex.wait();
191
+ try {
192
+ const result = {
193
+ processedRequests: [],
194
+ unprocessedRequests: [],
195
+ };
196
+ for (const model of requests) {
197
+ const requestModel = this._createInternalRequest(model, options.forefront);
198
+ const existingRequestWithId = this.requests.get(requestModel.id);
199
+ if (existingRequestWithId) {
200
+ result.processedRequests.push({
201
+ requestId: existingRequestWithId.id,
202
+ uniqueKey: existingRequestWithId.uniqueKey,
203
+ wasAlreadyHandled: existingRequestWithId.orderNo === null,
204
+ wasAlreadyPresent: true,
205
+ });
154
206
  continue;
155
207
  }
156
- seenRequestIds.add(requestId);
157
- const storageEntry = this.requests.get(requestId);
158
- // This is set to null when the request has been handled, so we don't need to re-fetch from fs
159
- if (storageEntry.orderNo === null) {
160
- if (this.forefrontRequestIds.includes(requestId)) {
161
- handledForefrontIds.add(requestId);
162
- }
163
- continue;
208
+ this.requests.set(requestModel.id, requestModel);
209
+ if (requestModel.orderNo) {
210
+ this.pendingRequestCount += 1;
164
211
  }
165
- // Always fetch from fs, as this also locks and we do not want to end up in a state where another process locked the request but we have cached it as unlocked
166
- const request = await storageEntry.get(true);
167
- if (isLocked(request)) {
168
- continue;
212
+ else {
213
+ this.handledRequestCount += 1;
169
214
  }
170
- request.orderNo = (start + lockSecs * 1000) * (request.orderNo > 0 ? 1 : -1);
171
- await storageEntry.update(request);
172
- items.push(request);
215
+ if (options.forefront) {
216
+ this.forefrontRequestIds.push(requestModel.id);
217
+ }
218
+ result.processedRequests.push({
219
+ requestId: requestModel.id,
220
+ uniqueKey: requestModel.uniqueKey,
221
+ // We return wasAlreadyHandled: false even though the request may
222
+ // have been added as handled, because that's how API behaves.
223
+ wasAlreadyHandled: false,
224
+ wasAlreadyPresent: false,
225
+ });
173
226
  }
174
- this.forefrontRequestIds = this.forefrontRequestIds.filter((id) => !handledForefrontIds.has(id));
175
- return {
176
- limit,
177
- lockSecs,
178
- hadMultipleClients: false,
179
- queueModifiedAt: this.modifiedAt,
180
- items: items.map(({ json }) => this._jsonToRequest(json)),
181
- };
227
+ this.updateTimestamps(true);
228
+ return result;
182
229
  }
183
230
  finally {
184
- this.mutex.shift();
231
+ this.queueStateMutex.shift();
185
232
  }
186
233
  }
187
- async prolongRequestLock(id, options) {
188
- s.string().parse(id);
189
- const { lockSecs, forefront } = s
190
- .object({
191
- lockSecs: s.number(),
192
- forefront: s.boolean().optional().default(false),
193
- })
194
- .parse(options);
234
+ async getRequest(uniqueKey) {
235
+ s.string().parse(uniqueKey);
195
236
  this.updateTimestamps(false);
196
- const request = this.requests.get(id);
197
- const internalRequest = await request?.get();
198
- if (!internalRequest) {
199
- throw new Error(`Request with ID ${id} not found in queue ${this.name ?? this.id}`);
200
- }
201
- const canProlong = (r) => !!r.orderNo;
202
- if (!canProlong(internalRequest)) {
203
- throw new Error(`Request with ID ${id} has already been handled in queue ${this.name ?? this.id}`);
204
- }
205
- const unlockTimestamp = Math.abs(internalRequest.orderNo) + lockSecs * 1000;
206
- internalRequest.orderNo = forefront ? -unlockTimestamp : unlockTimestamp;
207
- await request?.update(internalRequest);
208
- if (forefront)
209
- this.forefrontRequestIds.push(id);
210
- return {
211
- lockExpiresAt: new Date(unlockTimestamp),
212
- };
237
+ const id = uniqueKeyToRequestId(uniqueKey);
238
+ const json = this.requests.get(id)?.json;
239
+ return this._jsonToRequest(json);
213
240
  }
214
- async deleteRequestLock(id, options = {}) {
215
- s.string().parse(id);
216
- const { forefront } = s
217
- .object({
218
- forefront: s.boolean().optional().default(false),
219
- })
220
- .parse(options);
241
+ async markRequestAsHandled(request) {
242
+ requestShape.parse(request);
221
243
  this.updateTimestamps(false);
222
- const request = this.requests.get(id);
223
- const internalRequest = await request?.get();
224
- if (!internalRequest) {
225
- throw new Error(`Request with ID ${id} not found in queue ${this.name ?? this.id}`);
226
- }
227
- const start = Date.now();
228
- // If there is no `orderNo` -> request was marked as handled
229
- const isLocked = (r) => r.orderNo && (r.orderNo > start || r.orderNo < -start);
230
- if (!isLocked(internalRequest)) {
231
- throw new Error(`Request with ID ${id} is not locked in queue ${this.name ?? this.id}`);
232
- }
233
- internalRequest.orderNo = forefront ? -start : start;
234
- if (forefront)
235
- this.forefrontRequestIds.push(id);
236
- await request?.update(internalRequest);
237
- }
238
- async addRequest(request, options = {}) {
239
- requestShapeWithoutId.parse(request);
240
- requestOptionsShape.parse(options);
241
- const requestModel = this._createInternalRequest(request, options.forefront);
242
- const existingRequestWithIdEntry = this.requests.get(requestModel.id);
243
- // We already have the request present, so we return information about it
244
- if (existingRequestWithIdEntry) {
245
- const existingRequestWithId = await existingRequestWithIdEntry.get();
246
- this.updateTimestamps(false);
244
+ // Serialize against other mutators (and the head scans in `isEmpty`/`isFinished`) so the shared
245
+ // `requests` map, `inProgressRequestIds` set and request counts stay consistent across the
246
+ // `await` points below.
247
+ await this.queueStateMutex.wait();
248
+ try {
249
+ const id = uniqueKeyToRequestId(request.uniqueKey);
250
+ const existingRequest = this.requests.get(id);
251
+ // The request must exist to be marked as handled. We intentionally do NOT require it to still
252
+ // be in progress: marking an already-released request handled must still succeed, otherwise
253
+ // the request could be handed out again and the queue would never finish.
254
+ if (!existingRequest) {
255
+ return null;
256
+ }
257
+ // A handled request has `orderNo === null`. Marking it again is an idempotent no-op.
258
+ const wasAlreadyHandled = existingRequest.orderNo === null;
259
+ const handledAt = request.handledAt ?? new Date().toISOString();
260
+ const requestModel = this._createInternalRequest({ ...request, handledAt }, false);
261
+ this.requests.set(id, requestModel);
262
+ // The request is no longer in progress for this client.
263
+ this.inProgressRequestIds.delete(id);
264
+ if (!wasAlreadyHandled) {
265
+ this.pendingRequestCount -= 1;
266
+ this.handledRequestCount += 1;
267
+ }
268
+ this.updateTimestamps(true);
247
269
  return {
248
- requestId: existingRequestWithId.id,
249
- wasAlreadyHandled: existingRequestWithId.orderNo === null,
270
+ requestId: id,
271
+ wasAlreadyHandled,
250
272
  wasAlreadyPresent: true,
251
273
  };
252
274
  }
253
- const newEntry = createRequestQueueStorageImplementation({
254
- persistStorage: this.client.persistStorage,
255
- requestId: requestModel.id,
256
- storeDirectory: this.requestQueueDirectory,
257
- });
258
- await newEntry.update(requestModel);
259
- this.requests.set(requestModel.id, newEntry);
260
- this.updateTimestamps(true);
261
- if (requestModel.orderNo) {
262
- this.pendingRequestCount += 1;
263
- }
264
- else {
265
- this.handledRequestCount += 1;
266
- }
267
- if (options.forefront) {
268
- this.forefrontRequestIds.push(requestModel.id);
275
+ finally {
276
+ this.queueStateMutex.shift();
269
277
  }
270
- return {
271
- requestId: requestModel.id,
272
- // We return wasAlreadyHandled: false even though the request may
273
- // have been added as handled, because that's how API behaves.
274
- wasAlreadyHandled: false,
275
- wasAlreadyPresent: false,
276
- };
277
278
  }
278
- async batchAddRequests(requests, options = {}) {
279
- batchRequestShapeWithoutId.parse(requests);
279
+ async reclaimRequest(request, options = {}) {
280
+ requestShape.parse(request);
280
281
  requestOptionsShape.parse(options);
281
- const result = {
282
- processedRequests: [],
283
- unprocessedRequests: [],
284
- };
285
- for (const model of requests) {
286
- const requestModel = this._createInternalRequest(model, options.forefront);
287
- const existingRequestWithIdEntry = this.requests.get(requestModel.id);
288
- if (existingRequestWithIdEntry) {
289
- const existingRequestWithId = await existingRequestWithIdEntry.get();
290
- result.processedRequests.push({
291
- requestId: existingRequestWithId.id,
292
- uniqueKey: existingRequestWithId.uniqueKey,
293
- wasAlreadyHandled: existingRequestWithId.orderNo === null,
294
- wasAlreadyPresent: true,
295
- });
296
- continue;
297
- }
298
- const newEntry = createRequestQueueStorageImplementation({
299
- persistStorage: this.client.persistStorage,
300
- requestId: requestModel.id,
301
- storeDirectory: this.requestQueueDirectory,
302
- });
303
- await newEntry.update(requestModel);
304
- this.requests.set(requestModel.id, newEntry);
305
- if (requestModel.orderNo) {
306
- this.pendingRequestCount += 1;
307
- }
308
- else {
309
- this.handledRequestCount += 1;
282
+ this.updateTimestamps(false);
283
+ // Serialize against other mutators (and the head scans in `isEmpty`/`isFinished`) so the shared
284
+ // `requests` map, `forefrontRequestIds` array and `inProgressRequestIds` set stay consistent
285
+ // across the `await` points below.
286
+ await this.queueStateMutex.wait();
287
+ try {
288
+ const id = uniqueKeyToRequestId(request.uniqueKey);
289
+ const existingRequest = this.requests.get(id);
290
+ // The request must exist and not already be handled to be reclaimed. As with
291
+ // `markRequestAsHandled`, we do NOT require it to still be in progress — returning an
292
+ // already-released request to the queue (e.g. to honor a `forefront` reorder) must still
293
+ // work, rather than have the reclaim silently dropped.
294
+ if (!existingRequest || existingRequest.orderNo === null) {
295
+ return null;
310
296
  }
297
+ // Reclaiming resets the `orderNo` to a fresh timestamp, restoring the request to the queue
298
+ // (at the front if `forefront`).
299
+ const requestModel = this._createInternalRequest(request, options.forefront);
300
+ this.requests.set(id, requestModel);
301
+ // The request is no longer in progress for this client.
302
+ this.inProgressRequestIds.delete(id);
311
303
  if (options.forefront) {
312
- this.forefrontRequestIds.push(requestModel.id);
304
+ this.forefrontRequestIds.push(id);
313
305
  }
314
- result.processedRequests.push({
315
- requestId: requestModel.id,
316
- uniqueKey: requestModel.uniqueKey,
317
- // We return wasAlreadyHandled: false even though the request may
318
- // have been added as handled, because that's how API behaves.
306
+ this.updateTimestamps(true);
307
+ return {
308
+ requestId: id,
319
309
  wasAlreadyHandled: false,
320
- wasAlreadyPresent: false,
321
- });
310
+ wasAlreadyPresent: true,
311
+ };
312
+ }
313
+ finally {
314
+ this.queueStateMutex.shift();
322
315
  }
323
- this.updateTimestamps(true);
324
- return result;
325
316
  }
326
- async getRequest(id) {
327
- s.string().parse(id);
317
+ async isEmpty() {
328
318
  this.updateTimestamps(false);
329
- const json = (await this.requests.get(id)?.get())?.json;
330
- return this._jsonToRequest(json);
331
- }
332
- async updateRequest(request, options = {}) {
333
- requestShape.parse(request);
334
- requestOptionsShape.parse(options);
335
- const requestModel = this._createInternalRequest(request, options.forefront);
336
- // First we need to check the existing request to be
337
- // able to return information about its handled state.
338
- const existingRequestEntry = this.requests.get(requestModel.id);
339
- // Undefined means that the request is not present in the queue.
340
- // We need to insert it, to behave the same as API.
341
- if (!existingRequestEntry) {
342
- return this.addRequest(request, options);
319
+ // "Empty" means there is nothing left to fetch right now — i.e. the next `fetchNextRequest`
320
+ // would return `null`. Requests that are currently in progress are intentionally NOT counted
321
+ // here: they are not fetchable, so the queue is empty from a consumer's point of view. Whether
322
+ // those in-progress requests mean crawling is not yet done is a separate question, answered by
323
+ // `isFinished`.
324
+ //
325
+ // `listPendingHead` prunes `forefrontRequestIds` as it scans, so we must hold the queue-state mutex to avoid
326
+ // racing a concurrent mutator (e.g. `addBatchOfRequests`) at its `await` points.
327
+ await this.queueStateMutex.wait();
328
+ try {
329
+ const { items } = await this.listPendingHead(1);
330
+ return items.length === 0;
343
331
  }
344
- const existingRequest = await existingRequestEntry.get();
345
- const newEntry = createRequestQueueStorageImplementation({
346
- persistStorage: this.client.persistStorage,
347
- requestId: requestModel.id,
348
- storeDirectory: this.requestQueueDirectory,
349
- });
350
- await newEntry.update(requestModel);
351
- // When updating the request, we need to make sure that
352
- // the handled counts are updated correctly in all cases.
353
- this.requests.set(requestModel.id, newEntry);
354
- const isRequestHandledStateChanging = typeof existingRequest.orderNo !== typeof requestModel.orderNo;
355
- const requestWasHandledBeforeUpdate = existingRequest.orderNo === null;
356
- const requestIsHandledAfterUpdate = requestModel.orderNo === null;
357
- if (isRequestHandledStateChanging) {
358
- this.pendingRequestCount += requestWasHandledBeforeUpdate ? 1 : -1;
332
+ finally {
333
+ this.queueStateMutex.shift();
359
334
  }
360
- if (requestIsHandledAfterUpdate) {
361
- this.handledRequestCount += 1;
335
+ }
336
+ async isFinished() {
337
+ this.updateTimestamps(false);
338
+ // The queue is finished only when there is nothing left to fetch AND nothing currently in
339
+ // progress. Counting in-progress requests is what allows a crawler with concurrency to keep
340
+ // waiting while it still holds the last requests, instead of finishing prematurely.
341
+ //
342
+ // Detecting in-progress requests requires a full scan, hence the `detectInProgressRequests`
343
+ // flag — unlike `fetchNextRequest`/`isEmpty`, which only need the head and can stop early.
344
+ //
345
+ // `listPendingHead` prunes `forefrontRequestIds` as it scans, so we must hold the queue-state mutex to avoid
346
+ // racing a concurrent mutator (e.g. `addBatchOfRequests`) at its `await` points.
347
+ await this.queueStateMutex.wait();
348
+ try {
349
+ const { items, hasInProgressRequests } = await this.listPendingHead(1, true);
350
+ return items.length === 0 && !hasInProgressRequests;
362
351
  }
363
- this.updateTimestamps(true);
364
- if (options.forefront && !requestIsHandledAfterUpdate) {
365
- this.forefrontRequestIds.push(requestModel.id);
352
+ finally {
353
+ this.queueStateMutex.shift();
366
354
  }
367
- return {
368
- requestId: requestModel.id,
369
- wasAlreadyHandled: requestWasHandledBeforeUpdate,
370
- wasAlreadyPresent: true,
371
- };
372
355
  }
373
356
  toRequestQueueInfo() {
374
357
  return {
@@ -390,19 +373,6 @@ export class RequestQueueClient extends BaseClient {
390
373
  if (hasBeenModified) {
391
374
  this.modifiedAt = new Date();
392
375
  }
393
- const data = {
394
- ...this.toRequestQueueInfo(),
395
- forefrontRequestIds: this.forefrontRequestIds,
396
- };
397
- scheduleBackgroundTask({
398
- action: 'update-metadata',
399
- data,
400
- entityType: 'requestQueues',
401
- entityDirectory: this.requestQueueDirectory,
402
- id: this.name ?? this.id,
403
- writeMetadata: this.client.writeMetadata,
404
- persistStorage: this.client.persistStorage,
405
- }, this.client.logger);
406
376
  }
407
377
  _jsonToRequest(requestJson) {
408
378
  if (!requestJson)