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 +10 -0
- package/dist/actor.js +11 -0
- package/dist/charging.d.ts +23 -0
- package/dist/charging.js +120 -45
- package/dist/storage/dataset-client.js +11 -5
- package/dist/storage/dataset-collection-client.js +5 -4
- package/dist/storage/key-value-store-client.js +9 -5
- package/dist/storage/kv-store-collection-client.js +5 -8
- package/dist/storage/log-manager.d.ts +1 -1
- package/dist/storage/log-manager.js +9 -4
- package/dist/storage/request-queue-client.d.ts +11 -0
- package/dist/storage/request-queue-client.js +59 -14
- package/dist/storage/rq-collection-client.js +5 -9
- package/dist/storage/rq-lock-script.d.ts +13 -7
- package/dist/storage/rq-lock-script.js +48 -26
- package/dist/storage/run-metadata.js +6 -0
- package/dist/storage/ttl-manager.d.ts +3 -0
- package/dist/storage/ttl-manager.js +20 -0
- package/dist/storage/utils.d.ts +19 -0
- package/dist/storage/utils.js +39 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +12 -5
- package/package.json +2 -1
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.
|
package/dist/charging.d.ts
CHANGED
|
@@ -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')
|
|
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')
|
|
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
|
|
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')
|
|
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
|
-
|
|
1332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1833
|
-
|
|
1834
|
-
let
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
got
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
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
|
-
|
|
1850
|
-
|
|
1851
|
-
await
|
|
1852
|
-
|
|
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
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1918
|
+
}
|
|
1919
|
+
if (got < want) {
|
|
1920
|
+
shortTo = Math.floor(got / n);
|
|
1921
|
+
break;
|
|
1857
1922
|
}
|
|
1858
1923
|
}
|
|
1859
|
-
if (
|
|
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
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
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 {
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
415
|
-
const
|
|
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, ...
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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:
|
|
41
|
-
* matches the clientKey (and optional
|
|
42
|
-
* per-request loop, optionally
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
81
|
-
* matches the clientKey (and optional
|
|
82
|
-
* per-request loop, optionally
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
-
|
|
105
|
-
local
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
*/
|
package/dist/storage/utils.d.ts
CHANGED
|
@@ -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>;
|
package/dist/storage/utils.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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",
|