scrapely 1.2.40 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.2.41] - 2026-11-08
8
+
9
+ - An atomic multi-event push (`Actor.pushData(rows, { charges })`) whose charge the limit covers only in part no longer logs "Charging 5 instances of 'audio-minute' event would exceed the end of the account balance - only 3 events were charged". A row is saved only when all of its events are charged, so the SDK gives those 3 back and nothing is charged: the line was untrue. It now says what happened: "Saving this item needs 5 'audio-minute' event(s), but the account balance covers only 3: it was not saved and nothing was charged for it." When some rows still fit, it names how many were saved and charged in full, and when giving a charge back is not confirmed it says so instead of "nothing was charged". A plain `Actor.charge()` that comes back short keeps its line: those events do stay charged.
10
+ - `ChargeResult.coveredCounts` (from `pushData(rows, { charges })`): for each event whose charge came back short, how many events the limit covered (given back, not charged), e.g. `{ 'audio-minute': 3 }`. Actors can cut a row to what the limit covers without reading the log. What is charged and saved does not change.
11
+ - A spending limit of $0 (`maxTotalChargeUsd: 0`) is a real limit, the same as on the platform. It used to be read as "no limit". A missing or non-numeric value still means no limit.
12
+ - A plain `Actor.pushData(items)` to the default dataset of a pay-per-event Actor whose dataset item event is priced goes through the charged push: rows past the spending limit are not saved, and it returns a `ChargeResult`. Before, all rows were saved and only the ones within the limit were paid.
13
+ - A dataset push that fails with a `503 push-outcome-unknown` (the server could not tell whether it was stored) keeps its charge instead of voiding it.
14
+ - `utils.enqueueLinks({ limit })`: enqueue at most `limit` links per page; links are added to the queue in batches of 100.
15
+ - Dragonfly storage clients: named datasets, key-value stores and request queues are looked up per owner (`name:{userId}:{name}`, as the server stores them), a storage is created only with a known owner, and keys created later (dataset items, records, the log truncation marker) take their storage's expiry. Actor runs on the platform use the API storage client and are not affected.
16
+
7
17
  ## [1.2.40] - 2026-10-30
8
18
 
9
19
  - Actors on puppeteer 25 (ESM-only) are sent to the remote browser when puppeteer is loaded with `require()` before the SDK on a run without the preload. The SDK only recognised puppeteer's old `lib/cjs` build, so such a `puppeteer.launch()` tried to start a local browser; the named `launch` export (`const { launch } = require('puppeteer')`) is covered too. puppeteer 24 and older behave as before.
package/dist/actor.js CHANGED
@@ -603,6 +603,17 @@ class Actor {
603
603
  }
604
604
  const { Dataset } = await import('@crawlee/core');
605
605
  const dataset = await Dataset.open();
606
+ const isDefault = dataset.id === this.config.get('defaultDatasetId');
607
+ // A priced default-dataset item event: rows are trimmed to the budget and
608
+ // counted like a charged push, so the spending limit is seen locally.
609
+ if (eventName === undefined
610
+ && !this.usesPushDataInterception(dataset)
611
+ && this.initialized
612
+ && isDefault
613
+ && this.chargingManager._isPayPerEvent()
614
+ && this.chargingManager._isPaidPerItem(undefined, true)) {
615
+ return this.pushDataWithExplicitCharging(dataset, item, undefined);
616
+ }
606
617
  if (eventName === undefined && !this.usesPushDataInterception(dataset)) {
607
618
  // Charge-limit safety net (also for local storage): this push is not
608
619
  // charged, so past the run's spending limit it is not stored.
@@ -84,7 +84,24 @@ export interface ChargeResult {
84
84
  eventChargeLimitReached: boolean;
85
85
  chargedCount: number;
86
86
  chargeableWithinLimit: Record<string, number>;
87
+ /**
88
+ * `pushData(rows, { charges })` only: for each event whose charge came back
89
+ * short, how many events the limit (account balance, maxTotalChargeUsd,
90
+ * maxItems) covered. A row is saved only when all of its events are charged,
91
+ * so those events were given back, not charged. Empty when no charge came
92
+ * back short.
93
+ */
94
+ coveredCounts?: Record<string, number>;
95
+ }
96
+ /** @internal A charge of an atomic push that the limit accepted only in part (then given back). */
97
+ export interface ShortCharge {
98
+ eventName: string;
99
+ requested: number;
100
+ covered: number;
101
+ limit: string;
87
102
  }
103
+ /** @internal Log what an atomic push whose charge came back short really did. */
104
+ export declare function logAtomicRefusal(shorts: ShortCharge[], rowsPushed: number, rowsSaved: number, givenBack: boolean): void;
88
105
  export interface ActorPricingInfo {
89
106
  pricingModel?: ActorRunPricingInfo['pricingModel'];
90
107
  maxTotalChargeUsd: number;
@@ -110,6 +127,12 @@ export declare class ChargingManager {
110
127
  private lastLimitMaxItems;
111
128
  /** Default-dataset rows refused after the limit (one warning, then a count). */
112
129
  private refusedDefaultDatasetItems;
130
+ /**
131
+ * @internal Set by pushDataWithCharges while it runs (it holds the operation
132
+ * lock): charges that come back short are collected here instead of being
133
+ * logged as "only N events were charged", because the atomic push gives them back.
134
+ */
135
+ _shortCharges: ShortCharge[] | undefined;
113
136
  /**
114
137
  * Prepaid-row balance: priced events charged through charge() minus rows
115
138
  * pushed to the default dataset without a per-row charge. An Actor that
package/dist/charging.js CHANGED
@@ -10,6 +10,7 @@ exports.refuseDefaultDatasetPush = refuseDefaultDatasetPush;
10
10
  exports.isDefaultDatasetItemEvent = isDefaultDatasetItemEvent;
11
11
  exports.resolveDefaultDatasetItemEventName = resolveDefaultDatasetItemEventName;
12
12
  exports.hasDefaultDatasetItemEventInPricing = hasDefaultDatasetItemEventInPricing;
13
+ exports.logAtomicRefusal = logAtomicRefusal;
13
14
  exports.mergeChargeResults = mergeChargeResults;
14
15
  exports.assertItemsSerializable = assertItemsSerializable;
15
16
  exports.pushDataAndCharge = pushDataAndCharge;
@@ -234,6 +235,32 @@ function hasDefaultDatasetItemEventInPricing(perEventPrices) {
234
235
  function isSyntheticEvent(eventName) {
235
236
  return isDefaultDatasetItemEvent(eventName);
236
237
  }
238
+ function itemsPhrase(count) {
239
+ return count === 1
240
+ ? { what: 'this item', were: 'it was', them: 'it' }
241
+ : { what: `these ${count} items`, were: 'they were', them: 'them' };
242
+ }
243
+ /** @internal Log what an atomic push whose charge came back short really did. */
244
+ function logAtomicRefusal(shorts, rowsPushed, rowsSaved, givenBack) {
245
+ const first = shorts[0];
246
+ if (!first)
247
+ return;
248
+ const limit = first.limit.replace(/^the end of /, '');
249
+ const { what, were, them } = itemsPhrase(rowsPushed);
250
+ const head = `[Scrapely] Saving ${what} needs ${first.requested} '${first.eventName}' event(s), but ${limit} covers only ${first.covered}`;
251
+ if (rowsSaved <= 0) {
252
+ console.log(givenBack
253
+ ? `${head}: ${were} not saved and nothing was charged for ${them}.`
254
+ : `${head}: ${were} not saved, but the ${first.covered} event(s) charged for ${them} could not be given back yet (see the warning about it).`);
255
+ return;
256
+ }
257
+ const left = rowsPushed - rowsSaved;
258
+ const rest = itemsPhrase(left);
259
+ const tail = givenBack
260
+ ? `nothing was charged for ${rest.them}`
261
+ : `what was charged for ${rest.them} could not be given back yet (see the warning about it)`;
262
+ console.log(`${head}: saved the first ${rowsSaved} item(s), charged in full; the other ${left} ${rest.were.split(' ').pop()} not saved and ${tail}.`);
263
+ }
237
264
  function mergeChargeResults(a, b) {
238
265
  return {
239
266
  eventChargeLimitReached: a.eventChargeLimitReached || b.eventChargeLimitReached,
@@ -244,6 +271,17 @@ function mergeChargeResults(a, b) {
244
271
  ])),
245
272
  };
246
273
  }
274
+ /**
275
+ * The run's spending limit (maxTotalChargeUsd), or Infinity when uncapped.
276
+ * 0 is a real limit (no fees), the same as on the platform; only a missing
277
+ * or non-numeric value means uncapped.
278
+ */
279
+ function spendingLimitUsd(value) {
280
+ if (value == null || value === '')
281
+ return Infinity;
282
+ const usd = Number(value);
283
+ return Number.isFinite(usd) && usd >= 0 ? usd : Infinity;
284
+ }
247
285
  /**
248
286
  * Pay-per-event charging for platform runs.
249
287
  * Reports charges via POST /v2/actor-runs/:runId/charge (Redis → worker billing).
@@ -262,6 +300,12 @@ class ChargingManager {
262
300
  lastLimitMaxItems;
263
301
  /** Default-dataset rows refused after the limit (one warning, then a count). */
264
302
  refusedDefaultDatasetItems = 0;
303
+ /**
304
+ * @internal Set by pushDataWithCharges while it runs (it holds the operation
305
+ * lock): charges that come back short are collected here instead of being
306
+ * logged as "only N events were charged", because the atomic push gives them back.
307
+ */
308
+ _shortCharges;
265
309
  /**
266
310
  * Prepaid-row balance: priced events charged through charge() minus rows
267
311
  * pushed to the default dataset without a per-row charge. An Actor that
@@ -577,7 +621,7 @@ class ChargingManager {
577
621
  constructor(configuration, apifyClient) {
578
622
  this.configuration = configuration;
579
623
  this.apifyClientSource = apifyClient;
580
- this.maxTotalChargeUsd = configuration.get('maxTotalChargeUsd') || Infinity;
624
+ this.maxTotalChargeUsd = spendingLimitUsd(configuration.get('maxTotalChargeUsd'));
581
625
  this.isAtHome = !!configuration.get('isAtHome');
582
626
  this.actorRunId = configuration.get('actorRunId') || '';
583
627
  }
@@ -619,7 +663,7 @@ class ChargingManager {
619
663
  return {
620
664
  pricingInfo: actorPricingInfo ? JSON.parse(actorPricingInfo) : undefined,
621
665
  chargedEventCounts: JSON.parse(chargedEventCounts),
622
- maxTotalChargeUsd: this.configuration.get('maxTotalChargeUsd') || Infinity,
666
+ maxTotalChargeUsd: spendingLimitUsd(this.configuration.get('maxTotalChargeUsd')),
623
667
  };
624
668
  }
625
669
  if (this.isAtHome) {
@@ -633,13 +677,13 @@ class ChargingManager {
633
677
  return {
634
678
  pricingInfo: run.pricingInfo,
635
679
  chargedEventCounts: run.chargedEventCounts,
636
- maxTotalChargeUsd: run.options?.maxTotalChargeUsd || Infinity,
680
+ maxTotalChargeUsd: spendingLimitUsd(run.options?.maxTotalChargeUsd),
637
681
  };
638
682
  }
639
683
  return {
640
684
  pricingInfo: undefined,
641
685
  chargedEventCounts: {},
642
- maxTotalChargeUsd: this.configuration.get('maxTotalChargeUsd') || Infinity,
686
+ maxTotalChargeUsd: spendingLimitUsd(this.configuration.get('maxTotalChargeUsd')),
643
687
  };
644
688
  }
645
689
  /**
@@ -1328,8 +1372,16 @@ class ChargingManager {
1328
1372
  });
1329
1373
  }
1330
1374
  if (chargedCount < count) {
1331
- const subject = count === 1 ? 'instance' : 'instances';
1332
- console.log(`[Scrapely] Charging ${count} ${subject} of '${normalizedEventName}' event would exceed ${this.limitName()} - only ${chargedCount} events were charged`);
1375
+ if (this._shortCharges) {
1376
+ // Inside an atomic push: this charge is given back unless the row is
1377
+ // saved, so "only N events were charged" would not be true there.
1378
+ // pushDataWithCharges logs what really happened instead.
1379
+ this._shortCharges.push({ eventName: normalizedEventName, requested: count, covered: chargedCount, limit: this.limitName() });
1380
+ }
1381
+ else {
1382
+ const subject = count === 1 ? 'instance' : 'instances';
1383
+ console.log(`[Scrapely] Charging ${count} ${subject} of '${normalizedEventName}' event would exceed ${this.limitName()} - only ${chargedCount} events were charged`);
1384
+ }
1333
1385
  }
1334
1386
  return {
1335
1387
  eventChargeLimitReached: this.calculateMaxEventChargeCountWithinLimit(normalizedEventName) <= 0,
@@ -1814,6 +1866,10 @@ async function pushDataWithCharges({ chargingManager, items, charges, isDefaultD
1814
1866
  // An item the storage would refuse fails here, before it is charged.
1815
1867
  assertItemsSerializable(rowsIn);
1816
1868
  validateItems?.(rowsIn);
1869
+ // Charges of this push that came back short: given back, and logged once the
1870
+ // outcome is known (logAtomicRefusal), not as "only N events were charged".
1871
+ const shorts = [];
1872
+ let givenBack = true;
1817
1873
  const undo = async (ops) => {
1818
1874
  for (const op of ops) {
1819
1875
  if (op.charged <= 0)
@@ -1823,61 +1879,75 @@ async function pushDataWithCharges({ chargingManager, items, charges, isDefaultD
1823
1879
  else if (!op.operationId)
1824
1880
  chargingManager.applyAcceptedCount(op.name, op.charged, 0);
1825
1881
  // Cancel / void; a refused void stays billed and becomes credit.
1826
- else
1827
- await voidOrCredit(chargingManager, op.name, op.operationId, op.charged, op.uncertain);
1882
+ else if ((await voidOrCredit(chargingManager, op.name, op.operationId, op.charged, op.uncertain)) !== 'voided')
1883
+ givenBack = false;
1828
1884
  }
1829
1885
  };
1886
+ // Per event: how many events the limit covered in its last short answer.
1887
+ const coveredCounts = () => Object.fromEntries(shorts.map((sc) => [sc.eventName, sc.covered]));
1830
1888
  const order = Object.entries(perRow).sort(([a], [b]) => Number(isSyntheticEvent(a)) - Number(isSyntheticEvent(b)) || a.localeCompare(b));
1831
1889
  let ops = [];
1832
- for (let attempt = 0; attempt < 6 && rows > 0; attempt++) {
1833
- ops = [];
1834
- let shortTo = null;
1835
- for (const [name, n] of order) {
1836
- const want = n * rows;
1837
- chargingManager._takeLastAcceptedOperationId?.();
1838
- let got;
1839
- if (takeVoidCredit(chargingManager, name, want) > 0) {
1840
- got = want;
1841
- ops.push({ name, charged: want, uncertain: false, credit: true });
1842
- }
1843
- else {
1844
- try {
1845
- const r = await chargingManager._chargeExclusive({ eventName: name, count: want });
1846
- got = r.chargedCount;
1847
- ops.push({ name, charged: got, operationId: chargingManager._takeLastAcceptedOperationId?.(), uncertain: false });
1890
+ chargingManager._shortCharges = shorts;
1891
+ try {
1892
+ for (let attempt = 0; attempt < 6 && rows > 0; attempt++) {
1893
+ ops = [];
1894
+ let shortTo = null;
1895
+ for (const [name, n] of order) {
1896
+ const want = n * rows;
1897
+ chargingManager._takeLastAcceptedOperationId?.();
1898
+ let got;
1899
+ if (takeVoidCredit(chargingManager, name, want) > 0) {
1900
+ got = want;
1901
+ ops.push({ name, charged: want, uncertain: false, credit: true });
1848
1902
  }
1849
- catch (err) {
1850
- if (!(err instanceof ChargeUncertainError)) {
1851
- await undo(ops);
1852
- throw err;
1903
+ else {
1904
+ try {
1905
+ const r = await chargingManager._chargeExclusive({ eventName: name, count: want });
1906
+ got = r.chargedCount;
1907
+ ops.push({ name, charged: got, operationId: chargingManager._takeLastAcceptedOperationId?.(), uncertain: false });
1908
+ }
1909
+ catch (err) {
1910
+ if (!(err instanceof ChargeUncertainError)) {
1911
+ await undo(ops);
1912
+ throw err;
1913
+ }
1914
+ // Not confirmed: stays pending and is re-posted at exit, so it counts as accepted.
1915
+ got = err.count;
1916
+ ops.push({ name, charged: got, operationId: err.operationId, uncertain: true });
1853
1917
  }
1854
- // Not confirmed: stays pending and is re-posted at exit, so it counts as accepted.
1855
- got = err.count;
1856
- ops.push({ name, charged: got, operationId: err.operationId, uncertain: true });
1918
+ }
1919
+ if (got < want) {
1920
+ shortTo = Math.floor(got / n);
1921
+ break;
1857
1922
  }
1858
1923
  }
1859
- if (got < want) {
1860
- shortTo = Math.floor(got / n);
1924
+ if (shortTo === null)
1861
1925
  break;
1862
- }
1926
+ // An event came back short: void this attempt and retry with the whole
1927
+ // rows the budget covers at the FULL per-row cost (sizing from the short
1928
+ // event alone came back short again: appstore 64df0a3608d74af88, 0 saved).
1929
+ // At least one single-row try: the last affordable row may still fit.
1930
+ const attemptOps = ops.map((op) => ({ name: op.name, charged: op.charged }));
1931
+ await undo(ops);
1932
+ ops = [];
1933
+ rows = Math.min(rows - 1, Math.max(chargingManager._rowsAfterShort(attemptOps, perRow), 1));
1863
1934
  }
1864
- if (shortTo === null)
1865
- break;
1866
- // An event came back short: void this attempt and retry with the whole
1867
- // rows the budget covers at the FULL per-row cost (sizing from the short
1868
- // event alone came back short again: appstore 64df0a3608d74af88, 0 saved).
1869
- // At least one single-row try: the last affordable row may still fit.
1870
- const attemptOps = ops.map((op) => ({ name: op.name, charged: op.charged }));
1871
- await undo(ops);
1872
- ops = [];
1873
- rows = Math.min(rows - 1, Math.max(chargingManager._rowsAfterShort(attemptOps, perRow), 1));
1935
+ }
1936
+ finally {
1937
+ chargingManager._shortCharges = undefined;
1874
1938
  }
1875
1939
  if (rows <= 0 || ops.length === 0) {
1876
1940
  await undo(ops);
1941
+ logAtomicRefusal(shorts, rowsIn.length, 0, givenBack);
1877
1942
  chargingManager._markChargeLimitHit();
1878
1943
  if (isDefaultDataset)
1879
1944
  chargingManager._noteRefusedDefaultDatasetItems(rowsIn.length);
1880
- return { eventChargeLimitReached: true, chargedCount: 0, chargeableWithinLimit: chargingManager._chargeableWithinLimit() };
1945
+ return {
1946
+ eventChargeLimitReached: true,
1947
+ chargedCount: 0,
1948
+ chargeableWithinLimit: chargingManager._chargeableWithinLimit(),
1949
+ coveredCounts: coveredCounts(),
1950
+ };
1881
1951
  }
1882
1952
  const kept = batch ? rowsIn : rowsIn.slice(0, rows);
1883
1953
  if (kept.length < rowsIn.length) {
@@ -1894,10 +1964,12 @@ async function pushDataWithCharges({ chargingManager, items, charges, isDefaultD
1894
1964
  await undo(ops);
1895
1965
  throw pushErr;
1896
1966
  }
1967
+ logAtomicRefusal(shorts, rowsIn.length, kept.length, givenBack);
1897
1968
  return {
1898
1969
  eventChargeLimitReached: kept.length < rowsIn.length || chargingManager.isChargeLimitReached(),
1899
1970
  chargedCount: kept.length,
1900
1971
  chargeableWithinLimit: chargingManager._chargeableWithinLimit(),
1972
+ coveredCounts: coveredCounts(),
1901
1973
  };
1902
1974
  });
1903
1975
  }
@@ -1919,6 +1991,9 @@ function pushCertainlyFailed(err) {
1919
1991
  for (let e = err, depth = 0; e && depth < 4; e = e.cause, depth++) {
1920
1992
  if (e.outcomeUnknown === true)
1921
1993
  return false;
1994
+ // The server could not tell whether the push was stored (a 503 with this type).
1995
+ if (apiErrorTypeOf(e) === 'push-outcome-unknown')
1996
+ return false;
1922
1997
  }
1923
1998
  if ((0, api_storage_client_js_1.isConnectFailure)(err))
1924
1999
  return true;
@@ -4,6 +4,7 @@ exports.DragonflyDatasetClient = void 0;
4
4
  const utils_js_1 = require("./utils.js");
5
5
  const billing_tracker_js_1 = require("./billing-tracker.js");
6
6
  const dataset_push_script_js_1 = require("./dataset-push-script.js");
7
+ const ttl_manager_js_1 = require("./ttl-manager.js");
7
8
  function makeAsyncIterableResult(promiseFn) {
8
9
  const promise = promiseFn();
9
10
  const iterable = {
@@ -60,11 +61,13 @@ class DragonflyDatasetClient {
60
61
  const raw = await this.client.redis.hget(indexKey, `id:${this.id}`);
61
62
  if (raw) {
62
63
  const data = JSON.parse(raw);
63
- if (data.name)
64
- await this.client.redis.hdel(indexKey, `name:${data.name}`);
64
+ // Name pointers are scoped by owner (name:{userId}:{name}).
65
+ const ownerId = data.userId ?? this.client.billingContext?.userId;
66
+ await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, ownerId, data.name, this.id);
65
67
  data.name = newFields.name;
66
68
  await this.client.redis.hset(indexKey, `id:${this.id}`, JSON.stringify(data));
67
- await this.client.redis.hset(indexKey, `name:${newFields.name}`, JSON.stringify(data));
69
+ if (ownerId && newFields.name)
70
+ await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(ownerId, newFields.name), JSON.stringify(data));
68
71
  }
69
72
  }
70
73
  await this.client.redis.hset(this.infoKey, 'modifiedAt', now.toISOString());
@@ -75,8 +78,7 @@ class DragonflyDatasetClient {
75
78
  await this.client.redis.del(this.infoKey, this.itemsKey);
76
79
  const indexKey = this.client.key('datasets:index');
77
80
  await this.client.redis.hdel(indexKey, `id:${this.id}`);
78
- if (info?.name)
79
- await this.client.redis.hdel(indexKey, `name:${info.name}`);
81
+ await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, info?.userId || this.client.billingContext?.userId, info?.name, this.id);
80
82
  }
81
83
  async downloadItems() {
82
84
  const result = await this._listItems();
@@ -216,6 +218,10 @@ class DragonflyDatasetClient {
216
218
  // separate round trips allowed concurrent pushes to allocate the same
217
219
  // slot indices, silently overwriting items.
218
220
  await (0, dataset_push_script_js_1.pushItemsAtomic)(this.client.redis, this.infoKey, this.itemsKey, serializedItems, totalBytes);
221
+ // The items hash is created by the first push: give it the dataset's expiry.
222
+ const expiry = this.client.redis.pipeline();
223
+ (0, ttl_manager_js_1.queueInheritExpiry)(expiry, this.infoKey, [this.itemsKey]);
224
+ await expiry.exec();
219
225
  // Track billing using client's billing context
220
226
  if (this.client.billingContext) {
221
227
  const billingTracker = new billing_tracker_js_1.BillingTracker(this.client);
@@ -9,8 +9,10 @@ class DragonflyDatasetCollectionClient {
9
9
  }
10
10
  async getOrCreate(name) {
11
11
  const indexKey = this.client.key('datasets: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, 'dataset');
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
  }
@@ -25,7 +27,7 @@ class DragonflyDatasetCollectionClient {
25
27
  };
26
28
  await this.client.redis.hset(indexKey, `id:${id}`, JSON.stringify(data));
27
29
  if (name)
28
- await this.client.redis.hset(indexKey, `name:${name}`, JSON.stringify(data));
30
+ await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(userId, name), JSON.stringify(data));
29
31
  const infoKey = this.client.key('dataset', id, 'info');
30
32
  const itemsKey = this.client.key('dataset', id, 'items');
31
33
  await this.client.redis.hset(infoKey, {
@@ -62,8 +64,7 @@ class DragonflyDatasetCollectionClient {
62
64
  const exists = await this.client.redis.exists(infoKey);
63
65
  if (!exists) {
64
66
  await this.client.redis.hdel(indexKey, `id:${parsed.id}`);
65
- if (parsed.name)
66
- await this.client.redis.hdel(indexKey, `name:${parsed.name}`);
67
+ await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, parsed.userId ?? this.client.billingContext?.userId, parsed.name, parsed.id);
67
68
  continue;
68
69
  }
69
70
  const itemCount = parseInt(await this.client.redis.hget(infoKey, 'itemCount') || '0', 10);
@@ -4,6 +4,7 @@ exports.DragonflyKeyValueStoreClient = void 0;
4
4
  const node_stream_1 = require("node:stream");
5
5
  const utils_js_1 = require("./utils.js");
6
6
  const billing_tracker_js_1 = require("./billing-tracker.js");
7
+ const ttl_manager_js_1 = require("./ttl-manager.js");
7
8
  const configuration_js_1 = require("../configuration.js");
8
9
  const kvs_iteration_js_1 = require("./kvs-iteration.js");
9
10
  function makeAsyncIterableListKeys(promiseFn) {
@@ -70,11 +71,13 @@ class DragonflyKeyValueStoreClient {
70
71
  const raw = await this.client.redis.hget(indexKey, `id:${this.id}`);
71
72
  if (raw) {
72
73
  const data = JSON.parse(raw);
73
- if (data.name)
74
- await this.client.redis.hdel(indexKey, `name:${data.name}`);
74
+ // Name pointers are scoped by owner (name:{userId}:{name}).
75
+ const ownerId = data.userId ?? this.client.billingContext?.userId;
76
+ await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, ownerId, data.name, this.id);
75
77
  data.name = newFields.name;
76
78
  await this.client.redis.hset(indexKey, `id:${this.id}`, JSON.stringify(data));
77
- await this.client.redis.hset(indexKey, `name:${newFields.name}`, JSON.stringify(data));
79
+ if (ownerId && newFields.name)
80
+ await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(ownerId, newFields.name), JSON.stringify(data));
78
81
  }
79
82
  }
80
83
  await this.client.redis.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
@@ -85,8 +88,7 @@ class DragonflyKeyValueStoreClient {
85
88
  await this.client.redis.del(this.infoKey, this.recordsKey, this.metaKey);
86
89
  const indexKey = this.client.key('kvs:index');
87
90
  await this.client.redis.hdel(indexKey, `id:${this.id}`);
88
- if (info?.name)
89
- await this.client.redis.hdel(indexKey, `name:${info.name}`);
91
+ await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, info?.userId || this.client.billingContext?.userId, info?.name, this.id);
90
92
  }
91
93
  async recordExists(key) {
92
94
  return (await this.client.redis.hexists(this.recordsKey, key)) === 1;
@@ -168,6 +170,8 @@ class DragonflyKeyValueStoreClient {
168
170
  pipeline.hset(this.metaKey, key, JSON.stringify({ contentType }));
169
171
  }
170
172
  pipeline.hset(this.infoKey, 'modifiedAt', (0, utils_js_1.nowISO)().toISOString());
173
+ // Records/meta are created by the first write: give them the store's expiry.
174
+ (0, ttl_manager_js_1.queueInheritExpiry)(pipeline, this.infoKey, [this.recordsKey, this.metaKey]);
171
175
  await pipeline.exec();
172
176
  // Track billing using client's billing context
173
177
  if (this.client.billingContext) {
@@ -9,17 +9,15 @@ class DragonflyKeyValueStoreCollectionClient {
9
9
  }
10
10
  async getOrCreate(name) {
11
11
  const indexKey = this.client.key('kvs: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, 'key-value store');
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
- // Derive ownership from the active billing context (as the request-queue
20
- // client does); the server authorizes against this persisted field.
21
- // The legacy '1' placeholder applies only when no context is present.
22
- const userId = this.client.billingContext?.userId ?? '1';
23
21
  const data = {
24
22
  id,
25
23
  name,
@@ -30,7 +28,7 @@ class DragonflyKeyValueStoreCollectionClient {
30
28
  };
31
29
  await this.client.redis.hset(indexKey, `id:${id}`, JSON.stringify(data));
32
30
  if (name)
33
- await this.client.redis.hset(indexKey, `name:${name}`, JSON.stringify(data));
31
+ await this.client.redis.hset(indexKey, (0, utils_js_1.ownerNameField)(userId, name), JSON.stringify(data));
34
32
  const infoKey = this.client.key('kvs', id, 'info');
35
33
  const recordsKey = this.client.key('kvs', id, 'records');
36
34
  const metaKey = this.client.key('kvs', id, 'meta');
@@ -68,8 +66,7 @@ class DragonflyKeyValueStoreCollectionClient {
68
66
  const exists = await this.client.redis.exists(infoKey);
69
67
  if (!exists) {
70
68
  await this.client.redis.hdel(indexKey, `id:${parsed.id}`);
71
- if (parsed.name)
72
- await this.client.redis.hdel(indexKey, `name:${parsed.name}`);
69
+ await (0, utils_js_1.releaseOwnerName)(this.client.redis, indexKey, parsed.userId, parsed.name, parsed.id);
73
70
  continue;
74
71
  }
75
72
  items.push(parsed);
@@ -1,5 +1,5 @@
1
1
  import type { DragonflyClient } from './dragonfly-client.js';
2
- import type { UserPlan } from './ttl-manager.js';
2
+ import { type UserPlan } from './ttl-manager.js';
3
3
  /**
4
4
  * Log configuration
5
5
  */
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.LogManager = void 0;
4
+ const ttl_manager_js_1 = require("./ttl-manager.js");
4
5
  const run_metadata_js_1 = require("./run-metadata.js");
5
6
  const DEFAULT_LOG_CONFIG = {
6
7
  maxLogSize: 5 * 1024 * 1024, // 5MB
@@ -89,10 +90,14 @@ class LogManager {
89
90
  // a valid code-point boundary and never store a broken sequence.
90
91
  const alignedOffset = LogManager.alignUtf8Boundary(trailingBuf, 0);
91
92
  const safeTrailing = trailingBuf.subarray(alignedOffset);
92
- // Overwrite the key with only the safe trailing portion
93
- await this.client.redis.set(key, safeTrailing);
94
- // Mark log as truncated (NX — only set once; ignore if already set)
93
+ // Overwrite the key with only the safe trailing portion (keeping the log's expiry)
94
+ await this.client.redis.set(key, safeTrailing, 'KEEPTTL');
95
+ // Mark log as truncated (NX — only set once; ignore if already set). The marker
96
+ // is created here, after setLogTtl ran: it takes the log's expiry.
95
97
  await this.client.redis.set(this.getTruncatedKey(runId), '1', 'NX');
98
+ const expiry = this.client.redis.pipeline();
99
+ (0, ttl_manager_js_1.queueInheritExpiry)(expiry, key, [this.getTruncatedKey(runId)]);
100
+ await expiry.exec();
96
101
  }
97
102
  }
98
103
  /**
@@ -118,7 +123,7 @@ class LogManager {
118
123
  const key = this.getLogKey(runId);
119
124
  const ttl = this.client.ttlManager.getTtl('logs', plan);
120
125
  if (ttl !== null && ttl > 0) {
121
- await this.client.ttlManager.setExpiry(this.client.redis, [key], ttl);
126
+ await this.client.ttlManager.setExpiry(this.client.redis, [key, this.getTruncatedKey(runId)], ttl);
122
127
  }
123
128
  }
124
129
  /**
@@ -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.40",
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",