scrapely 1.2.3 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -1
- package/dist/actor.d.ts +703 -0
- package/dist/actor.js +1441 -0
- package/dist/browser-configuration.d.ts +58 -0
- package/dist/browser-configuration.js +128 -0
- package/dist/browser-locations.d.ts +5 -0
- package/dist/browser-locations.js +5469 -0
- package/dist/charging.d.ts +153 -0
- package/dist/charging.js +782 -0
- package/dist/charging_client.d.ts +18 -0
- package/dist/charging_client.js +49 -0
- package/dist/configuration.d.ts +121 -0
- package/dist/configuration.js +128 -0
- package/dist/crypto/secrets.d.ts +44 -0
- package/dist/crypto/secrets.js +164 -0
- package/dist/events/events-client.d.ts +59 -0
- package/dist/events/events-client.js +120 -0
- package/dist/events/scrapely-event-manager.d.ts +13 -0
- package/dist/events/scrapely-event-manager.js +61 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +95 -0
- package/dist/log-env.d.ts +1 -0
- package/dist/log-env.js +12 -0
- package/dist/manifest.d.ts +30 -0
- package/dist/manifest.js +47 -0
- package/dist/patch-browser-pool.d.ts +66 -0
- package/dist/patch-browser-pool.js +827 -0
- package/dist/proxy-configuration.d.ts +118 -0
- package/dist/proxy-configuration.js +88 -0
- package/dist/request-list.d.ts +57 -0
- package/dist/request-list.js +79 -0
- package/dist/runner/docker-manager.d.ts +88 -0
- package/dist/runner/docker-manager.js +283 -0
- package/dist/runner/index.d.ts +2 -0
- package/dist/runner/index.js +7 -0
- package/dist/runner/timeout-manager.d.ts +43 -0
- package/dist/runner/timeout-manager.js +90 -0
- package/dist/scrapely-proxy-configuration.d.ts +99 -0
- package/dist/scrapely-proxy-configuration.js +559 -0
- package/dist/storage/api-storage-client.d.ts +199 -0
- package/dist/storage/api-storage-client.js +910 -0
- package/dist/storage/billing-tracker.d.ts +163 -0
- package/dist/storage/billing-tracker.js +260 -0
- package/dist/storage/dataset-client.d.ts +43 -0
- package/dist/storage/dataset-client.js +232 -0
- package/dist/storage/dataset-collection-client.d.ts +8 -0
- package/dist/storage/dataset-collection-client.js +75 -0
- package/dist/storage/dataset-push-script.d.ts +18 -0
- package/dist/storage/dataset-push-script.js +38 -0
- package/dist/storage/dragonfly-client.d.ts +94 -0
- package/dist/storage/dragonfly-client.js +167 -0
- package/dist/storage/index.d.ts +11 -0
- package/dist/storage/index.js +25 -0
- package/dist/storage/key-value-store-client.d.ts +61 -0
- package/dist/storage/key-value-store-client.js +306 -0
- package/dist/storage/kv-store-collection-client.d.ts +8 -0
- package/dist/storage/kv-store-collection-client.js +76 -0
- package/dist/storage/kvs-iteration.d.ts +17 -0
- package/dist/storage/kvs-iteration.js +126 -0
- package/dist/storage/log-manager.d.ts +114 -0
- package/dist/storage/log-manager.js +244 -0
- package/dist/storage/patch-storage.d.ts +11 -0
- package/dist/storage/patch-storage.js +177 -0
- package/dist/storage/request-queue-client.d.ts +93 -0
- package/dist/storage/request-queue-client.js +522 -0
- package/dist/storage/rq-collection-client.d.ts +8 -0
- package/dist/storage/rq-collection-client.js +89 -0
- package/dist/storage/rq-list-requests.d.ts +59 -0
- package/dist/storage/rq-list-requests.js +114 -0
- package/dist/storage/rq-lock-script.d.ts +56 -0
- package/dist/storage/rq-lock-script.js +127 -0
- package/dist/storage/run-metadata.d.ts +98 -0
- package/dist/storage/run-metadata.js +256 -0
- package/dist/storage/s3-client.d.ts +103 -0
- package/dist/storage/s3-client.js +302 -0
- package/dist/storage/ttl-manager.d.ts +70 -0
- package/dist/storage/ttl-manager.js +115 -0
- package/dist/storage/utils.d.ts +3 -0
- package/dist/storage/utils.js +19 -0
- package/dist/storage-identifiers.d.ts +20 -0
- package/dist/storage-identifiers.js +6 -0
- package/dist/utils.d.ts +54 -0
- package/dist/utils.js +63 -0
- package/dist/validator.d.ts +24 -0
- package/dist/validator.js +158 -0
- package/package.json +90 -9
package/dist/charging.js
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ChargingManager = exports.DEFAULT_DATASET_ITEM_EVENT = exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT = exports.APIFY_DEFAULT_DATASET_ITEM_EVENT = exports.ChargeRejectedError = exports.ChargeUncertainError = exports.CHARGE_IDEMPOTENCY_SAFE_AGE_MS = void 0;
|
|
4
|
+
exports.isDefaultDatasetItemEvent = isDefaultDatasetItemEvent;
|
|
5
|
+
exports.resolveDefaultDatasetItemEventName = resolveDefaultDatasetItemEventName;
|
|
6
|
+
exports.hasDefaultDatasetItemEventInPricing = hasDefaultDatasetItemEventInPricing;
|
|
7
|
+
exports.mergeChargeResults = mergeChargeResults;
|
|
8
|
+
exports.pushDataAndCharge = pushDataAndCharge;
|
|
9
|
+
const node_crypto_1 = require("node:crypto");
|
|
10
|
+
/** Client safe-replay window; server TTL is longer (35d). */
|
|
11
|
+
exports.CHARGE_IDEMPOTENCY_SAFE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
12
|
+
const PENDING_MAX_ENTRIES = 1024;
|
|
13
|
+
const POST_ATTEMPTS = 3;
|
|
14
|
+
class ChargeUncertainError extends Error {
|
|
15
|
+
operationId;
|
|
16
|
+
eventName;
|
|
17
|
+
count;
|
|
18
|
+
constructor(message, opts) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'ChargeUncertainError';
|
|
21
|
+
this.operationId = opts.operationId;
|
|
22
|
+
this.eventName = opts.eventName;
|
|
23
|
+
this.count = opts.count;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
exports.ChargeUncertainError = ChargeUncertainError;
|
|
27
|
+
/** Apify-compatible synthetic event for default-dataset items in pay-per-event actors. */
|
|
28
|
+
/** Definitive platform rejection (402/400/409). Do not retry with the same key. */
|
|
29
|
+
class ChargeRejectedError extends Error {
|
|
30
|
+
operationId;
|
|
31
|
+
eventName;
|
|
32
|
+
count;
|
|
33
|
+
statusCode;
|
|
34
|
+
reason;
|
|
35
|
+
constructor(message, opts) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'ChargeRejectedError';
|
|
38
|
+
this.operationId = opts.operationId;
|
|
39
|
+
this.eventName = opts.eventName;
|
|
40
|
+
this.count = opts.count;
|
|
41
|
+
this.statusCode = opts.statusCode;
|
|
42
|
+
this.reason = opts.reason;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
exports.ChargeRejectedError = ChargeRejectedError;
|
|
46
|
+
exports.APIFY_DEFAULT_DATASET_ITEM_EVENT = 'apify-default-dataset-item';
|
|
47
|
+
/** Scrapely-native synthetic event for default-dataset items. */
|
|
48
|
+
exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT = 'scrapely-default-dataset-item';
|
|
49
|
+
/** Apify-compatible alias — imported actors expect this constant name. */
|
|
50
|
+
exports.DEFAULT_DATASET_ITEM_EVENT = exports.APIFY_DEFAULT_DATASET_ITEM_EVENT;
|
|
51
|
+
function isDefaultDatasetItemEvent(eventName) {
|
|
52
|
+
return eventName === exports.APIFY_DEFAULT_DATASET_ITEM_EVENT || eventName === exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT;
|
|
53
|
+
}
|
|
54
|
+
function resolveDefaultDatasetItemEventName(pricingInfo) {
|
|
55
|
+
if (exports.APIFY_DEFAULT_DATASET_ITEM_EVENT in pricingInfo) {
|
|
56
|
+
return exports.APIFY_DEFAULT_DATASET_ITEM_EVENT;
|
|
57
|
+
}
|
|
58
|
+
if (exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT in pricingInfo) {
|
|
59
|
+
return exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT;
|
|
60
|
+
}
|
|
61
|
+
return exports.APIFY_DEFAULT_DATASET_ITEM_EVENT;
|
|
62
|
+
}
|
|
63
|
+
function hasDefaultDatasetItemEventInPricing(perEventPrices) {
|
|
64
|
+
return (exports.APIFY_DEFAULT_DATASET_ITEM_EVENT in perEventPrices ||
|
|
65
|
+
exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT in perEventPrices);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Events that must never be POSTed to the charge API (server derives them at settle).
|
|
69
|
+
* Explicit names only — a prefix heuristic would drop real priced events like `apify-foo`.
|
|
70
|
+
*/
|
|
71
|
+
function isSyntheticEvent(eventName) {
|
|
72
|
+
return isDefaultDatasetItemEvent(eventName);
|
|
73
|
+
}
|
|
74
|
+
function mergeChargeResults(a, b) {
|
|
75
|
+
return {
|
|
76
|
+
eventChargeLimitReached: a.eventChargeLimitReached || b.eventChargeLimitReached,
|
|
77
|
+
chargedCount: a.chargedCount + b.chargedCount,
|
|
78
|
+
chargeableWithinLimit: Object.fromEntries(Object.entries(a.chargeableWithinLimit).map(([key, oldValue]) => [
|
|
79
|
+
key,
|
|
80
|
+
Math.min(oldValue, b.chargeableWithinLimit[key] ?? oldValue),
|
|
81
|
+
])),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Pay-per-event charging for platform runs.
|
|
86
|
+
* Reports charges via POST /v2/actor-runs/:runId/charge (Redis → worker billing).
|
|
87
|
+
*/
|
|
88
|
+
class ChargingManager {
|
|
89
|
+
configuration;
|
|
90
|
+
apifyClient;
|
|
91
|
+
/** Set during actor shutdown; rejects new charge operations after the final flush. */
|
|
92
|
+
_closed = false;
|
|
93
|
+
/** @internal Called by Actor shutdown before the final flush. */
|
|
94
|
+
close() {
|
|
95
|
+
this._closed = true;
|
|
96
|
+
}
|
|
97
|
+
/** @internal Whether shutdown has closed this manager to new charges. */
|
|
98
|
+
isClosed() {
|
|
99
|
+
return this._closed;
|
|
100
|
+
}
|
|
101
|
+
LOCAL_CHARGING_LOG_DATASET_NAME = 'charging_log';
|
|
102
|
+
PLATFORM_CHARGING_LOG_DATASET_ID_KEY = 'CHARGING_LOG_DATASET_ID';
|
|
103
|
+
maxTotalChargeUsd;
|
|
104
|
+
isAtHome;
|
|
105
|
+
actorRunId;
|
|
106
|
+
pricingModel;
|
|
107
|
+
pricingInfo = {};
|
|
108
|
+
chargingState;
|
|
109
|
+
chargingLogDataset;
|
|
110
|
+
notPpeWarningPrinted = false;
|
|
111
|
+
/** Unresolved platform POSTs: key = idempotency UUID (never event name). */
|
|
112
|
+
pendingCharges = new Map();
|
|
113
|
+
flushInFlight = null;
|
|
114
|
+
operationTail = Promise.resolve();
|
|
115
|
+
postAttempts = POST_ATTEMPTS;
|
|
116
|
+
pendingMaxEntries = PENDING_MAX_ENTRIES;
|
|
117
|
+
idempotencySafeAgeMs = exports.CHARGE_IDEMPOTENCY_SAFE_AGE_MS;
|
|
118
|
+
constructor(configuration, apifyClient) {
|
|
119
|
+
this.configuration = configuration;
|
|
120
|
+
this.apifyClient = apifyClient;
|
|
121
|
+
this.maxTotalChargeUsd = configuration.get('maxTotalChargeUsd') || Infinity;
|
|
122
|
+
this.isAtHome = !!configuration.get('isAtHome');
|
|
123
|
+
this.actorRunId = configuration.get('actorRunId') || '';
|
|
124
|
+
}
|
|
125
|
+
/** @internal Serialize allowance calculation, side effects, and local reservation. */
|
|
126
|
+
async _withOperationLock(operation) {
|
|
127
|
+
const previous = this.operationTail;
|
|
128
|
+
let release;
|
|
129
|
+
this.operationTail = new Promise((resolve) => {
|
|
130
|
+
release = resolve;
|
|
131
|
+
});
|
|
132
|
+
await previous;
|
|
133
|
+
try {
|
|
134
|
+
return await operation();
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
release();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
get isPayPerEvent() {
|
|
141
|
+
return this.pricingModel === 'PAY_PER_EVENT';
|
|
142
|
+
}
|
|
143
|
+
ensureInitialized() {
|
|
144
|
+
if (this.chargingState === undefined) {
|
|
145
|
+
throw new Error('ChargingManager is not initialized');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async fetchPricingInfo() {
|
|
149
|
+
const actorPricingInfo = this.configuration.get('actorPricingInfo');
|
|
150
|
+
const chargedEventCounts = this.configuration.get('chargedEventCounts');
|
|
151
|
+
if (actorPricingInfo && chargedEventCounts) {
|
|
152
|
+
return {
|
|
153
|
+
pricingInfo: JSON.parse(actorPricingInfo),
|
|
154
|
+
chargedEventCounts: JSON.parse(chargedEventCounts),
|
|
155
|
+
maxTotalChargeUsd: this.configuration.get('maxTotalChargeUsd') || Infinity,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (this.isAtHome) {
|
|
159
|
+
if (!this.actorRunId) {
|
|
160
|
+
throw new Error('Actor run ID not found even though the Actor is running on the platform');
|
|
161
|
+
}
|
|
162
|
+
const run = await this.apifyClient.run(this.actorRunId).get();
|
|
163
|
+
if (!run) {
|
|
164
|
+
throw new Error('Actor run not found');
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
pricingInfo: run.pricingInfo,
|
|
168
|
+
chargedEventCounts: run.chargedEventCounts,
|
|
169
|
+
maxTotalChargeUsd: run.options?.maxTotalChargeUsd || Infinity,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
pricingInfo: undefined,
|
|
174
|
+
chargedEventCounts: {},
|
|
175
|
+
maxTotalChargeUsd: this.configuration.get('maxTotalChargeUsd') || Infinity,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Initialize the ChargingManager by loading pricing information and charging state via platform API.
|
|
180
|
+
*/
|
|
181
|
+
async init() {
|
|
182
|
+
const useChargingLogDataset = this.configuration.get('useChargingLogDataset');
|
|
183
|
+
if (useChargingLogDataset && this.isAtHome) {
|
|
184
|
+
throw new Error('Using the ACTOR_USE_CHARGING_LOG_DATASET environment variable is only supported in a local development environment');
|
|
185
|
+
}
|
|
186
|
+
if (this.configuration.get('testPayPerEvent') && this.isAtHome) {
|
|
187
|
+
throw new Error('Using the ACTOR_TEST_PAY_PER_EVENT environment variable is only supported in a local development environment');
|
|
188
|
+
}
|
|
189
|
+
const { pricingInfo, chargedEventCounts, maxTotalChargeUsd } = await this.fetchPricingInfo();
|
|
190
|
+
if (this.configuration.get('testPayPerEvent')) {
|
|
191
|
+
this.pricingModel = 'PAY_PER_EVENT';
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
this.pricingModel ??= pricingInfo?.pricingModel;
|
|
195
|
+
}
|
|
196
|
+
if (pricingInfo?.pricingModel === 'PAY_PER_EVENT') {
|
|
197
|
+
const events = pricingInfo.pricingPerEvent?.actorChargeEvents ?? {};
|
|
198
|
+
for (const [eventName, eventPricing] of Object.entries(events)) {
|
|
199
|
+
this.pricingInfo[eventName] = {
|
|
200
|
+
price: eventPricing.eventPriceUsd,
|
|
201
|
+
title: eventPricing.eventTitle,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
this.maxTotalChargeUsd = maxTotalChargeUsd;
|
|
205
|
+
}
|
|
206
|
+
this.chargingState = {};
|
|
207
|
+
for (const [eventName, chargeCount] of Object.entries(chargedEventCounts ?? {})) {
|
|
208
|
+
this.chargingState[eventName] = {
|
|
209
|
+
chargeCount,
|
|
210
|
+
totalChargedAmount: chargeCount * (this.pricingInfo[eventName]?.price ?? 0),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (!this.isPayPerEvent || !useChargingLogDataset) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const { Dataset, KeyValueStore } = await import('@crawlee/core');
|
|
217
|
+
if (this.isAtHome) {
|
|
218
|
+
const defaultStore = await KeyValueStore.open();
|
|
219
|
+
const storedDatasetId = await defaultStore.getValue(this.PLATFORM_CHARGING_LOG_DATASET_ID_KEY);
|
|
220
|
+
const datasetId = storedDatasetId ??
|
|
221
|
+
(await (async () => {
|
|
222
|
+
const dataset = await this.apifyClient.datasets().getOrCreate();
|
|
223
|
+
await defaultStore.setValue(this.PLATFORM_CHARGING_LOG_DATASET_ID_KEY, dataset.id);
|
|
224
|
+
return dataset.id;
|
|
225
|
+
})());
|
|
226
|
+
this.chargingLogDataset = await Dataset.open(datasetId);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
if (this.configuration.get('purgeOnStart')) {
|
|
230
|
+
const dataset = await Dataset.open(this.LOCAL_CHARGING_LOG_DATASET_NAME);
|
|
231
|
+
await dataset.drop();
|
|
232
|
+
}
|
|
233
|
+
this.chargingLogDataset = await Dataset.open(this.LOCAL_CHARGING_LOG_DATASET_NAME);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
getPricingInfo() {
|
|
237
|
+
if (this.chargingState === undefined) {
|
|
238
|
+
throw new Error('ChargingManager is not initialized');
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
pricingModel: this.pricingModel,
|
|
242
|
+
maxTotalChargeUsd: Number.isFinite(this.maxTotalChargeUsd) ? this.maxTotalChargeUsd : Infinity,
|
|
243
|
+
isPayPerEvent: this.isPayPerEvent,
|
|
244
|
+
perEventPrices: Object.fromEntries(Object.entries(this.pricingInfo).map(([name, { price }]) => [name, price])),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
getChargedEventCount(eventName) {
|
|
248
|
+
this.ensureInitialized();
|
|
249
|
+
if (isDefaultDatasetItemEvent(eventName)) {
|
|
250
|
+
return ((this.chargingState[exports.APIFY_DEFAULT_DATASET_ITEM_EVENT]?.chargeCount ?? 0) +
|
|
251
|
+
(this.chargingState[exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT]?.chargeCount ?? 0));
|
|
252
|
+
}
|
|
253
|
+
return this.chargingState[eventName]?.chargeCount ?? 0;
|
|
254
|
+
}
|
|
255
|
+
getMaxTotalChargeUsd() {
|
|
256
|
+
this.ensureInitialized();
|
|
257
|
+
return this.maxTotalChargeUsd;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Helper to calculate how many items can be pushed within charging limits.
|
|
261
|
+
* Returns the limited items and count to charge.
|
|
262
|
+
*/
|
|
263
|
+
calculatePushDataLimits(params) {
|
|
264
|
+
this.ensureInitialized();
|
|
265
|
+
const { eventName, isDefaultDataset, items } = params;
|
|
266
|
+
const itemsArray = Array.isArray(items) ? items : [items];
|
|
267
|
+
if (!this.isPayPerEvent) {
|
|
268
|
+
return {
|
|
269
|
+
eventsToCharge: {},
|
|
270
|
+
limitedItems: itemsArray,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const defaultDatasetItemEvent = resolveDefaultDatasetItemEventName(this.pricingInfo);
|
|
274
|
+
const itemPrice = ((eventName !== undefined ? this.calculateEventPrice(eventName) : undefined) ?? 0) +
|
|
275
|
+
((isDefaultDataset ? this.calculateEventPrice(defaultDatasetItemEvent) : undefined) ?? 0);
|
|
276
|
+
const maxChargedCount = itemPrice > 0 ? this.calculateMaxChargesByPrice(itemPrice) : Infinity;
|
|
277
|
+
// Dataset writes must never exceed the reservable budget. Direct charge()
|
|
278
|
+
// may preserve its legacy one-event overdraw behavior, but persisting an
|
|
279
|
+
// item first requires a strict allowance.
|
|
280
|
+
const itemsToKeep = Math.min(itemsArray.length, maxChargedCount);
|
|
281
|
+
const eventsToCharge = {};
|
|
282
|
+
if (eventName !== undefined && itemsToKeep > 0) {
|
|
283
|
+
eventsToCharge[eventName] = itemsToKeep;
|
|
284
|
+
}
|
|
285
|
+
if (isDefaultDataset && itemsToKeep > 0) {
|
|
286
|
+
eventsToCharge[defaultDatasetItemEvent] = itemsToKeep;
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
limitedItems: itemsToKeep >= itemsArray.length ? itemsArray : itemsArray.slice(0, itemsToKeep),
|
|
290
|
+
eventsToCharge,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
calculateTotalChargedAmount() {
|
|
294
|
+
this.ensureInitialized();
|
|
295
|
+
const result = Object.values(this.chargingState).reduce((sum, item) => sum + item.totalChargedAmount, 0);
|
|
296
|
+
return Number(result.toFixed(6));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* How many events of a given type can still be charged for before reaching the limit;
|
|
300
|
+
* If the event is not registered, returns Infinity (free of charge)
|
|
301
|
+
*/
|
|
302
|
+
calculateMaxEventChargeCountWithinLimit(eventName) {
|
|
303
|
+
this.ensureInitialized();
|
|
304
|
+
const price = this.calculateEventPrice(eventName);
|
|
305
|
+
if (!price) {
|
|
306
|
+
return Infinity;
|
|
307
|
+
}
|
|
308
|
+
return this.calculateMaxChargesByPrice(price);
|
|
309
|
+
}
|
|
310
|
+
calculateEventPrice(eventName) {
|
|
311
|
+
if (!this.isAtHome) {
|
|
312
|
+
return 1;
|
|
313
|
+
}
|
|
314
|
+
if (isDefaultDatasetItemEvent(eventName)) {
|
|
315
|
+
return (this.pricingInfo[exports.APIFY_DEFAULT_DATASET_ITEM_EVENT]?.price ??
|
|
316
|
+
this.pricingInfo[exports.SCRAPELY_DEFAULT_DATASET_ITEM_EVENT]?.price);
|
|
317
|
+
}
|
|
318
|
+
return this.pricingInfo[eventName]?.price;
|
|
319
|
+
}
|
|
320
|
+
getEventPricing(eventName) {
|
|
321
|
+
const canonicalEventName = isDefaultDatasetItemEvent(eventName)
|
|
322
|
+
? resolveDefaultDatasetItemEventName(this.pricingInfo)
|
|
323
|
+
: eventName;
|
|
324
|
+
if (this.pricingInfo[canonicalEventName]) {
|
|
325
|
+
return this.pricingInfo[canonicalEventName];
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
price: this.isAtHome ? 0 : 1,
|
|
329
|
+
title: `Unknown event '${eventName}'`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
normalizeChargingEventName(eventName) {
|
|
333
|
+
if (isDefaultDatasetItemEvent(eventName)) {
|
|
334
|
+
return resolveDefaultDatasetItemEventName(this.pricingInfo);
|
|
335
|
+
}
|
|
336
|
+
return eventName;
|
|
337
|
+
}
|
|
338
|
+
calculateMaxChargesByPrice(price) {
|
|
339
|
+
const unroundedResult = (this.maxTotalChargeUsd - this.calculateTotalChargedAmount()) / price;
|
|
340
|
+
const roundedResult = Math.floor(Number(unroundedResult.toFixed(4)));
|
|
341
|
+
return Math.max(0, roundedResult);
|
|
342
|
+
}
|
|
343
|
+
getChargeableWithinLimit() {
|
|
344
|
+
if (Object.keys(this.pricingInfo).length === 0) {
|
|
345
|
+
return {};
|
|
346
|
+
}
|
|
347
|
+
return Object.fromEntries(Object.keys(this.pricingInfo).map((name) => [
|
|
348
|
+
name,
|
|
349
|
+
this.calculateMaxEventChargeCountWithinLimit(name),
|
|
350
|
+
]));
|
|
351
|
+
}
|
|
352
|
+
quarantineStalePending() {
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
for (const [key, pending] of this.pendingCharges) {
|
|
355
|
+
if (now - pending.createdAt > this.idempotencySafeAgeMs) {
|
|
356
|
+
// Unwind local reservation — dropping without reverse permanently
|
|
357
|
+
// consumes budget the same way a rejected pending used to.
|
|
358
|
+
const eventPrice = this.getEventPricing(pending.eventName).price;
|
|
359
|
+
this.applyAcceptedCount(pending.eventName, pending.count, 0, eventPrice);
|
|
360
|
+
this.pendingCharges.delete(key);
|
|
361
|
+
console.error(`[Scrapely] Quarantined stale charge intent past safe idempotency age ` +
|
|
362
|
+
`(key=${key} event=${pending.eventName} count=${pending.count}); ` +
|
|
363
|
+
`unwound local reservation; will not replay`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* POST a charge intent. Returns acceptedCount on success.
|
|
369
|
+
* Throws ChargeRejectedError for definitive platform rejections (no retry).
|
|
370
|
+
* Returns null when the outcome is uncertain after retries.
|
|
371
|
+
*/
|
|
372
|
+
async postChargeWithRetries(intent) {
|
|
373
|
+
let lastError;
|
|
374
|
+
for (let attempt = 0; attempt < this.postAttempts; attempt++) {
|
|
375
|
+
try {
|
|
376
|
+
const response = await this.apifyClient.run(this.actorRunId).charge({
|
|
377
|
+
eventName: intent.eventName,
|
|
378
|
+
count: intent.count,
|
|
379
|
+
idempotencyKey: intent.idempotencyKey,
|
|
380
|
+
});
|
|
381
|
+
const body = extractChargeResponseBody(response);
|
|
382
|
+
const accepted = readAcceptedCount(body, intent.count);
|
|
383
|
+
return accepted;
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
lastError = err;
|
|
387
|
+
const rejected = classifyChargeHttpError(err, intent);
|
|
388
|
+
if (rejected) {
|
|
389
|
+
throw rejected;
|
|
390
|
+
}
|
|
391
|
+
if (attempt + 1 < this.postAttempts) {
|
|
392
|
+
await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
console.warn(`[Scrapely] Failed to record charge for event ${intent.eventName} ` +
|
|
397
|
+
`(count=${intent.count}, key=${intent.idempotencyKey}): ` +
|
|
398
|
+
`${lastError?.message ?? lastError}`);
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
/** Clamp local reservation down when the server accepted fewer events. */
|
|
402
|
+
/** @internal Used by pushDataAndCharge dual-event reconciliation. */
|
|
403
|
+
applyAcceptedCount(eventName, reservedCount, acceptedCount, eventPrice) {
|
|
404
|
+
if (acceptedCount >= reservedCount) {
|
|
405
|
+
return reservedCount;
|
|
406
|
+
}
|
|
407
|
+
const price = eventPrice ?? this.getEventPricing(eventName).price;
|
|
408
|
+
const delta = reservedCount - acceptedCount;
|
|
409
|
+
const state = this.chargingState?.[eventName];
|
|
410
|
+
if (state) {
|
|
411
|
+
state.chargeCount = Math.max(0, state.chargeCount - delta);
|
|
412
|
+
state.totalChargedAmount = Math.max(0, state.totalChargedAmount - delta * price);
|
|
413
|
+
}
|
|
414
|
+
return Math.max(0, acceptedCount);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Best-effort flush of open charge intents (same keys, no local counter bump).
|
|
418
|
+
* @returns number of intents confirmed
|
|
419
|
+
*/
|
|
420
|
+
/**
|
|
421
|
+
* Best-effort flush of open charge intents under the operation lock.
|
|
422
|
+
* Drains in batches until empty or deadlineMs elapses.
|
|
423
|
+
*/
|
|
424
|
+
async flushPending(maxIntents = 32, deadlineMs) {
|
|
425
|
+
if (!this.actorRunId)
|
|
426
|
+
return 0;
|
|
427
|
+
// Serialize with charge/push so concurrent flush cannot double-unwind.
|
|
428
|
+
return this._withOperationLock(() => this._flushPendingExclusive(maxIntents, deadlineMs));
|
|
429
|
+
}
|
|
430
|
+
/** @internal Caller must hold the operation lock. */
|
|
431
|
+
async _flushPendingExclusive(maxIntents = 32, deadlineMs) {
|
|
432
|
+
if (!this.actorRunId)
|
|
433
|
+
return 0;
|
|
434
|
+
if (this.flushInFlight) {
|
|
435
|
+
return this.flushInFlight;
|
|
436
|
+
}
|
|
437
|
+
const deadline = deadlineMs !== undefined ? Date.now() + Math.max(0, deadlineMs) : undefined;
|
|
438
|
+
this.flushInFlight = (async () => {
|
|
439
|
+
let confirmedTotal = 0;
|
|
440
|
+
// Drain loop: keep flushing batches until empty or deadline.
|
|
441
|
+
for (;;) {
|
|
442
|
+
if (deadline !== undefined && Date.now() >= deadline)
|
|
443
|
+
break;
|
|
444
|
+
this.quarantineStalePending();
|
|
445
|
+
if (this.pendingCharges.size === 0)
|
|
446
|
+
break;
|
|
447
|
+
const snapshot = [...this.pendingCharges.values()].slice(0, Math.max(0, maxIntents));
|
|
448
|
+
if (snapshot.length === 0)
|
|
449
|
+
break;
|
|
450
|
+
let confirmed = 0;
|
|
451
|
+
for (const intent of snapshot) {
|
|
452
|
+
if (deadline !== undefined && Date.now() >= deadline)
|
|
453
|
+
break;
|
|
454
|
+
try {
|
|
455
|
+
const accepted = await this.postChargeWithRetries(intent);
|
|
456
|
+
if (accepted !== null) {
|
|
457
|
+
const eventPrice = this.getEventPricing(intent.eventName).price;
|
|
458
|
+
this.applyAcceptedCount(intent.eventName, intent.count, accepted, eventPrice);
|
|
459
|
+
this.pendingCharges.delete(intent.idempotencyKey);
|
|
460
|
+
confirmed += 1;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
if (err instanceof ChargeRejectedError) {
|
|
465
|
+
const eventPrice = this.getEventPricing(intent.eventName).price;
|
|
466
|
+
this.applyAcceptedCount(intent.eventName, intent.count, 0, eventPrice);
|
|
467
|
+
this.pendingCharges.delete(intent.idempotencyKey);
|
|
468
|
+
console.warn(`[Scrapely] Dropping rejected pending charge ` +
|
|
469
|
+
`(event=${intent.eventName} key=${intent.idempotencyKey}): ${err.message}`);
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
throw err;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
confirmedTotal += confirmed;
|
|
477
|
+
// No progress and still pending → stop to avoid tight loop on uncertain.
|
|
478
|
+
if (confirmed === 0)
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
return confirmedTotal;
|
|
482
|
+
})();
|
|
483
|
+
try {
|
|
484
|
+
return await this.flushInFlight;
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
this.flushInFlight = null;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
async charge({ eventName, count = 1 }) {
|
|
491
|
+
return this._withOperationLock(() => this._chargeExclusive({ eventName, count }));
|
|
492
|
+
}
|
|
493
|
+
/** @internal Caller must hold the operation lock. */
|
|
494
|
+
async _chargeExclusive({ eventName, count = 1 }) {
|
|
495
|
+
if (this._closed) {
|
|
496
|
+
throw new Error('ChargingManager is closed (actor is shutting down); charge rejected');
|
|
497
|
+
}
|
|
498
|
+
const calculateChargeableWithinLimit = () => this.getChargeableWithinLimit();
|
|
499
|
+
const normalizedEventName = this.chargingState === undefined ? eventName : this.normalizeChargingEventName(eventName);
|
|
500
|
+
if (!this.isPayPerEvent) {
|
|
501
|
+
if (!this.notPpeWarningPrinted) {
|
|
502
|
+
console.warn('[Scrapely] Ignored attempt to charge for an event - the Actor does not use the pay-per-event pricing');
|
|
503
|
+
this.notPpeWarningPrinted = true;
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
eventChargeLimitReached: false,
|
|
507
|
+
chargedCount: 0,
|
|
508
|
+
chargeableWithinLimit: this.chargingState === undefined ? {} : calculateChargeableWithinLimit(),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
this.ensureInitialized();
|
|
512
|
+
if (!Number.isInteger(count) || count < 1) {
|
|
513
|
+
throw new Error('count must be a positive integer');
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
// Already under operation lock — call exclusive path (no re-enter lock).
|
|
517
|
+
await this._flushPendingExclusive();
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
console.warn(`[Scrapely] flushPending before charge failed: ${err.message}`);
|
|
521
|
+
}
|
|
522
|
+
const maxEventChargeCount = this.calculateMaxEventChargeCountWithinLimit(normalizedEventName);
|
|
523
|
+
let chargedCount = (() => {
|
|
524
|
+
if (count <= maxEventChargeCount) {
|
|
525
|
+
return count;
|
|
526
|
+
}
|
|
527
|
+
if (this.calculateTotalChargedAmount() <= this.maxTotalChargeUsd) {
|
|
528
|
+
return maxEventChargeCount + 1;
|
|
529
|
+
}
|
|
530
|
+
return 0;
|
|
531
|
+
})();
|
|
532
|
+
if (chargedCount === 0) {
|
|
533
|
+
return {
|
|
534
|
+
eventChargeLimitReached: count > 0,
|
|
535
|
+
chargedCount: 0,
|
|
536
|
+
chargeableWithinLimit: calculateChargeableWithinLimit(),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
const eventPricing = this.getEventPricing(normalizedEventName);
|
|
540
|
+
const canPost = this.isAtHome &&
|
|
541
|
+
!!this.actorRunId &&
|
|
542
|
+
!isSyntheticEvent(normalizedEventName) &&
|
|
543
|
+
this.pricingInfo[normalizedEventName] !== undefined;
|
|
544
|
+
let intent;
|
|
545
|
+
if (canPost) {
|
|
546
|
+
if (this.pendingCharges.size >= this.pendingMaxEntries) {
|
|
547
|
+
console.warn(`[Scrapely] Too many unresolved charge intents (${this.pendingCharges.size}); ` +
|
|
548
|
+
`refusing new billable charge for event ${normalizedEventName}`);
|
|
549
|
+
return {
|
|
550
|
+
eventChargeLimitReached: false,
|
|
551
|
+
chargedCount: 0,
|
|
552
|
+
chargeableWithinLimit: calculateChargeableWithinLimit(),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
intent = {
|
|
556
|
+
idempotencyKey: (0, node_crypto_1.randomUUID)(),
|
|
557
|
+
eventName: normalizedEventName,
|
|
558
|
+
count: chargedCount,
|
|
559
|
+
createdAt: Date.now(),
|
|
560
|
+
};
|
|
561
|
+
this.pendingCharges.set(intent.idempotencyKey, intent);
|
|
562
|
+
}
|
|
563
|
+
// Reserve local budget once per logical operation (no rollback on POST failure).
|
|
564
|
+
this.chargingState[normalizedEventName] ??= { chargeCount: 0, totalChargedAmount: 0 };
|
|
565
|
+
this.chargingState[normalizedEventName].chargeCount += chargedCount;
|
|
566
|
+
this.chargingState[normalizedEventName].totalChargedAmount += chargedCount * eventPricing.price;
|
|
567
|
+
if (canPost && intent) {
|
|
568
|
+
let accepted;
|
|
569
|
+
try {
|
|
570
|
+
accepted = await this.postChargeWithRetries(intent);
|
|
571
|
+
}
|
|
572
|
+
catch (err) {
|
|
573
|
+
if (err instanceof ChargeRejectedError) {
|
|
574
|
+
// Server definitively rejected: unwind local reservation.
|
|
575
|
+
this.pendingCharges.delete(intent.idempotencyKey);
|
|
576
|
+
chargedCount = this.applyAcceptedCount(normalizedEventName, chargedCount, 0, eventPricing.price);
|
|
577
|
+
return {
|
|
578
|
+
eventChargeLimitReached: true,
|
|
579
|
+
chargedCount: 0,
|
|
580
|
+
chargeableWithinLimit: calculateChargeableWithinLimit(),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
throw err;
|
|
584
|
+
}
|
|
585
|
+
if (accepted !== null) {
|
|
586
|
+
this.pendingCharges.delete(intent.idempotencyKey);
|
|
587
|
+
chargedCount = this.applyAcceptedCount(normalizedEventName, chargedCount, accepted, eventPricing.price);
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
throw new ChargeUncertainError(`Charge for event '${normalizedEventName}' (count=${chargedCount}) was not confirmed by the platform; operationId=${intent.idempotencyKey}`, {
|
|
591
|
+
operationId: intent.idempotencyKey,
|
|
592
|
+
eventName: normalizedEventName,
|
|
593
|
+
count: chargedCount,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
else if (this.isAtHome &&
|
|
598
|
+
!isSyntheticEvent(normalizedEventName) &&
|
|
599
|
+
this.pricingInfo[normalizedEventName] === undefined) {
|
|
600
|
+
console.warn(`[Scrapely] Attempting to charge for an unknown event '${normalizedEventName}'`);
|
|
601
|
+
}
|
|
602
|
+
if (this.chargingLogDataset !== undefined) {
|
|
603
|
+
await this.chargingLogDataset.pushData({
|
|
604
|
+
eventName: normalizedEventName,
|
|
605
|
+
eventTitle: eventPricing.title,
|
|
606
|
+
eventPriceUsd: eventPricing.price,
|
|
607
|
+
chargedCount,
|
|
608
|
+
timestamp: new Date().toISOString(),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
if (chargedCount < count) {
|
|
612
|
+
const subject = count === 1 ? 'instance' : 'instances';
|
|
613
|
+
console.log(`[Scrapely] Charging ${count} ${subject} of '${normalizedEventName}' event would exceed maxTotalChargeUsd - only ${chargedCount} events were charged`);
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
eventChargeLimitReached: this.calculateMaxEventChargeCountWithinLimit(normalizedEventName) <= 0,
|
|
617
|
+
chargedCount,
|
|
618
|
+
chargeableWithinLimit: calculateChargeableWithinLimit(),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
exports.ChargingManager = ChargingManager;
|
|
623
|
+
/**
|
|
624
|
+
* Helper for PPE-aware pushing of data to the dataset.
|
|
625
|
+
*
|
|
626
|
+
* 1. Calculate limits based on budget
|
|
627
|
+
* 2. Push limited items via the provided callback
|
|
628
|
+
* 3. Charge for the events
|
|
629
|
+
*/
|
|
630
|
+
function extractChargeResponseBody(response) {
|
|
631
|
+
if (!response || typeof response !== 'object')
|
|
632
|
+
return {};
|
|
633
|
+
const r = response;
|
|
634
|
+
// apify-client httpClient.call returns { data, statusCode, ... }
|
|
635
|
+
if (r.data && typeof r.data === 'object' && !Array.isArray(r.data)) {
|
|
636
|
+
return r.data;
|
|
637
|
+
}
|
|
638
|
+
return r;
|
|
639
|
+
}
|
|
640
|
+
function readAcceptedCount(body, fallback) {
|
|
641
|
+
const raw = body.acceptedCount;
|
|
642
|
+
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
|
|
643
|
+
return Math.floor(raw);
|
|
644
|
+
}
|
|
645
|
+
return fallback;
|
|
646
|
+
}
|
|
647
|
+
function classifyChargeHttpError(err, intent) {
|
|
648
|
+
const status = err?.statusCode ??
|
|
649
|
+
err?.status ??
|
|
650
|
+
err?.response?.statusCode ??
|
|
651
|
+
err?.response?.status;
|
|
652
|
+
// Definitive client/billing/auth outcomes — do not retry.
|
|
653
|
+
// 401/403: ownership or scoped-run authorization failures are permanent for this key.
|
|
654
|
+
if (status === 400 ||
|
|
655
|
+
status === 401 ||
|
|
656
|
+
status === 402 ||
|
|
657
|
+
status === 403 ||
|
|
658
|
+
status === 404 ||
|
|
659
|
+
status === 405 ||
|
|
660
|
+
status === 409) {
|
|
661
|
+
return new ChargeRejectedError(`Charge rejected by platform (HTTP ${status}) for event '${intent.eventName}'`, {
|
|
662
|
+
operationId: intent.idempotencyKey,
|
|
663
|
+
eventName: intent.eventName,
|
|
664
|
+
count: intent.count,
|
|
665
|
+
statusCode: status,
|
|
666
|
+
reason: status === 402 ? 'charge-limit-exceeded' : `http-${status}`,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
const message = String(err?.message ?? err ?? '');
|
|
670
|
+
if (/charge-limit-exceeded|unknown-event|ledger-sealed|invalid-input|conflict/i.test(message)) {
|
|
671
|
+
return new ChargeRejectedError(message, {
|
|
672
|
+
operationId: intent.idempotencyKey,
|
|
673
|
+
eventName: intent.eventName,
|
|
674
|
+
count: intent.count,
|
|
675
|
+
statusCode: status,
|
|
676
|
+
reason: 'message-match',
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
async function pushDataAndCharge({ chargingManager, items, eventName, isDefaultDataset, pushFn, }) {
|
|
682
|
+
return chargingManager._withOperationLock(async () => {
|
|
683
|
+
if (chargingManager.isClosed()) {
|
|
684
|
+
throw new Error('ChargingManager is closed (actor is shutting down); charge rejected');
|
|
685
|
+
}
|
|
686
|
+
const { limitedItems, eventsToCharge } = chargingManager.calculatePushDataLimits({
|
|
687
|
+
items,
|
|
688
|
+
eventName,
|
|
689
|
+
isDefaultDataset,
|
|
690
|
+
});
|
|
691
|
+
const itemsArray = Array.isArray(items) ? items : [items];
|
|
692
|
+
const allItemsTrimmed = itemsArray.length > 0 && limitedItems.length === 0;
|
|
693
|
+
if (limitedItems.length === 0) {
|
|
694
|
+
return {
|
|
695
|
+
eventChargeLimitReached: allItemsTrimmed,
|
|
696
|
+
chargedCount: 0,
|
|
697
|
+
chargeableWithinLimit: {},
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
// Charge BEFORE persisting so a server cap/reject cannot leave unbilled
|
|
701
|
+
// items in the dataset. Operation lock is held across both steps.
|
|
702
|
+
let chargeResult;
|
|
703
|
+
if (Object.keys(eventsToCharge).length > 0) {
|
|
704
|
+
const results = [];
|
|
705
|
+
// Synthetic/default-dataset events first (local-only); then explicit.
|
|
706
|
+
const ordered = Object.entries(eventsToCharge).sort(([a], [b]) => {
|
|
707
|
+
const aSyn = isSyntheticEvent(a) ? 0 : 1;
|
|
708
|
+
const bSyn = isSyntheticEvent(b) ? 0 : 1;
|
|
709
|
+
return aSyn - bSyn;
|
|
710
|
+
});
|
|
711
|
+
const reservedInOp = [];
|
|
712
|
+
try {
|
|
713
|
+
for (const [name, chargeCount] of ordered) {
|
|
714
|
+
const r = await chargingManager._chargeExclusive({
|
|
715
|
+
eventName: name,
|
|
716
|
+
count: chargeCount,
|
|
717
|
+
});
|
|
718
|
+
results.push(r);
|
|
719
|
+
reservedInOp.push({ name, reserved: r.chargedCount });
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
catch (err) {
|
|
723
|
+
// Uncertain/failure after partial multi-event reserve: unwind all
|
|
724
|
+
// prior reservations for this operation (synthetic + explicit).
|
|
725
|
+
for (const { name, reserved } of reservedInOp) {
|
|
726
|
+
if (reserved > 0) {
|
|
727
|
+
chargingManager.applyAcceptedCount(name, reserved, 0);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
throw err;
|
|
731
|
+
}
|
|
732
|
+
// Persist only what every charged event accepted.
|
|
733
|
+
// When multiple events are charged for the same items, use the
|
|
734
|
+
// minimum accepted count so storage never exceeds any billable event.
|
|
735
|
+
const acceptedForItems = Math.min(limitedItems.length, ...results.map((r) => r.chargedCount));
|
|
736
|
+
// Unwind any event that reserved more than we will persist.
|
|
737
|
+
// Critical for synthetic default-dataset (local-only): if the
|
|
738
|
+
// explicit server charge caps below the synthetic reservation,
|
|
739
|
+
// leaving the synthetic count high permanently overstates local spend.
|
|
740
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
741
|
+
const [name] = ordered[i];
|
|
742
|
+
const reserved = results[i].chargedCount;
|
|
743
|
+
if (reserved > acceptedForItems) {
|
|
744
|
+
chargingManager.applyAcceptedCount(name, reserved, acceptedForItems);
|
|
745
|
+
results[i] = {
|
|
746
|
+
...results[i],
|
|
747
|
+
chargedCount: acceptedForItems,
|
|
748
|
+
eventChargeLimitReached: true,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
chargeResult = results.reduce(mergeChargeResults);
|
|
753
|
+
if (acceptedForItems <= 0) {
|
|
754
|
+
return {
|
|
755
|
+
eventChargeLimitReached: true,
|
|
756
|
+
chargedCount: chargeResult.chargedCount,
|
|
757
|
+
chargeableWithinLimit: chargeResult.chargeableWithinLimit,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
const toPersist = limitedItems.slice(0, acceptedForItems);
|
|
761
|
+
try {
|
|
762
|
+
await pushFn(Array.isArray(items) ? toPersist : toPersist[0]);
|
|
763
|
+
}
|
|
764
|
+
catch (pushErr) {
|
|
765
|
+
// Charge already accepted server-side; storage failed. Do not unwind
|
|
766
|
+
// server ledger (no reverse API). Surface error so caller can retry
|
|
767
|
+
// storage; local counters already match accepted charges.
|
|
768
|
+
console.error(`[Scrapely] Dataset push failed after charge accepted ` +
|
|
769
|
+
`(items=${acceptedForItems}): ${pushErr.message}`);
|
|
770
|
+
throw pushErr;
|
|
771
|
+
}
|
|
772
|
+
return chargeResult;
|
|
773
|
+
}
|
|
774
|
+
// No billable events — still persist the budget-limited items.
|
|
775
|
+
await pushFn(Array.isArray(items) ? limitedItems : limitedItems[0]);
|
|
776
|
+
return {
|
|
777
|
+
eventChargeLimitReached: false,
|
|
778
|
+
chargedCount: 0,
|
|
779
|
+
chargeableWithinLimit: {},
|
|
780
|
+
};
|
|
781
|
+
});
|
|
782
|
+
}
|