scrapely 1.2.3 → 1.2.4
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.
- package/README.md +119 -1
- package/dist/actor.d.ts +703 -0
- package/dist/actor.js +1441 -0
- package/dist/browser-configuration.d.ts +58 -0
- package/dist/browser-configuration.js +128 -0
- package/dist/browser-locations.d.ts +5 -0
- package/dist/browser-locations.js +5469 -0
- package/dist/charging.d.ts +153 -0
- package/dist/charging.js +782 -0
- package/dist/charging_client.d.ts +18 -0
- package/dist/charging_client.js +49 -0
- package/dist/configuration.d.ts +121 -0
- package/dist/configuration.js +128 -0
- package/dist/crypto/secrets.d.ts +44 -0
- package/dist/crypto/secrets.js +164 -0
- package/dist/events/events-client.d.ts +59 -0
- package/dist/events/events-client.js +120 -0
- package/dist/events/scrapely-event-manager.d.ts +13 -0
- package/dist/events/scrapely-event-manager.js +61 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +95 -0
- package/dist/log-env.d.ts +1 -0
- package/dist/log-env.js +12 -0
- package/dist/manifest.d.ts +30 -0
- package/dist/manifest.js +47 -0
- package/dist/patch-browser-pool.d.ts +66 -0
- package/dist/patch-browser-pool.js +827 -0
- package/dist/proxy-configuration.d.ts +118 -0
- package/dist/proxy-configuration.js +88 -0
- package/dist/request-list.d.ts +57 -0
- package/dist/request-list.js +79 -0
- package/dist/runner/docker-manager.d.ts +88 -0
- package/dist/runner/docker-manager.js +283 -0
- package/dist/runner/index.d.ts +2 -0
- package/dist/runner/index.js +7 -0
- package/dist/runner/timeout-manager.d.ts +43 -0
- package/dist/runner/timeout-manager.js +90 -0
- package/dist/scrapely-proxy-configuration.d.ts +99 -0
- package/dist/scrapely-proxy-configuration.js +559 -0
- package/dist/storage/api-storage-client.d.ts +199 -0
- package/dist/storage/api-storage-client.js +910 -0
- package/dist/storage/billing-tracker.d.ts +163 -0
- package/dist/storage/billing-tracker.js +260 -0
- package/dist/storage/dataset-client.d.ts +43 -0
- package/dist/storage/dataset-client.js +232 -0
- package/dist/storage/dataset-collection-client.d.ts +8 -0
- package/dist/storage/dataset-collection-client.js +75 -0
- package/dist/storage/dataset-push-script.d.ts +18 -0
- package/dist/storage/dataset-push-script.js +38 -0
- package/dist/storage/dragonfly-client.d.ts +94 -0
- package/dist/storage/dragonfly-client.js +167 -0
- package/dist/storage/index.d.ts +11 -0
- package/dist/storage/index.js +25 -0
- package/dist/storage/key-value-store-client.d.ts +61 -0
- package/dist/storage/key-value-store-client.js +306 -0
- package/dist/storage/kv-store-collection-client.d.ts +8 -0
- package/dist/storage/kv-store-collection-client.js +76 -0
- package/dist/storage/kvs-iteration.d.ts +17 -0
- package/dist/storage/kvs-iteration.js +126 -0
- package/dist/storage/log-manager.d.ts +114 -0
- package/dist/storage/log-manager.js +244 -0
- package/dist/storage/patch-storage.d.ts +11 -0
- package/dist/storage/patch-storage.js +177 -0
- package/dist/storage/request-queue-client.d.ts +93 -0
- package/dist/storage/request-queue-client.js +522 -0
- package/dist/storage/rq-collection-client.d.ts +8 -0
- package/dist/storage/rq-collection-client.js +89 -0
- package/dist/storage/rq-list-requests.d.ts +59 -0
- package/dist/storage/rq-list-requests.js +114 -0
- package/dist/storage/rq-lock-script.d.ts +56 -0
- package/dist/storage/rq-lock-script.js +127 -0
- package/dist/storage/run-metadata.d.ts +98 -0
- package/dist/storage/run-metadata.js +256 -0
- package/dist/storage/s3-client.d.ts +103 -0
- package/dist/storage/s3-client.js +302 -0
- package/dist/storage/ttl-manager.d.ts +70 -0
- package/dist/storage/ttl-manager.js +115 -0
- package/dist/storage/utils.d.ts +3 -0
- package/dist/storage/utils.js +19 -0
- package/dist/storage-identifiers.d.ts +20 -0
- package/dist/storage-identifiers.js +6 -0
- package/dist/utils.d.ts +54 -0
- package/dist/utils.js +63 -0
- package/dist/validator.d.ts +24 -0
- package/dist/validator.js +158 -0
- package/package.json +90 -9
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DragonflyRequestQueueClient = void 0;
|
|
4
|
+
const utils_js_1 = require("./utils.js");
|
|
5
|
+
const billing_tracker_js_1 = require("./billing-tracker.js");
|
|
6
|
+
const rq_lock_script_js_1 = require("./rq-lock-script.js");
|
|
7
|
+
const rq_list_requests_js_1 = require("./rq-list-requests.js");
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
9
|
+
class DragonflyRequestQueueClient {
|
|
10
|
+
client;
|
|
11
|
+
id;
|
|
12
|
+
infoKey;
|
|
13
|
+
requestsKey;
|
|
14
|
+
pendingKey;
|
|
15
|
+
lockedKey;
|
|
16
|
+
lockOwnersKey;
|
|
17
|
+
clientKey;
|
|
18
|
+
constructor(client, id, options) {
|
|
19
|
+
this.client = client;
|
|
20
|
+
this.id = id;
|
|
21
|
+
this.clientKey = options?.clientKey || (0, node_crypto_1.randomUUID)().replace(/-/g, '').slice(0, 16);
|
|
22
|
+
this.infoKey = client.key('rq', id, 'info');
|
|
23
|
+
this.requestsKey = client.key('rq', id, 'requests');
|
|
24
|
+
this.pendingKey = client.key('rq', id, 'pending');
|
|
25
|
+
this.lockedKey = client.key('rq', id, 'locked');
|
|
26
|
+
this.lockOwnersKey = client.key('rq', id, 'lockOwners');
|
|
27
|
+
}
|
|
28
|
+
estimateStoredRequestBytes(raw) {
|
|
29
|
+
return raw ? Buffer.byteLength(raw, 'utf8') : 0;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Track a write operation for billing.
|
|
33
|
+
*/
|
|
34
|
+
async trackWrite(count = 1) {
|
|
35
|
+
if (this.client.billingContext) {
|
|
36
|
+
const billingTracker = new billing_tracker_js_1.BillingTracker(this.client);
|
|
37
|
+
await billingTracker.trackWrite(this.client.billingContext.userId, count);
|
|
38
|
+
await this.client.redis.hincrby(this.infoKey, 'writeCount', count);
|
|
39
|
+
// Register this queue as accessed for per-run aggregation
|
|
40
|
+
await this.client.redis.sadd(this.client.key('run', this.client.billingContext.runId, 'rqs_accessed'), this.id);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Track a read operation for billing.
|
|
45
|
+
*/
|
|
46
|
+
async trackRead(count = 1, bytesTransferred = 0) {
|
|
47
|
+
if (this.client.billingContext) {
|
|
48
|
+
const billingTracker = new billing_tracker_js_1.BillingTracker(this.client);
|
|
49
|
+
await billingTracker.trackRead(this.client.billingContext.userId, count);
|
|
50
|
+
await this.client.redis.hincrby(this.infoKey, 'readCount', count);
|
|
51
|
+
// Track internal data transfer (bytes returned to actor)
|
|
52
|
+
if (bytesTransferred > 0) {
|
|
53
|
+
await billingTracker.trackInternalTransfer(this.client.billingContext.runId, this.client.billingContext.userId, bytesTransferred);
|
|
54
|
+
}
|
|
55
|
+
// Register this queue as accessed for per-run aggregation
|
|
56
|
+
await this.client.redis.sadd(this.client.key('run', this.client.billingContext.runId, 'rqs_accessed'), this.id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Track storage bytes for billing.
|
|
61
|
+
*/
|
|
62
|
+
async trackStorageBytes(bytes) {
|
|
63
|
+
if (this.client.billingContext) {
|
|
64
|
+
const billingTracker = new billing_tracker_js_1.BillingTracker(this.client);
|
|
65
|
+
await billingTracker.trackStorageBytes(this.client.billingContext.runId, this.client.billingContext.userId, bytes);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Track clientKey access for hadMultipleClients (per Apify spec).
|
|
70
|
+
* Returns the current hadMultipleClients flag after update.
|
|
71
|
+
*/
|
|
72
|
+
async trackClientKey(clientKey) {
|
|
73
|
+
if (clientKey) {
|
|
74
|
+
const existingClientKey = await this.client.redis.hget(this.infoKey, 'clientKey');
|
|
75
|
+
if (existingClientKey && existingClientKey !== clientKey) {
|
|
76
|
+
await this.client.redis.hset(this.infoKey, 'hadMultipleClients', '1');
|
|
77
|
+
}
|
|
78
|
+
else if (!existingClientKey) {
|
|
79
|
+
await this.client.redis.hset(this.infoKey, 'clientKey', clientKey);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const existingClientKey = await this.client.redis.hget(this.infoKey, 'clientKey');
|
|
84
|
+
if (existingClientKey) {
|
|
85
|
+
await this.client.redis.hset(this.infoKey, 'hadMultipleClients', '1');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const flag = await this.client.redis.hget(this.infoKey, 'hadMultipleClients');
|
|
89
|
+
return flag === '1';
|
|
90
|
+
}
|
|
91
|
+
async get() {
|
|
92
|
+
const info = await this.client.redis.hgetall(this.infoKey);
|
|
93
|
+
if (!info || !info.id)
|
|
94
|
+
return undefined;
|
|
95
|
+
return {
|
|
96
|
+
id: info.id,
|
|
97
|
+
name: info.name || undefined,
|
|
98
|
+
userId: info.userId,
|
|
99
|
+
createdAt: new Date(info.createdAt),
|
|
100
|
+
modifiedAt: new Date(info.modifiedAt),
|
|
101
|
+
accessedAt: new Date(info.accessedAt),
|
|
102
|
+
totalRequestCount: parseInt(info.totalRequestCount || '0', 10),
|
|
103
|
+
handledRequestCount: parseInt(info.handledRequestCount || '0', 10),
|
|
104
|
+
pendingRequestCount: parseInt(info.pendingRequestCount || '0', 10),
|
|
105
|
+
hadMultipleClients: info.hadMultipleClients === '1',
|
|
106
|
+
stats: {
|
|
107
|
+
readCount: parseInt(info.readCount || '0', 10),
|
|
108
|
+
writeCount: parseInt(info.writeCount || '0', 10),
|
|
109
|
+
deleteCount: parseInt(info.deleteCount || '0', 10),
|
|
110
|
+
headItemReadCount: parseInt(info.headItemReadCount || '0', 10),
|
|
111
|
+
storageBytes: parseInt(info.storageBytes || '0', 10),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async update(newFields) {
|
|
116
|
+
if (newFields.name !== undefined) {
|
|
117
|
+
await this.client.redis.hset(this.infoKey, 'name', newFields.name);
|
|
118
|
+
const indexKey = this.client.key('rq:index');
|
|
119
|
+
const raw = await this.client.redis.hget(indexKey, `id:${this.id}`);
|
|
120
|
+
if (raw) {
|
|
121
|
+
const data = JSON.parse(raw);
|
|
122
|
+
if (data.name)
|
|
123
|
+
await this.client.redis.hdel(indexKey, `name:${data.name}`);
|
|
124
|
+
data.name = newFields.name;
|
|
125
|
+
await this.client.redis.hset(indexKey, `id:${this.id}`, JSON.stringify(data));
|
|
126
|
+
await this.client.redis.hset(indexKey, `name:${newFields.name}`, JSON.stringify(data));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
await this.client.redis.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
|
|
130
|
+
return { name: newFields.name };
|
|
131
|
+
}
|
|
132
|
+
async delete() {
|
|
133
|
+
const info = await this.client.redis.hgetall(this.infoKey);
|
|
134
|
+
await this.client.redis.del(this.infoKey, this.requestsKey, this.pendingKey, this.lockedKey, this.lockOwnersKey);
|
|
135
|
+
const indexKey = this.client.key('rq:index');
|
|
136
|
+
await this.client.redis.hdel(indexKey, `id:${this.id}`);
|
|
137
|
+
if (info?.name)
|
|
138
|
+
await this.client.redis.hdel(indexKey, `name:${info.name}`);
|
|
139
|
+
}
|
|
140
|
+
async addRequest(request, options) {
|
|
141
|
+
const requestId = request.id || (0, utils_js_1.uniqueKeyToRequestId)(request.uniqueKey);
|
|
142
|
+
const existing = await this.client.redis.hget(this.requestsKey, requestId);
|
|
143
|
+
if (existing) {
|
|
144
|
+
const parsed = JSON.parse(existing);
|
|
145
|
+
return {
|
|
146
|
+
requestId,
|
|
147
|
+
wasAlreadyPresent: true,
|
|
148
|
+
wasAlreadyHandled: !!parsed.handledAt,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const stored = { ...request, id: requestId };
|
|
152
|
+
const score = options?.forefront ? 0 : Date.now();
|
|
153
|
+
const pipeline = this.client.redis.pipeline();
|
|
154
|
+
pipeline.hset(this.requestsKey, requestId, JSON.stringify(stored));
|
|
155
|
+
pipeline.zadd(this.pendingKey, String(score), requestId);
|
|
156
|
+
pipeline.hincrby(this.infoKey, 'totalRequestCount', 1);
|
|
157
|
+
pipeline.hincrby(this.infoKey, 'pendingRequestCount', 1);
|
|
158
|
+
pipeline.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
|
|
159
|
+
await pipeline.exec();
|
|
160
|
+
// Track write and storage bytes for billing
|
|
161
|
+
const bytes = billing_tracker_js_1.BillingTracker.estimateRequestBytes(stored);
|
|
162
|
+
await Promise.all([
|
|
163
|
+
this.trackWrite(1),
|
|
164
|
+
this.trackStorageBytes(bytes),
|
|
165
|
+
]);
|
|
166
|
+
// Track internal data transfer (bytes written to storage)
|
|
167
|
+
if (this.client.billingContext) {
|
|
168
|
+
const billingTracker = new billing_tracker_js_1.BillingTracker(this.client);
|
|
169
|
+
await billingTracker.trackInternalTransfer(this.client.billingContext.runId, this.client.billingContext.userId, bytes);
|
|
170
|
+
}
|
|
171
|
+
return { requestId, wasAlreadyPresent: false, wasAlreadyHandled: false };
|
|
172
|
+
}
|
|
173
|
+
async batchAddRequests(requests, options) {
|
|
174
|
+
const processedRequests = [];
|
|
175
|
+
const unprocessedRequests = [];
|
|
176
|
+
for (const request of requests) {
|
|
177
|
+
try {
|
|
178
|
+
const result = await this.addRequest(request, options);
|
|
179
|
+
processedRequests.push({
|
|
180
|
+
uniqueKey: request.uniqueKey,
|
|
181
|
+
requestId: result.requestId,
|
|
182
|
+
wasAlreadyPresent: result.wasAlreadyPresent,
|
|
183
|
+
wasAlreadyHandled: result.wasAlreadyHandled,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
unprocessedRequests.push({
|
|
188
|
+
uniqueKey: request.uniqueKey,
|
|
189
|
+
url: request.url,
|
|
190
|
+
method: request.method,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { processedRequests, unprocessedRequests };
|
|
195
|
+
}
|
|
196
|
+
async getRequest(id) {
|
|
197
|
+
const raw = await this.client.redis.hget(this.requestsKey, id);
|
|
198
|
+
if (!raw)
|
|
199
|
+
return undefined;
|
|
200
|
+
// Track read for billing with bytes transferred
|
|
201
|
+
const bytesTransferred = Buffer.byteLength(raw, 'utf8');
|
|
202
|
+
await this.trackRead(1, bytesTransferred);
|
|
203
|
+
return JSON.parse(raw);
|
|
204
|
+
}
|
|
205
|
+
async updateRequest(request, options) {
|
|
206
|
+
const existing = await this.client.redis.hget(this.requestsKey, request.id);
|
|
207
|
+
if (!existing) {
|
|
208
|
+
return this.addRequest(request, options);
|
|
209
|
+
}
|
|
210
|
+
const old = JSON.parse(existing);
|
|
211
|
+
const wasHandled = !!old.handledAt;
|
|
212
|
+
const isNowHandled = !!request.handledAt;
|
|
213
|
+
const updatedRequest = { ...old, ...request };
|
|
214
|
+
const oldBytes = this.estimateStoredRequestBytes(existing);
|
|
215
|
+
const newBytes = billing_tracker_js_1.BillingTracker.estimateRequestBytes(updatedRequest);
|
|
216
|
+
const deltaBytes = newBytes - oldBytes;
|
|
217
|
+
await this.client.redis.hset(this.requestsKey, request.id, JSON.stringify(updatedRequest));
|
|
218
|
+
if (!wasHandled && isNowHandled) {
|
|
219
|
+
const pipeline = this.client.redis.pipeline();
|
|
220
|
+
pipeline.zrem(this.pendingKey, request.id);
|
|
221
|
+
pipeline.zrem(this.lockedKey, request.id);
|
|
222
|
+
pipeline.hdel(this.lockOwnersKey, request.id);
|
|
223
|
+
pipeline.hincrby(this.infoKey, 'handledRequestCount', 1);
|
|
224
|
+
pipeline.hincrby(this.infoKey, 'pendingRequestCount', -1);
|
|
225
|
+
pipeline.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
|
|
226
|
+
await pipeline.exec();
|
|
227
|
+
}
|
|
228
|
+
else if (wasHandled && !isNowHandled) {
|
|
229
|
+
const score = options?.forefront ? 0 : Date.now();
|
|
230
|
+
const pipeline = this.client.redis.pipeline();
|
|
231
|
+
pipeline.zadd(this.pendingKey, String(score), request.id);
|
|
232
|
+
pipeline.hincrby(this.infoKey, 'handledRequestCount', -1);
|
|
233
|
+
pipeline.hincrby(this.infoKey, 'pendingRequestCount', 1);
|
|
234
|
+
pipeline.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
|
|
235
|
+
await pipeline.exec();
|
|
236
|
+
}
|
|
237
|
+
if (deltaBytes !== 0) {
|
|
238
|
+
await this.trackStorageBytes(deltaBytes);
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
requestId: request.id,
|
|
242
|
+
wasAlreadyPresent: true,
|
|
243
|
+
wasAlreadyHandled: wasHandled,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
async deleteRequest(id) {
|
|
247
|
+
const raw = await this.client.redis.hget(this.requestsKey, id);
|
|
248
|
+
if (!raw)
|
|
249
|
+
return;
|
|
250
|
+
const request = JSON.parse(raw);
|
|
251
|
+
const releasedBytes = this.estimateStoredRequestBytes(raw);
|
|
252
|
+
const pipeline = this.client.redis.pipeline();
|
|
253
|
+
pipeline.hdel(this.requestsKey, id);
|
|
254
|
+
pipeline.zrem(this.pendingKey, id);
|
|
255
|
+
pipeline.zrem(this.lockedKey, id);
|
|
256
|
+
pipeline.hdel(this.lockOwnersKey, id);
|
|
257
|
+
pipeline.hincrby(this.infoKey, 'totalRequestCount', -1);
|
|
258
|
+
if (request.handledAt) {
|
|
259
|
+
pipeline.hincrby(this.infoKey, 'handledRequestCount', -1);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
pipeline.hincrby(this.infoKey, 'pendingRequestCount', -1);
|
|
263
|
+
}
|
|
264
|
+
pipeline.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
|
|
265
|
+
await pipeline.exec();
|
|
266
|
+
if (releasedBytes > 0) {
|
|
267
|
+
await this.trackStorageBytes(-releasedBytes);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async listHead(options) {
|
|
271
|
+
const limit = options?.limit ?? 100;
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
// Two round-trips total (same pattern as listRequestsPage): one
|
|
274
|
+
// pipeline for the pending head + all actively locked IDs (replaces
|
|
275
|
+
// the per-id ZSCORE loop), then a single HMGET for the request bodies
|
|
276
|
+
// (replaces the per-id HGET loop).
|
|
277
|
+
const pipeline = this.client.redis.pipeline();
|
|
278
|
+
// ioredis 6 types: stop must be string | Buffer (not number)
|
|
279
|
+
pipeline.zrange(this.pendingKey, 0, String(limit - 1));
|
|
280
|
+
// The locked ZSET is scored by lockUntil ms, so one exclusive-bound
|
|
281
|
+
// range read equals the old per-id ZSCORE > now check.
|
|
282
|
+
pipeline.zrangebyscore(this.lockedKey, `(${now}`, '+inf');
|
|
283
|
+
const results = await pipeline.exec();
|
|
284
|
+
const at = (i) => {
|
|
285
|
+
const value = results?.[i]?.[1];
|
|
286
|
+
return Array.isArray(value) ? value : [];
|
|
287
|
+
};
|
|
288
|
+
const ids = at(0);
|
|
289
|
+
const activeLocks = new Set(at(1));
|
|
290
|
+
// Filter out locked items
|
|
291
|
+
const items = [];
|
|
292
|
+
const candidates = ids.filter((id) => !activeLocks.has(id)).slice(0, limit);
|
|
293
|
+
if (candidates.length > 0) {
|
|
294
|
+
const raws = await this.client.redis.hmget(this.requestsKey, ...candidates);
|
|
295
|
+
for (const raw of raws) {
|
|
296
|
+
if (!raw)
|
|
297
|
+
continue;
|
|
298
|
+
const req = JSON.parse(raw);
|
|
299
|
+
items.push({
|
|
300
|
+
id: req.id,
|
|
301
|
+
retryCount: req.retryCount ?? 0,
|
|
302
|
+
uniqueKey: req.uniqueKey,
|
|
303
|
+
url: req.url,
|
|
304
|
+
method: req.method ?? 'GET',
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// Track reads for billing with bytes transferred
|
|
309
|
+
if (items.length > 0) {
|
|
310
|
+
const totalBytes = items.reduce((sum, item) => sum + JSON.stringify(item).length, 0);
|
|
311
|
+
await this.trackRead(items.length, totalBytes);
|
|
312
|
+
}
|
|
313
|
+
const modifiedAt = await this.client.redis.hget(this.infoKey, 'modifiedAt');
|
|
314
|
+
const hadMultipleClients = await this.trackClientKey(this.clientKey);
|
|
315
|
+
return {
|
|
316
|
+
limit,
|
|
317
|
+
queueModifiedAt: modifiedAt ? new Date(modifiedAt) : (0, utils_js_1.nowISO)(),
|
|
318
|
+
hadMultipleClients,
|
|
319
|
+
items,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* List all requests in the queue with optional filtering and pagination.
|
|
324
|
+
* Supports cursor-based pagination (preferred) and deprecated exclusiveStartId.
|
|
325
|
+
* @param options.limit - Max number of requests to return (default 100, max 10000)
|
|
326
|
+
* @param options.cursor - Base64-encoded cursor from previous response.
|
|
327
|
+
* If the cursor's request was deleted, listing resumes just past its
|
|
328
|
+
* sorted position (it never restarts from the first page).
|
|
329
|
+
* @param options.exclusiveStartId - DEPRECATED: Skip requests up to this ID
|
|
330
|
+
* @param options.filter - Filter by state: 'locked' or 'pending'
|
|
331
|
+
* @param options.clientKey - Client identifier for hadMultipleClients tracking
|
|
332
|
+
*/
|
|
333
|
+
async listRequests(options) {
|
|
334
|
+
const limit = Math.min(options?.limit ?? 100, 10000);
|
|
335
|
+
const now = Date.now();
|
|
336
|
+
// Validate that filter is not used with deprecated exclusiveStartId (per Apify spec)
|
|
337
|
+
if (options?.filter && options.filter.length > 0 && options?.exclusiveStartId) {
|
|
338
|
+
throw new Error('filter parameter is not compatible with deprecated exclusiveStartId parameter');
|
|
339
|
+
}
|
|
340
|
+
// Track clientKey for hadMultipleClients (per Apify spec)
|
|
341
|
+
if (options?.clientKey) {
|
|
342
|
+
const existingClientKey = await this.client.redis.hget(this.infoKey, 'clientKey');
|
|
343
|
+
if (existingClientKey && existingClientKey !== options.clientKey) {
|
|
344
|
+
await this.client.redis.hset(this.infoKey, 'hadMultipleClients', '1');
|
|
345
|
+
}
|
|
346
|
+
else if (!existingClientKey) {
|
|
347
|
+
await this.client.redis.hset(this.infoKey, 'clientKey', options.clientKey);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
// No clientKey provided - treat as new client per Apify spec
|
|
352
|
+
const existingClientKey = await this.client.redis.hget(this.infoKey, 'clientKey');
|
|
353
|
+
if (existingClientKey) {
|
|
354
|
+
await this.client.redis.hset(this.infoKey, 'hadMultipleClients', '1');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// Decode cursor to get starting position
|
|
358
|
+
let startId;
|
|
359
|
+
if (options?.cursor) {
|
|
360
|
+
try {
|
|
361
|
+
const decoded = JSON.parse(Buffer.from(options.cursor, 'base64').toString('utf8'));
|
|
362
|
+
startId = decoded.requestId;
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
// Invalid cursor, start from beginning
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
else if (options?.exclusiveStartId) {
|
|
369
|
+
// Deprecated but still supported
|
|
370
|
+
startId = options.exclusiveStartId;
|
|
371
|
+
}
|
|
372
|
+
// Fetch, filter and paginate via the shared helper. Two round-trips
|
|
373
|
+
// total: one pipeline (HKEYS + only the ZSET reads the filter needs)
|
|
374
|
+
// and one HMGET of the page's request bodies.
|
|
375
|
+
const { items, hasMore } = await (0, rq_list_requests_js_1.listRequestsPage)(this.client.redis, {
|
|
376
|
+
requestsKey: this.requestsKey,
|
|
377
|
+
pendingKey: this.pendingKey,
|
|
378
|
+
lockedKey: this.lockedKey,
|
|
379
|
+
}, {
|
|
380
|
+
now,
|
|
381
|
+
limit,
|
|
382
|
+
startId,
|
|
383
|
+
filter: options?.filter,
|
|
384
|
+
});
|
|
385
|
+
// Track reads for billing
|
|
386
|
+
if (items.length > 0) {
|
|
387
|
+
const totalBytes = items.reduce((sum, item) => sum + JSON.stringify(item).length, 0);
|
|
388
|
+
await this.trackRead(items.length, totalBytes);
|
|
389
|
+
}
|
|
390
|
+
// Build response
|
|
391
|
+
const result = {
|
|
392
|
+
items,
|
|
393
|
+
count: items.length,
|
|
394
|
+
limit,
|
|
395
|
+
};
|
|
396
|
+
// Set exclusiveStartId for backward compatibility
|
|
397
|
+
if (startId) {
|
|
398
|
+
result.exclusiveStartId = startId;
|
|
399
|
+
}
|
|
400
|
+
// Set cursor in response if one was provided
|
|
401
|
+
if (options?.cursor) {
|
|
402
|
+
result.cursor = options.cursor;
|
|
403
|
+
}
|
|
404
|
+
// Set nextCursor if there are more results
|
|
405
|
+
if (hasMore && items.length > 0) {
|
|
406
|
+
const lastItem = items[items.length - 1];
|
|
407
|
+
result.nextCursor = Buffer.from(JSON.stringify({ requestId: lastItem.id })).toString('base64');
|
|
408
|
+
}
|
|
409
|
+
return result;
|
|
410
|
+
}
|
|
411
|
+
async listAndLockHead(options) {
|
|
412
|
+
const limit = Math.min(options.limit ?? 25, 25);
|
|
413
|
+
const now = Date.now();
|
|
414
|
+
const lockUntil = now + options.lockSecs * 1000;
|
|
415
|
+
const clientKey = options.clientKey || this.clientKey;
|
|
416
|
+
const runId = this.client.billingContext?.runId || null;
|
|
417
|
+
const ownerValue = runId ? `${clientKey}:${runId}` : clientKey;
|
|
418
|
+
const { lockedIds, activeLockCount } = await (0, rq_lock_script_js_1.listAndLockHeadAtomic)(this.client.redis, {
|
|
419
|
+
pendingKey: this.pendingKey,
|
|
420
|
+
lockedKey: this.lockedKey,
|
|
421
|
+
lockOwnersKey: this.lockOwnersKey,
|
|
422
|
+
requestsKey: this.requestsKey,
|
|
423
|
+
}, {
|
|
424
|
+
now,
|
|
425
|
+
lockUntil,
|
|
426
|
+
limit,
|
|
427
|
+
ownerValue,
|
|
428
|
+
});
|
|
429
|
+
const items = [];
|
|
430
|
+
if (lockedIds.length > 0) {
|
|
431
|
+
// Single HMGET instead of one HGET round-trip per locked ID
|
|
432
|
+
const raws = await this.client.redis.hmget(this.requestsKey, ...lockedIds);
|
|
433
|
+
for (const raw of raws) {
|
|
434
|
+
if (!raw)
|
|
435
|
+
continue;
|
|
436
|
+
const req = JSON.parse(raw);
|
|
437
|
+
items.push({
|
|
438
|
+
id: req.id,
|
|
439
|
+
retryCount: req.retryCount ?? 0,
|
|
440
|
+
uniqueKey: req.uniqueKey,
|
|
441
|
+
url: req.url,
|
|
442
|
+
method: req.method ?? 'GET',
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (items.length > 0) {
|
|
447
|
+
const totalBytes = items.reduce((sum, item) => sum + JSON.stringify(item).length, 0);
|
|
448
|
+
await this.trackRead(items.length, totalBytes);
|
|
449
|
+
}
|
|
450
|
+
const modifiedAt = await this.client.redis.hget(this.infoKey, 'modifiedAt');
|
|
451
|
+
const hadMultipleClients = await this.trackClientKey(clientKey);
|
|
452
|
+
return {
|
|
453
|
+
limit,
|
|
454
|
+
queueModifiedAt: modifiedAt ? new Date(modifiedAt) : (0, utils_js_1.nowISO)(),
|
|
455
|
+
hadMultipleClients,
|
|
456
|
+
lockSecs: options.lockSecs,
|
|
457
|
+
items,
|
|
458
|
+
// From the atomic lock script: active locks held by ANY client
|
|
459
|
+
// (including this call's own), per the Apify spec.
|
|
460
|
+
queueHasLockedRequests: activeLockCount > 0,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
async prolongRequestLock(id, options) {
|
|
464
|
+
// One pipeline for the three reads instead of sequential round-trips
|
|
465
|
+
const pipeline = this.client.redis.pipeline();
|
|
466
|
+
pipeline.hget(this.requestsKey, id);
|
|
467
|
+
pipeline.zscore(this.lockedKey, id);
|
|
468
|
+
pipeline.hget(this.lockOwnersKey, id);
|
|
469
|
+
const results = await pipeline.exec();
|
|
470
|
+
const raw = results?.[0]?.[1] ?? null;
|
|
471
|
+
const lockScore = results?.[1]?.[1] ?? null;
|
|
472
|
+
const owner = results?.[2]?.[1] ?? null;
|
|
473
|
+
if (!raw) {
|
|
474
|
+
throw new Error(`Request with ID ${id} not found in queue ${this.id}`);
|
|
475
|
+
}
|
|
476
|
+
const request = JSON.parse(raw);
|
|
477
|
+
if (request.handledAt) {
|
|
478
|
+
throw new Error(`Request with ID ${id} has already been handled in queue ${this.id}`);
|
|
479
|
+
}
|
|
480
|
+
const now = Date.now();
|
|
481
|
+
if (!lockScore || parseInt(lockScore, 10) <= now) {
|
|
482
|
+
throw new Error(`Request with ID ${id} is not locked in queue ${this.id}`);
|
|
483
|
+
}
|
|
484
|
+
const runId = this.client.billingContext?.runId || null;
|
|
485
|
+
if (owner && !(0, rq_lock_script_js_1.matchesLockOwner)(owner, this.clientKey, runId)) {
|
|
486
|
+
throw new Error(`Request with ID ${id} is not locked by this client in queue ${this.id}`);
|
|
487
|
+
}
|
|
488
|
+
const lockUntil = now + options.lockSecs * 1000;
|
|
489
|
+
await this.client.redis.zadd(this.lockedKey, String(lockUntil), id);
|
|
490
|
+
return { lockExpiresAt: new Date(lockUntil) };
|
|
491
|
+
}
|
|
492
|
+
async deleteRequestLock(id, options) {
|
|
493
|
+
await this.client.redis.zrem(this.lockedKey, id);
|
|
494
|
+
await this.client.redis.hdel(this.lockOwnersKey, id);
|
|
495
|
+
if (options?.forefront) {
|
|
496
|
+
await this.client.redis.zadd(this.pendingKey, '0', id);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Unlock all requests locked by a specific clientKey.
|
|
501
|
+
* This is useful for batch unlocking when a client is done processing.
|
|
502
|
+
* @param options.clientKey - The client key to unlock requests for
|
|
503
|
+
* @param options.forefront - If true, move unlocked requests to the front of the queue
|
|
504
|
+
* @param options.runId - Optional run ID to only unlock requests for a specific run
|
|
505
|
+
*/
|
|
506
|
+
async unlockRequests(options) {
|
|
507
|
+
// Single atomic round-trip (UNLOCK_REQUESTS_LUA): owner matching, lock
|
|
508
|
+
// removal and optional forefront re-queueing all happen server-side,
|
|
509
|
+
// replacing the previous HGETALL + 2-3 round-trips per locked request.
|
|
510
|
+
const unlockedCount = await (0, rq_lock_script_js_1.unlockRequestsAtomic)(this.client.redis, {
|
|
511
|
+
lockedKey: this.lockedKey,
|
|
512
|
+
lockOwnersKey: this.lockOwnersKey,
|
|
513
|
+
pendingKey: this.pendingKey,
|
|
514
|
+
}, {
|
|
515
|
+
clientKey: options.clientKey,
|
|
516
|
+
forefront: options.forefront,
|
|
517
|
+
runId: options.runId,
|
|
518
|
+
});
|
|
519
|
+
return { unlockedCount };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
exports.DragonflyRequestQueueClient = DragonflyRequestQueueClient;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RequestQueueCollectionClient, RequestQueueInfo, PaginatedList } from '@crawlee/types';
|
|
2
|
+
import type { DragonflyClient } from './dragonfly-client.js';
|
|
3
|
+
export declare class DragonflyRequestQueueCollectionClient implements RequestQueueCollectionClient {
|
|
4
|
+
private client;
|
|
5
|
+
constructor(client: DragonflyClient);
|
|
6
|
+
getOrCreate(name?: string): Promise<RequestQueueInfo>;
|
|
7
|
+
list(): Promise<PaginatedList<RequestQueueInfo>>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DragonflyRequestQueueCollectionClient = void 0;
|
|
4
|
+
const utils_js_1 = require("./utils.js");
|
|
5
|
+
class DragonflyRequestQueueCollectionClient {
|
|
6
|
+
client;
|
|
7
|
+
constructor(client) {
|
|
8
|
+
this.client = client;
|
|
9
|
+
}
|
|
10
|
+
async getOrCreate(name) {
|
|
11
|
+
const indexKey = this.client.key('rq:index');
|
|
12
|
+
if (name) {
|
|
13
|
+
const existing = await this.client.redis.hget(indexKey, `name:${name}`);
|
|
14
|
+
if (existing)
|
|
15
|
+
return JSON.parse(existing);
|
|
16
|
+
}
|
|
17
|
+
const id = (0, utils_js_1.generateId)();
|
|
18
|
+
const now = (0, utils_js_1.nowISO)();
|
|
19
|
+
// Real userId when billing context is active; legacy placeholder otherwise.
|
|
20
|
+
// Matches the KVS collection client convention — the server's storage usage
|
|
21
|
+
// and TTL jobs match on this field to attribute storages to users, so an
|
|
22
|
+
// empty value would orphan the queue from per-user accounting.
|
|
23
|
+
const userId = this.client.billingContext?.userId ?? '1';
|
|
24
|
+
const data = {
|
|
25
|
+
id,
|
|
26
|
+
name,
|
|
27
|
+
userId,
|
|
28
|
+
createdAt: now,
|
|
29
|
+
modifiedAt: now,
|
|
30
|
+
accessedAt: now,
|
|
31
|
+
totalRequestCount: 0,
|
|
32
|
+
handledRequestCount: 0,
|
|
33
|
+
pendingRequestCount: 0,
|
|
34
|
+
hadMultipleClients: false,
|
|
35
|
+
stats: { readCount: 0, writeCount: 0, deleteCount: 0, headItemReadCount: 0, storageBytes: 0 },
|
|
36
|
+
};
|
|
37
|
+
await this.client.redis.hset(indexKey, `id:${id}`, JSON.stringify(data));
|
|
38
|
+
if (name)
|
|
39
|
+
await this.client.redis.hset(indexKey, `name:${name}`, JSON.stringify(data));
|
|
40
|
+
const infoKey = this.client.key('rq', id, 'info');
|
|
41
|
+
const requestsKey = this.client.key('rq', id, 'requests');
|
|
42
|
+
const pendingKey = this.client.key('rq', id, 'pending');
|
|
43
|
+
const lockedKey = this.client.key('rq', id, 'locked');
|
|
44
|
+
const lockOwnersKey = this.client.key('rq', id, 'lockOwners');
|
|
45
|
+
await this.client.redis.hset(infoKey, {
|
|
46
|
+
id,
|
|
47
|
+
name: name ?? '',
|
|
48
|
+
userId,
|
|
49
|
+
totalRequestCount: '0',
|
|
50
|
+
handledRequestCount: '0',
|
|
51
|
+
pendingRequestCount: '0',
|
|
52
|
+
hadMultipleClients: '0',
|
|
53
|
+
createdAt: now.toISOString(),
|
|
54
|
+
modifiedAt: now.toISOString(),
|
|
55
|
+
accessedAt: now.toISOString(),
|
|
56
|
+
});
|
|
57
|
+
// Set 24h fallback TTL on all RQ keys (safety net for orphaned RQs from crashed containers)
|
|
58
|
+
// RQs should be explicitly deleted after run completes, this TTL is just a fallback
|
|
59
|
+
const ttl = this.client.ttlManager.getTtl('rq', this.client.userPlan);
|
|
60
|
+
await this.client.ttlManager.setExpiry(this.client.redis, [infoKey, requestsKey, pendingKey, lockedKey, lockOwnersKey], ttl);
|
|
61
|
+
return data;
|
|
62
|
+
}
|
|
63
|
+
async list() {
|
|
64
|
+
const indexKey = this.client.key('rq:index');
|
|
65
|
+
const all = await this.client.redis.hgetall(indexKey);
|
|
66
|
+
const items = [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (const [key, val] of Object.entries(all)) {
|
|
69
|
+
if (!key.startsWith('id:'))
|
|
70
|
+
continue;
|
|
71
|
+
const parsed = JSON.parse(val);
|
|
72
|
+
if (seen.has(parsed.id))
|
|
73
|
+
continue;
|
|
74
|
+
seen.add(parsed.id);
|
|
75
|
+
const infoKey = this.client.key('rq', parsed.id, 'info');
|
|
76
|
+
// Skip and clean up stale index entries whose keys have expired via TTL
|
|
77
|
+
const exists = await this.client.redis.exists(infoKey);
|
|
78
|
+
if (!exists) {
|
|
79
|
+
await this.client.redis.hdel(indexKey, `id:${parsed.id}`);
|
|
80
|
+
if (parsed.name)
|
|
81
|
+
await this.client.redis.hdel(indexKey, `name:${parsed.name}`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
items.push(parsed);
|
|
85
|
+
}
|
|
86
|
+
return { total: items.length, count: items.length, offset: 0, limit: items.length, items };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
exports.DragonflyRequestQueueCollectionClient = DragonflyRequestQueueCollectionClient;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type Redis from 'ioredis';
|
|
2
|
+
/** Full request object as returned by listRequests endpoint */
|
|
3
|
+
export interface RequestQueueRequest {
|
|
4
|
+
id: string;
|
|
5
|
+
retryCount: number;
|
|
6
|
+
uniqueKey: string;
|
|
7
|
+
url: string;
|
|
8
|
+
method: string;
|
|
9
|
+
loadedUrl?: string | null;
|
|
10
|
+
payload?: string | null;
|
|
11
|
+
noRetry?: boolean;
|
|
12
|
+
errorMessages?: string[] | null;
|
|
13
|
+
headers?: Record<string, string> | null;
|
|
14
|
+
userData?: Record<string, unknown> | null;
|
|
15
|
+
handledAt?: string | null;
|
|
16
|
+
}
|
|
17
|
+
/** Per-queue Redis keys read by listRequestsPage(). */
|
|
18
|
+
export interface RqListRequestsKeys {
|
|
19
|
+
requestsKey: string;
|
|
20
|
+
pendingKey: string;
|
|
21
|
+
lockedKey: string;
|
|
22
|
+
}
|
|
23
|
+
export interface RqListRequestsParams {
|
|
24
|
+
now: number;
|
|
25
|
+
limit: number;
|
|
26
|
+
/** Cursor / deprecated exclusiveStartId: all IDs <= this one are skipped. */
|
|
27
|
+
startId?: string;
|
|
28
|
+
filter?: readonly ('locked' | 'pending')[];
|
|
29
|
+
}
|
|
30
|
+
export interface RqListRequestsPage {
|
|
31
|
+
items: RequestQueueRequest[];
|
|
32
|
+
/** True when IDs remain past this page (drives nextCursor). */
|
|
33
|
+
hasMore: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Index of the first element strictly greater than `target` in a
|
|
37
|
+
* lexicographically sorted array (binary search).
|
|
38
|
+
*
|
|
39
|
+
* Unlike indexOf(), it returns the correct position even when `target` itself
|
|
40
|
+
* is absent, so a cursor item deleted between pages advances pagination past
|
|
41
|
+
* its old position instead of restarting from page 1 (which used to loop
|
|
42
|
+
* pagination clients back to the first page forever). This also matches the
|
|
43
|
+
* documented exclusiveStartId semantics: "all requests up to this one
|
|
44
|
+
* (including) are skipped from the result".
|
|
45
|
+
*/
|
|
46
|
+
export declare function upperBoundSorted(sortedIds: readonly string[], target: string): number;
|
|
47
|
+
/**
|
|
48
|
+
* One page of request-queue listing: filtering + cursor pagination in two
|
|
49
|
+
* round-trips, shared by the storage client and the API server route.
|
|
50
|
+
*
|
|
51
|
+
* Round-trip 1 — a single pipeline:
|
|
52
|
+
* - HKEYS: all request IDs (field names only, not the bodies);
|
|
53
|
+
* - ZRANGEBYSCORE locked (now +inf — only when filtering by 'locked'. The
|
|
54
|
+
* locked ZSET is scored by lockUntil ms, so one exclusive-bound range read
|
|
55
|
+
* exactly replaces the old per-id ZSCORE loop (score > now);
|
|
56
|
+
* - ZRANGE pending 0 -1 — only when filtering by 'pending'.
|
|
57
|
+
* Round-trip 2 — one HMGET for the page's request bodies.
|
|
58
|
+
*/
|
|
59
|
+
export declare function listRequestsPage(redis: Redis, keys: RqListRequestsKeys, params: RqListRequestsParams): Promise<RqListRequestsPage>;
|