scrapely 1.2.39 → 1.2.41

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.
@@ -20,6 +20,15 @@ export interface ListRequestsResult {
20
20
  import type { DragonflyClient } from './dragonfly-client.js';
21
21
  import type { RequestQueueOptions } from '@crawlee/types';
22
22
  import { type RequestQueueRequest } from './rq-list-requests.js';
23
+ /** Longest lock, in seconds (same as the server's MAX_LOCK_SECS). */
24
+ export declare const MAX_LOCK_SECS = 86400;
25
+ /** lockSecs as a whole number of seconds from 1 to MAX_LOCK_SECS. */
26
+ export declare function clampLockSecs(lockSecs: number): number;
27
+ /**
28
+ * Largest stored request a head read loads (the server's MAX_READABLE_STORED_REQUEST_BYTES):
29
+ * a larger one is left out of the head instead of being read into memory.
30
+ */
31
+ export declare const MAX_HEAD_REQUEST_BYTES: number;
23
32
  export declare class DragonflyRequestQueueClient implements RequestQueueClient {
24
33
  private client;
25
34
  private id;
@@ -71,6 +80,8 @@ export declare class DragonflyRequestQueueClient implements RequestQueueClient {
71
80
  * @param options.clientKey - Client identifier for hadMultipleClients tracking
72
81
  */
73
82
  listRequests(options?: ListRequestsOptions): Promise<ListRequestsResult>;
83
+ /** The ids whose stored request is at most MAX_HEAD_REQUEST_BYTES (HSTRLEN, nothing loaded). */
84
+ private withinHeadSize;
74
85
  listAndLockHead(options: ListAndLockOptions & {
75
86
  clientKey?: string;
76
87
  }): Promise<ListAndLockHeadResult>;
@@ -1,11 +1,34 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DragonflyRequestQueueClient = void 0;
3
+ exports.DragonflyRequestQueueClient = exports.MAX_HEAD_REQUEST_BYTES = exports.MAX_LOCK_SECS = void 0;
4
+ exports.clampLockSecs = clampLockSecs;
4
5
  const utils_js_1 = require("./utils.js");
5
6
  const billing_tracker_js_1 = require("./billing-tracker.js");
6
7
  const rq_lock_script_js_1 = require("./rq-lock-script.js");
7
8
  const rq_list_requests_js_1 = require("./rq-list-requests.js");
9
+ const ttl_manager_js_1 = require("./ttl-manager.js");
8
10
  const node_crypto_1 = require("node:crypto");
11
+ /**
12
+ * Lock owners are stored as "clientKey:runId", so a clientKey holding ':'
13
+ * could present another run's owner value. Same rule as the server.
14
+ */
15
+ function assertClientKey(clientKey) {
16
+ if (clientKey.includes(':'))
17
+ throw new Error("clientKey must not contain ':'");
18
+ return clientKey;
19
+ }
20
+ /** Longest lock, in seconds (same as the server's MAX_LOCK_SECS). */
21
+ exports.MAX_LOCK_SECS = 86_400;
22
+ /** lockSecs as a whole number of seconds from 1 to MAX_LOCK_SECS. */
23
+ function clampLockSecs(lockSecs) {
24
+ const n = Math.floor(Number(lockSecs));
25
+ return Number.isFinite(n) ? Math.min(Math.max(n, 1), exports.MAX_LOCK_SECS) : 1;
26
+ }
27
+ /**
28
+ * Largest stored request a head read loads (the server's MAX_READABLE_STORED_REQUEST_BYTES):
29
+ * a larger one is left out of the head instead of being read into memory.
30
+ */
31
+ exports.MAX_HEAD_REQUEST_BYTES = 10 * 1024 * 1024 + 64 * 1024;
9
32
  class DragonflyRequestQueueClient {
10
33
  client;
11
34
  id;
@@ -18,7 +41,7 @@ class DragonflyRequestQueueClient {
18
41
  constructor(client, id, options) {
19
42
  this.client = client;
20
43
  this.id = id;
21
- this.clientKey = options?.clientKey || (0, node_crypto_1.randomUUID)().replace(/-/g, '').slice(0, 16);
44
+ this.clientKey = assertClientKey(options?.clientKey || (0, node_crypto_1.randomUUID)().replace(/-/g, '').slice(0, 16));
22
45
  this.infoKey = client.key('rq', id, 'info');
23
46
  this.requestsKey = client.key('rq', id, 'requests');
24
47
  this.pendingKey = client.key('rq', id, 'pending');
@@ -119,11 +142,13 @@ class DragonflyRequestQueueClient {
119
142
  const raw = await this.client.redis.hget(indexKey, `id:${this.id}`);
120
143
  if (raw) {
121
144
  const data = JSON.parse(raw);
122
- if (data.name)
123
- await this.client.redis.hdel(indexKey, `name:${data.name}`);
145
+ // Name pointers are scoped by owner (name:{userId}:{name}).
146
+ const ownerId = data.userId ?? this.client.billingContext?.userId;
147
+ await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, ownerId, data.name, this.id);
124
148
  data.name = newFields.name;
125
149
  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));
150
+ if (ownerId && newFields.name)
151
+ await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(ownerId, newFields.name), JSON.stringify(data));
127
152
  }
128
153
  }
129
154
  await this.client.redis.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
@@ -134,8 +159,7 @@ class DragonflyRequestQueueClient {
134
159
  await this.client.redis.del(this.infoKey, this.requestsKey, this.pendingKey, this.lockedKey, this.lockOwnersKey);
135
160
  const indexKey = this.client.key('rq:index');
136
161
  await this.client.redis.hdel(indexKey, `id:${this.id}`);
137
- if (info?.name)
138
- await this.client.redis.hdel(indexKey, `name:${info.name}`);
162
+ await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, info?.userId || this.client.billingContext?.userId, info?.name, this.id);
139
163
  }
140
164
  async addRequest(request, options) {
141
165
  const requestId = request.id || (0, utils_js_1.uniqueKeyToRequestId)(request.uniqueKey);
@@ -156,6 +180,8 @@ class DragonflyRequestQueueClient {
156
180
  pipeline.hincrby(this.infoKey, 'totalRequestCount', 1);
157
181
  pipeline.hincrby(this.infoKey, 'pendingRequestCount', 1);
158
182
  pipeline.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
183
+ // Requests/pending are created by the first add: give them the queue's expiry.
184
+ (0, ttl_manager_js_1.queueInheritExpiry)(pipeline, this.infoKey, [this.requestsKey, this.pendingKey]);
159
185
  await pipeline.exec();
160
186
  // Track write and storage bytes for billing
161
187
  const bytes = billing_tracker_js_1.BillingTracker.estimateRequestBytes(stored);
@@ -289,7 +315,7 @@ class DragonflyRequestQueueClient {
289
315
  const activeLocks = new Set(at(1));
290
316
  // Filter out locked items
291
317
  const items = [];
292
- const candidates = ids.filter((id) => !activeLocks.has(id)).slice(0, limit);
318
+ const candidates = await this.withinHeadSize(ids.filter((id) => !activeLocks.has(id)).slice(0, limit));
293
319
  if (candidates.length > 0) {
294
320
  const raws = await this.client.redis.hmget(this.requestsKey, ...candidates);
295
321
  for (const raw of raws) {
@@ -408,11 +434,22 @@ class DragonflyRequestQueueClient {
408
434
  }
409
435
  return result;
410
436
  }
437
+ /** The ids whose stored request is at most MAX_HEAD_REQUEST_BYTES (HSTRLEN, nothing loaded). */
438
+ async withinHeadSize(ids) {
439
+ if (ids.length === 0)
440
+ return ids;
441
+ const pipeline = this.client.redis.pipeline();
442
+ for (const id of ids)
443
+ pipeline.hstrlen(this.requestsKey, id);
444
+ const sizes = (await pipeline.exec()) ?? [];
445
+ return ids.filter((_, i) => Number(sizes[i]?.[1] ?? 0) <= exports.MAX_HEAD_REQUEST_BYTES);
446
+ }
411
447
  async listAndLockHead(options) {
412
448
  const limit = Math.min(options.limit ?? 25, 25);
413
449
  const now = Date.now();
414
- const lockUntil = now + options.lockSecs * 1000;
415
- const clientKey = options.clientKey || this.clientKey;
450
+ const lockSecs = clampLockSecs(options.lockSecs);
451
+ const lockUntil = now + lockSecs * 1000;
452
+ const clientKey = assertClientKey(options.clientKey || this.clientKey);
416
453
  const runId = this.client.billingContext?.runId || null;
417
454
  const ownerValue = runId ? `${clientKey}:${runId}` : clientKey;
418
455
  const { lockedIds, activeLockCount } = await (0, rq_lock_script_js_1.listAndLockHeadAtomic)(this.client.redis, {
@@ -425,11 +462,19 @@ class DragonflyRequestQueueClient {
425
462
  lockUntil,
426
463
  limit,
427
464
  ownerValue,
465
+ maxRequestBytes: exports.MAX_HEAD_REQUEST_BYTES,
428
466
  });
429
467
  const items = [];
430
468
  if (lockedIds.length > 0) {
469
+ // The locked set and owners hash are created by the first lock: give them the queue's expiry.
470
+ const expiry = this.client.redis.pipeline();
471
+ (0, ttl_manager_js_1.queueInheritExpiry)(expiry, this.infoKey, [this.lockedKey, this.lockOwnersKey]);
472
+ await expiry.exec();
473
+ }
474
+ const readable = await this.withinHeadSize(lockedIds);
475
+ if (readable.length > 0) {
431
476
  // Single HMGET instead of one HGET round-trip per locked ID
432
- const raws = await this.client.redis.hmget(this.requestsKey, ...lockedIds);
477
+ const raws = await this.client.redis.hmget(this.requestsKey, ...readable);
433
478
  for (const raw of raws) {
434
479
  if (!raw)
435
480
  continue;
@@ -453,7 +498,7 @@ class DragonflyRequestQueueClient {
453
498
  limit,
454
499
  queueModifiedAt: modifiedAt ? new Date(modifiedAt) : (0, utils_js_1.nowISO)(),
455
500
  hadMultipleClients,
456
- lockSecs: options.lockSecs,
501
+ lockSecs,
457
502
  items,
458
503
  // From the atomic lock script: active locks held by ANY client
459
504
  // (including this call's own).
@@ -485,7 +530,7 @@ class DragonflyRequestQueueClient {
485
530
  if (owner && !(0, rq_lock_script_js_1.matchesLockOwner)(owner, this.clientKey, runId)) {
486
531
  throw new Error(`Request with ID ${id} is not locked by this client in queue ${this.id}`);
487
532
  }
488
- const lockUntil = now + options.lockSecs * 1000;
533
+ const lockUntil = now + clampLockSecs(options.lockSecs) * 1000;
489
534
  await this.client.redis.zadd(this.lockedKey, String(lockUntil), id);
490
535
  return { lockExpiresAt: new Date(lockUntil) };
491
536
  }
@@ -512,7 +557,7 @@ class DragonflyRequestQueueClient {
512
557
  lockOwnersKey: this.lockOwnersKey,
513
558
  pendingKey: this.pendingKey,
514
559
  }, {
515
- clientKey: options.clientKey,
560
+ clientKey: assertClientKey(options.clientKey),
516
561
  forefront: options.forefront,
517
562
  runId: options.runId,
518
563
  });
@@ -9,18 +9,15 @@ class DragonflyRequestQueueCollectionClient {
9
9
  }
10
10
  async getOrCreate(name) {
11
11
  const indexKey = this.client.key('rq:index');
12
+ // Every storage belongs to the run's user; the name pointer is scoped by that user.
13
+ const userId = (0, utils_js_1.requireStorageOwner)(this.client.billingContext, 'request queue');
12
14
  if (name) {
13
- const existing = await this.client.redis.hget(indexKey, `name:${name}`);
15
+ const existing = await this.client.redis.hget(indexKey, (0, utils_js_1.ownerNameField)(userId, name));
14
16
  if (existing)
15
17
  return JSON.parse(existing);
16
18
  }
17
19
  const id = (0, utils_js_1.generateId)();
18
20
  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
21
  const data = {
25
22
  id,
26
23
  name,
@@ -36,7 +33,7 @@ class DragonflyRequestQueueCollectionClient {
36
33
  };
37
34
  await this.client.redis.hset(indexKey, `id:${id}`, JSON.stringify(data));
38
35
  if (name)
39
- await this.client.redis.hset(indexKey, `name:${name}`, JSON.stringify(data));
36
+ await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(userId, name), JSON.stringify(data));
40
37
  const infoKey = this.client.key('rq', id, 'info');
41
38
  const requestsKey = this.client.key('rq', id, 'requests');
42
39
  const pendingKey = this.client.key('rq', id, 'pending');
@@ -77,8 +74,7 @@ class DragonflyRequestQueueCollectionClient {
77
74
  const exists = await this.client.redis.exists(infoKey);
78
75
  if (!exists) {
79
76
  await this.client.redis.hdel(indexKey, `id:${parsed.id}`);
80
- if (parsed.name)
81
- await this.client.redis.hdel(indexKey, `name:${parsed.name}`);
77
+ await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, parsed.userId, parsed.name, parsed.id);
82
78
  continue;
83
79
  }
84
80
  items.push(parsed);
@@ -9,7 +9,7 @@ import type Redis from 'ioredis';
9
9
  * the one calling the endpoint or a different one"). Callers get the flag from
10
10
  * this same atomic reply instead of re-reading the whole locked + pending sets.
11
11
  */
12
- export declare const LIST_AND_LOCK_HEAD_LUA = "\nlocal pendingKey = KEYS[1]\nlocal lockedKey = KEYS[2]\nlocal lockOwnersKey = KEYS[3]\nlocal requestsKey = KEYS[4]\nlocal now = tonumber(ARGV[1])\nlocal lockUntil = tonumber(ARGV[2])\nlocal limit = tonumber(ARGV[3])\nlocal ownerValue = ARGV[4]\nlocal scanLimit = tonumber(ARGV[5])\n\nredis.call('ZREMRANGEBYSCORE', lockedKey, '-inf', now)\n\nlocal pendingIds = redis.call('ZRANGE', pendingKey, 0, scanLimit - 1)\nlocal lockedIds = {}\n\nfor _, id in ipairs(pendingIds) do\n if #lockedIds >= limit then break end\n local lockScore = redis.call('ZSCORE', lockedKey, id)\n if not lockScore or tonumber(lockScore) <= now then\n if redis.call('HEXISTS', requestsKey, id) == 1 then\n redis.call('ZADD', lockedKey, lockUntil, id)\n redis.call('HSET', lockOwnersKey, id, ownerValue)\n table.insert(lockedIds, id)\n end\n end\nend\n\n-- Everything left in lockedKey is an active lock (score > now): the purge\n-- above removed the expired ones and we just added ours. An O(1) ZCARD here\n-- replaces the callers' old tail of ZRANGEBYSCORE(locked) + full\n-- ZRANGE(pending, 0, -1) scans on every head fetch.\nreturn { lockedIds, redis.call('ZCARD', lockedKey) }\n";
12
+ export declare const LIST_AND_LOCK_HEAD_LUA = "\nlocal pendingKey = KEYS[1]\nlocal lockedKey = KEYS[2]\nlocal lockOwnersKey = KEYS[3]\nlocal requestsKey = KEYS[4]\nlocal now = tonumber(ARGV[1])\nlocal lockUntil = tonumber(ARGV[2])\nlocal limit = tonumber(ARGV[3])\nlocal ownerValue = ARGV[4]\nlocal scanLimit = tonumber(ARGV[5])\n-- Requests larger than this are never returned by the head, so they are not locked either.\nlocal maxRequestBytes = tonumber(ARGV[6])\n\n-- Expired locks leave lockedKey here, and their owner entries go with them:\n-- otherwise lockOwners keeps one entry per request ever locked.\nlocal expiredIds = redis.call('ZRANGEBYSCORE', lockedKey, '-inf', now)\nfor i = 1, #expiredIds, 1000 do\n redis.call('HDEL', lockOwnersKey, unpack(expiredIds, i, math.min(i + 999, #expiredIds)))\nend\nredis.call('ZREMRANGEBYSCORE', lockedKey, '-inf', now)\n\nlocal pendingIds = redis.call('ZRANGE', pendingKey, 0, scanLimit - 1)\nlocal lockedIds = {}\n\nfor _, id in ipairs(pendingIds) do\n if #lockedIds >= limit then break end\n local lockScore = redis.call('ZSCORE', lockedKey, id)\n if not lockScore or tonumber(lockScore) <= now then\n if redis.call('HEXISTS', requestsKey, id) == 1\n and (not maxRequestBytes or redis.call('HSTRLEN', requestsKey, id) <= maxRequestBytes) then\n redis.call('ZADD', lockedKey, lockUntil, id)\n redis.call('HSET', lockOwnersKey, id, ownerValue)\n table.insert(lockedIds, id)\n end\n end\nend\n\n-- Everything left in lockedKey is an active lock (score > now): the purge\n-- above removed the expired ones and we just added ours. An O(1) ZCARD here\n-- replaces the callers' old tail of ZRANGEBYSCORE(locked) + full\n-- ZRANGE(pending, 0, -1) scans on every head fetch.\nreturn { lockedIds, redis.call('ZCARD', lockedKey) }\n";
13
13
  export interface ListAndLockHeadKeys {
14
14
  pendingKey: string;
15
15
  lockedKey: string;
@@ -23,6 +23,8 @@ export interface ListAndLockHeadParams {
23
23
  ownerValue: string;
24
24
  /** How many pending IDs to scan (should be >= limit). */
25
25
  scanLimit?: number;
26
+ /** Skip (don't lock) requests stored larger than this many bytes. */
27
+ maxRequestBytes?: number;
26
28
  }
27
29
  export interface ListAndLockHeadAtomicResult {
28
30
  /** IDs locked by this call, in queue-head order. */
@@ -36,14 +38,18 @@ export interface ListAndLockHeadAtomicResult {
36
38
  export declare function listAndLockHeadAtomic(redis: Redis, keys: ListAndLockHeadKeys, params: ListAndLockHeadParams): Promise<ListAndLockHeadAtomicResult>;
37
39
  /** Returns true if lockOwners entry matches the caller's clientKey (and optional runId). */
38
40
  export declare function matchesLockOwner(owner: string, clientKey: string, runId?: string | null): boolean;
41
+ /** Locked requests UNLOCK_REQUESTS_LUA reads per page. */
42
+ export declare const UNLOCK_SCAN_PAGE = 1000;
39
43
  /**
40
- * Atomic unlock-by-owner: scans lockOwners, unlocks every request whose owner
41
- * matches the clientKey (and optional runId) using the same rules as the old
42
- * per-request loop, optionally re-queueing unlocked requests at the forefront.
43
- * Returns the number of unlocked requests. One round-trip, no race window
44
- * between ZREM and HDEL, and the whole owners hash never leaves the server.
44
+ * Atomic unlock-by-owner: walks the locked set a page at a time by rank,
45
+ * unlocks every request whose owner matches the clientKey (and optional
46
+ * runId) using the same rules as the old per-request loop, optionally
47
+ * re-queueing unlocked requests at the forefront. Returns the number of
48
+ * unlocked requests. One round-trip, no race window between ZREM and HDEL,
49
+ * and neither the owners hash nor the locked set is read whole (pages of
50
+ * UNLOCK_SCAN_PAGE, ARGV[4]).
45
51
  */
46
- export declare const UNLOCK_REQUESTS_LUA = "\nlocal lockedKey = KEYS[1]\nlocal lockOwnersKey = KEYS[2]\nlocal pendingKey = KEYS[3]\nlocal clientKey = ARGV[1]\nlocal runId = ARGV[2]\nlocal forefront = ARGV[3]\n\nlocal expectedOwner = clientKey\nif runId ~= '' then\n expectedOwner = clientKey .. ':' .. runId\nend\n\nlocal clientKeyPrefix = clientKey .. ':'\n\nlocal flat = redis.call('HGETALL', lockOwnersKey)\nlocal unlockedCount = 0\n\nfor i = 1, #flat, 2 do\n local requestId = flat[i]\n local owner = flat[i + 1]\n -- In an Actor run, unlock both (1) requests locked by this\n -- specific run (clientKey:runId) and (2) requests locked by the same\n -- clientKey without run context (clientKey), but NOT requests locked by the\n -- same clientKey in DIFFERENT runs (clientKey:otherRunId).\n if owner == expectedOwner or owner == clientKey\n or (runId == '' and string.sub(owner, 1, #clientKeyPrefix) == clientKeyPrefix) then\n redis.call('ZREM', lockedKey, requestId)\n redis.call('HDEL', lockOwnersKey, requestId)\n if forefront == '1' then\n redis.call('ZADD', pendingKey, 0, requestId)\n end\n unlockedCount = unlockedCount + 1\n end\nend\n\nreturn unlockedCount\n";
52
+ export declare const UNLOCK_REQUESTS_LUA = "\nlocal lockedKey = KEYS[1]\nlocal lockOwnersKey = KEYS[2]\nlocal pendingKey = KEYS[3]\nlocal clientKey = ARGV[1]\nlocal runId = ARGV[2]\nlocal forefront = ARGV[3]\nlocal pageSize = tonumber(ARGV[4])\n\nlocal expectedOwner = clientKey\nif runId ~= '' then\n expectedOwner = clientKey .. ':' .. runId\nend\n\nlocal unlockedCount = 0\nlocal start = 0\n\nwhile true do\n local ids = redis.call('ZRANGE', lockedKey, start, start + pageSize - 1)\n if #ids == 0 then break end\n local owners = redis.call('HMGET', lockOwnersKey, unpack(ids))\n local removed = 0\n for i, requestId in ipairs(ids) do\n local owner = owners[i]\n -- In an Actor run, unlock both (1) requests locked by this\n -- specific run (clientKey:runId) and (2) requests locked by the same\n -- clientKey without run context (clientKey), but NOT requests locked by the\n -- same clientKey in DIFFERENT runs (clientKey:otherRunId).\n -- Exact clientKey, or this run's clientKey:runId. An empty runId must not\n -- unlock every owner that merely starts with \"clientKey:\".\n if owner and (owner == expectedOwner or owner == clientKey) then\n redis.call('ZREM', lockedKey, requestId)\n redis.call('HDEL', lockOwnersKey, requestId)\n if forefront == '1' then\n redis.call('ZADD', pendingKey, 0, requestId)\n end\n unlockedCount = unlockedCount + 1\n removed = removed + 1\n end\n end\n if #ids < pageSize then break end\n -- Removed locks were in this page, so the next unread lock moved up by that many.\n start = start + #ids - removed\nend\n\nreturn unlockedCount\n";
47
53
  export interface UnlockRequestsKeys {
48
54
  lockedKey: string;
49
55
  lockOwnersKey: string;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.UNLOCK_REQUESTS_LUA = exports.LIST_AND_LOCK_HEAD_LUA = void 0;
3
+ exports.UNLOCK_REQUESTS_LUA = exports.UNLOCK_SCAN_PAGE = exports.LIST_AND_LOCK_HEAD_LUA = void 0;
4
4
  exports.listAndLockHeadAtomic = listAndLockHeadAtomic;
5
5
  exports.matchesLockOwner = matchesLockOwner;
6
6
  exports.unlockRequestsAtomic = unlockRequestsAtomic;
@@ -24,7 +24,15 @@ local lockUntil = tonumber(ARGV[2])
24
24
  local limit = tonumber(ARGV[3])
25
25
  local ownerValue = ARGV[4]
26
26
  local scanLimit = tonumber(ARGV[5])
27
+ -- Requests larger than this are never returned by the head, so they are not locked either.
28
+ local maxRequestBytes = tonumber(ARGV[6])
27
29
 
30
+ -- Expired locks leave lockedKey here, and their owner entries go with them:
31
+ -- otherwise lockOwners keeps one entry per request ever locked.
32
+ local expiredIds = redis.call('ZRANGEBYSCORE', lockedKey, '-inf', now)
33
+ for i = 1, #expiredIds, 1000 do
34
+ redis.call('HDEL', lockOwnersKey, unpack(expiredIds, i, math.min(i + 999, #expiredIds)))
35
+ end
28
36
  redis.call('ZREMRANGEBYSCORE', lockedKey, '-inf', now)
29
37
 
30
38
  local pendingIds = redis.call('ZRANGE', pendingKey, 0, scanLimit - 1)
@@ -34,7 +42,8 @@ for _, id in ipairs(pendingIds) do
34
42
  if #lockedIds >= limit then break end
35
43
  local lockScore = redis.call('ZSCORE', lockedKey, id)
36
44
  if not lockScore or tonumber(lockScore) <= now then
37
- if redis.call('HEXISTS', requestsKey, id) == 1 then
45
+ if redis.call('HEXISTS', requestsKey, id) == 1
46
+ and (not maxRequestBytes or redis.call('HSTRLEN', requestsKey, id) <= maxRequestBytes) then
38
47
  redis.call('ZADD', lockedKey, lockUntil, id)
39
48
  redis.call('HSET', lockOwnersKey, id, ownerValue)
40
49
  table.insert(lockedIds, id)
@@ -50,7 +59,7 @@ return { lockedIds, redis.call('ZCARD', lockedKey) }
50
59
  `;
51
60
  async function listAndLockHeadAtomic(redis, keys, params) {
52
61
  const scanLimit = params.scanLimit ?? Math.max(params.limit * 4, 100);
53
- const result = await redis.eval(exports.LIST_AND_LOCK_HEAD_LUA, 4, keys.pendingKey, keys.lockedKey, keys.lockOwnersKey, keys.requestsKey, String(params.now), String(params.lockUntil), String(params.limit), params.ownerValue, String(scanLimit));
62
+ const result = await redis.eval(exports.LIST_AND_LOCK_HEAD_LUA, 4, keys.pendingKey, keys.lockedKey, keys.lockOwnersKey, keys.requestsKey, String(params.now), String(params.lockUntil), String(params.limit), params.ownerValue, String(scanLimit), ...(params.maxRequestBytes !== undefined ? [String(params.maxRequestBytes)] : []));
54
63
  // Expected reply shape: [string[], number]. Tolerate degraded replies the
55
64
  // same way the old string[] version did (null / JSON-stringified table).
56
65
  if (!Array.isArray(result) || result.length < 2) {
@@ -76,12 +85,16 @@ function matchesLockOwner(owner, clientKey, runId) {
76
85
  const expectedOwner = runId ? `${clientKey}:${runId}` : clientKey;
77
86
  return owner === expectedOwner || owner === clientKey;
78
87
  }
88
+ /** Locked requests UNLOCK_REQUESTS_LUA reads per page. */
89
+ exports.UNLOCK_SCAN_PAGE = 1000;
79
90
  /**
80
- * Atomic unlock-by-owner: scans lockOwners, unlocks every request whose owner
81
- * matches the clientKey (and optional runId) using the same rules as the old
82
- * per-request loop, optionally re-queueing unlocked requests at the forefront.
83
- * Returns the number of unlocked requests. One round-trip, no race window
84
- * between ZREM and HDEL, and the whole owners hash never leaves the server.
91
+ * Atomic unlock-by-owner: walks the locked set a page at a time by rank,
92
+ * unlocks every request whose owner matches the clientKey (and optional
93
+ * runId) using the same rules as the old per-request loop, optionally
94
+ * re-queueing unlocked requests at the forefront. Returns the number of
95
+ * unlocked requests. One round-trip, no race window between ZREM and HDEL,
96
+ * and neither the owners hash nor the locked set is read whole (pages of
97
+ * UNLOCK_SCAN_PAGE, ARGV[4]).
85
98
  */
86
99
  exports.UNLOCK_REQUESTS_LUA = `
87
100
  local lockedKey = KEYS[1]
@@ -90,38 +103,47 @@ local pendingKey = KEYS[3]
90
103
  local clientKey = ARGV[1]
91
104
  local runId = ARGV[2]
92
105
  local forefront = ARGV[3]
106
+ local pageSize = tonumber(ARGV[4])
93
107
 
94
108
  local expectedOwner = clientKey
95
109
  if runId ~= '' then
96
110
  expectedOwner = clientKey .. ':' .. runId
97
111
  end
98
112
 
99
- local clientKeyPrefix = clientKey .. ':'
100
-
101
- local flat = redis.call('HGETALL', lockOwnersKey)
102
113
  local unlockedCount = 0
114
+ local start = 0
103
115
 
104
- for i = 1, #flat, 2 do
105
- local requestId = flat[i]
106
- local owner = flat[i + 1]
107
- -- In an Actor run, unlock both (1) requests locked by this
108
- -- specific run (clientKey:runId) and (2) requests locked by the same
109
- -- clientKey without run context (clientKey), but NOT requests locked by the
110
- -- same clientKey in DIFFERENT runs (clientKey:otherRunId).
111
- if owner == expectedOwner or owner == clientKey
112
- or (runId == '' and string.sub(owner, 1, #clientKeyPrefix) == clientKeyPrefix) then
113
- redis.call('ZREM', lockedKey, requestId)
114
- redis.call('HDEL', lockOwnersKey, requestId)
115
- if forefront == '1' then
116
- redis.call('ZADD', pendingKey, 0, requestId)
116
+ while true do
117
+ local ids = redis.call('ZRANGE', lockedKey, start, start + pageSize - 1)
118
+ if #ids == 0 then break end
119
+ local owners = redis.call('HMGET', lockOwnersKey, unpack(ids))
120
+ local removed = 0
121
+ for i, requestId in ipairs(ids) do
122
+ local owner = owners[i]
123
+ -- In an Actor run, unlock both (1) requests locked by this
124
+ -- specific run (clientKey:runId) and (2) requests locked by the same
125
+ -- clientKey without run context (clientKey), but NOT requests locked by the
126
+ -- same clientKey in DIFFERENT runs (clientKey:otherRunId).
127
+ -- Exact clientKey, or this run's clientKey:runId. An empty runId must not
128
+ -- unlock every owner that merely starts with "clientKey:".
129
+ if owner and (owner == expectedOwner or owner == clientKey) then
130
+ redis.call('ZREM', lockedKey, requestId)
131
+ redis.call('HDEL', lockOwnersKey, requestId)
132
+ if forefront == '1' then
133
+ redis.call('ZADD', pendingKey, 0, requestId)
134
+ end
135
+ unlockedCount = unlockedCount + 1
136
+ removed = removed + 1
117
137
  end
118
- unlockedCount = unlockedCount + 1
119
138
  end
139
+ if #ids < pageSize then break end
140
+ -- Removed locks were in this page, so the next unread lock moved up by that many.
141
+ start = start + #ids - removed
120
142
  end
121
143
 
122
144
  return unlockedCount
123
145
  `;
124
146
  async function unlockRequestsAtomic(redis, keys, params) {
125
- const result = await redis.eval(exports.UNLOCK_REQUESTS_LUA, 3, keys.lockedKey, keys.lockOwnersKey, keys.pendingKey, params.clientKey, params.runId ?? '', params.forefront ? '1' : '0');
147
+ const result = await redis.eval(exports.UNLOCK_REQUESTS_LUA, 3, keys.lockedKey, keys.lockOwnersKey, keys.pendingKey, params.clientKey, params.runId ?? '', params.forefront ? '1' : '0', String(exports.UNLOCK_SCAN_PAGE));
126
148
  return typeof result === 'number' ? result : parseInt(String(result), 10) || 0;
127
149
  }
@@ -208,6 +208,12 @@ class RunMetadataManager {
208
208
  ttlPipeline.ttl(this.getRunKey(runId));
209
209
  }
210
210
  const ttlResults = await ttlPipeline.exec();
211
+ // Runs whose hash is gone (TTL = -2: expired or deleted outside delete()) leave the
212
+ // sorted set here, so it doesn't keep one entry per run ever created.
213
+ const staleIds = olderIds.filter((_, i) => ttlResults?.[i]?.[1] === -2);
214
+ if (staleIds.length > 0) {
215
+ await this.client.redis.zrem(userRunsKey, ...staleIds);
216
+ }
211
217
  // Collect only the runs that were demoted (exist but have no expiry, TTL = -1)
212
218
  const demotedIds = olderIds.filter((_, i) => ttlResults?.[i]?.[1] === -1);
213
219
  if (demotedIds.length === 0) {
@@ -1,4 +1,7 @@
1
1
  import type Redis from 'ioredis';
2
+ import type { ChainableCommander } from 'ioredis';
3
+ /** Queue INHERIT_EXPIRY_LUA: the lazily created `keys` expire with `infoKey` (nothing when it has no expiry). */
4
+ export declare function queueInheritExpiry(pipeline: ChainableCommander, infoKey: string, keys: string[]): void;
2
5
  /**
3
6
  * TTL configuration in seconds
4
7
  */
@@ -1,6 +1,26 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TtlManager = exports.DEFAULT_TTL_CONFIG = void 0;
4
+ exports.queueInheritExpiry = queueInheritExpiry;
5
+ /**
6
+ * KEYS[1]: a storage's info key; KEYS[2..]: its data keys. Gives each data key the info key's
7
+ * remaining expiry (NX: one that has an expiry keeps it). An expiry set at creation is a no-op
8
+ * on data keys that don't exist yet, so a key created lazily on the first write never expired.
9
+ */
10
+ const INHERIT_EXPIRY_LUA = `
11
+ local ttl = redis.call('PTTL', KEYS[1])
12
+ if ttl <= 0 then return 0 end
13
+ for i = 2, #KEYS do
14
+ redis.call('PEXPIRE', KEYS[i], ttl, 'NX')
15
+ end
16
+ return 1
17
+ `;
18
+ /** Queue INHERIT_EXPIRY_LUA: the lazily created `keys` expire with `infoKey` (nothing when it has no expiry). */
19
+ function queueInheritExpiry(pipeline, infoKey, keys) {
20
+ if (keys.length === 0)
21
+ return;
22
+ pipeline.eval(INHERIT_EXPIRY_LUA, keys.length + 1, infoKey, ...keys);
23
+ }
4
24
  /**
5
25
  * Default TTL configuration
6
26
  */
@@ -9,3 +9,22 @@ export declare function nowISO(): Date;
9
9
  * segments even when they are percent-encoded.
10
10
  */
11
11
  export declare function pathSegment(value: string): string;
12
+ /**
13
+ * Field of a named storage in the global `{prefix}datasets:index` /
14
+ * `kvs:index` / `rq:index` hash: scoped by owner (`name:{userId}:{name}`, the
15
+ * same field the server writes), so two accounts using one name never share
16
+ * or overwrite each other's pointer.
17
+ */
18
+ export declare function ownerNameField(userId: string, name: string): string;
19
+ /** The owner a new storage is created for: the active billing context's user. */
20
+ export declare function requireStorageOwner(billingContext: {
21
+ userId?: string;
22
+ } | undefined, kind: string): string;
23
+ /**
24
+ * Remove an owner's name pointer, but only while it still points at `storageId`
25
+ * (a pointer the owner has since moved to another storage is left alone).
26
+ */
27
+ export declare function releaseOwnerName(redis: {
28
+ hget(key: string, field: string): Promise<string | null>;
29
+ hdel(key: string, ...fields: string[]): Promise<number>;
30
+ }, indexKey: string, userId: string | null | undefined, name: string | null | undefined, storageId: string): Promise<void>;
@@ -4,6 +4,9 @@ exports.uniqueKeyToRequestId = uniqueKeyToRequestId;
4
4
  exports.generateId = generateId;
5
5
  exports.nowISO = nowISO;
6
6
  exports.pathSegment = pathSegment;
7
+ exports.ownerNameField = ownerNameField;
8
+ exports.requireStorageOwner = requireStorageOwner;
9
+ exports.releaseOwnerName = releaseOwnerName;
7
10
  const node_crypto_1 = require("node:crypto");
8
11
  function uniqueKeyToRequestId(uniqueKey) {
9
12
  return (0, node_crypto_1.createHash)('sha256')
@@ -34,3 +37,39 @@ function pathSegment(value) {
34
37
  }
35
38
  return encodeURIComponent(value);
36
39
  }
40
+ /**
41
+ * Field of a named storage in the global `{prefix}datasets:index` /
42
+ * `kvs:index` / `rq:index` hash: scoped by owner (`name:{userId}:{name}`, the
43
+ * same field the server writes), so two accounts using one name never share
44
+ * or overwrite each other's pointer.
45
+ */
46
+ function ownerNameField(userId, name) {
47
+ return `name:${userId}:${name}`;
48
+ }
49
+ /** The owner a new storage is created for: the active billing context's user. */
50
+ function requireStorageOwner(billingContext, kind) {
51
+ const userId = billingContext?.userId;
52
+ if (!userId)
53
+ throw new Error(`Cannot create a ${kind} without a billing context (no owner user id).`);
54
+ return userId;
55
+ }
56
+ /**
57
+ * Remove an owner's name pointer, but only while it still points at `storageId`
58
+ * (a pointer the owner has since moved to another storage is left alone).
59
+ */
60
+ async function releaseOwnerName(redis, indexKey, userId, name, storageId) {
61
+ if (!userId || !name)
62
+ return;
63
+ const field = ownerNameField(userId, name);
64
+ const raw = await redis.hget(indexKey, field);
65
+ if (!raw)
66
+ return;
67
+ try {
68
+ if (JSON.parse(raw)?.id !== storageId)
69
+ return;
70
+ }
71
+ catch {
72
+ return;
73
+ }
74
+ await redis.hdel(indexKey, field);
75
+ }
package/dist/utils.d.ts CHANGED
@@ -26,6 +26,8 @@ export interface EnqueueLinksOptions {
26
26
  url: string;
27
27
  userData?: Record<string, any>;
28
28
  } | null;
29
+ /** Most requests to enqueue from this page. Default: no limit */
30
+ limit?: number;
29
31
  }
30
32
  /**
31
33
  * Enqueues links found on the page using a CSS selector.
package/dist/utils.js CHANGED
@@ -4,6 +4,8 @@ exports.utils = void 0;
4
4
  exports.enqueueLinks = enqueueLinks;
5
5
  exports.requestAsBrowser = requestAsBrowser;
6
6
  exports.getRandomUserAgent = getRandomUserAgent;
7
+ /** Requests sent to the queue per addRequests call. */
8
+ const ENQUEUE_BATCH_SIZE = 100;
7
9
  /**
8
10
  * Enqueues links found on the page using a CSS selector.
9
11
  *
@@ -22,7 +24,7 @@ exports.getRandomUserAgent = getRandomUserAgent;
22
24
  * ```
23
25
  */
24
26
  async function enqueueLinks(options) {
25
- const { $, requestQueue, selector = 'a[href]', baseUrl, userData = {}, transformRequestFunction } = options;
27
+ const { $, requestQueue, selector = 'a[href]', baseUrl, userData = {}, transformRequestFunction, limit = Infinity, } = options;
26
28
  // Collect all matching URLs
27
29
  const urls = [];
28
30
  $(selector).each((_, element) => {
@@ -41,8 +43,11 @@ async function enqueueLinks(options) {
41
43
  });
42
44
  // Deduplicate URLs
43
45
  const uniqueUrls = [...new Set(urls)];
44
- // Add each URL to the request queue
46
+ // Build the requests (up to `limit`), then add them to the queue in batches
47
+ const requests = [];
45
48
  for (const url of uniqueUrls) {
49
+ if (requests.length >= limit)
50
+ break;
46
51
  const request = {
47
52
  url,
48
53
  userData: { ...userData },
@@ -51,9 +56,11 @@ async function enqueueLinks(options) {
51
56
  const transformed = transformRequestFunction
52
57
  ? transformRequestFunction(request)
53
58
  : request;
54
- if (transformed) {
55
- await requestQueue.addRequest(transformed);
56
- }
59
+ if (transformed)
60
+ requests.push(transformed);
61
+ }
62
+ for (let i = 0; i < requests.length; i += ENQUEUE_BATCH_SIZE) {
63
+ await requestQueue.addRequests(requests.slice(i, i + ENQUEUE_BATCH_SIZE));
57
64
  }
58
65
  }
59
66
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrapely",
3
- "version": "1.2.39",
3
+ "version": "1.2.41",
4
4
  "description": "Scrapely SDK for building JavaScript and TypeScript Actors",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -44,6 +44,7 @@
44
44
  "test:charge-multi-event": "npx tsx --test tests/charge-multi-event.test.ts",
45
45
  "test:charge-multi-event-void": "npx tsx --test tests/charge-multi-event-void.test.ts",
46
46
  "test:charge-edge-cases": "npx tsx --test tests/charge-edge-cases.test.ts",
47
+ "test:charge-atomic-refusal-message": "npx tsx --test tests/charge-atomic-refusal-message.test.ts",
47
48
  "test:charge-pending-persist": "npx tsx --test tests/charge-pending-persist.test.ts",
48
49
  "test:charge-platform-budget": "npx tsx --test tests/charge-platform-budget.test.ts",
49
50
  "test:storage-retries": "npx tsx --test tests/storage-retries.test.ts",
@@ -89,20 +90,20 @@
89
90
  "crawlee": "^3.16.0",
90
91
  "dockerode": "^4.0.9",
91
92
  "ioredis": "^6.0.0",
92
- "ip-address": "10.3.1",
93
+ "ip-address": "10.6.0",
93
94
  "lodash": "^4.18.1",
94
95
  "mysql2": "^3.23.1",
95
96
  "playwright": "^1.58.2",
96
97
  "scrapely-client": "^2.25.9",
97
98
  "tar-fs": "2.1.5",
98
- "ws": "^8.18.0"
99
+ "ws": "^8.21.0"
99
100
  },
100
101
  "devDependencies": {
101
102
  "@types/dockerode": "^4.0.1",
102
103
  "@types/node": "^25.5.0",
103
104
  "@types/ws": "^8.5.10",
104
105
  "dotenv": "^17.3.1",
105
- "puppeteer-core": "^24.43.1",
106
+ "puppeteer-core": "^25.16.0",
106
107
  "tsx": "^4.21.0",
107
108
  "typescript": "^5.7.0"
108
109
  },
@@ -113,8 +114,10 @@
113
114
  "axios": "^1.20.0",
114
115
  "brace-expansion": "5.0.9",
115
116
  "fast-xml-builder": "^1.2.1",
117
+ "strnum": "^2.4.2",
116
118
  "file-type": "^21.3.3",
117
119
  "follow-redirects": "^1.16.1",
120
+ "protobufjs": "^7.6.6",
118
121
  "@crawlee/core": {
119
122
  "ow": "0.28.2"
120
123
  },