scrapely 1.2.3 → 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -1
- package/dist/actor.d.ts +703 -0
- package/dist/actor.js +1441 -0
- package/dist/browser-configuration.d.ts +58 -0
- package/dist/browser-configuration.js +128 -0
- package/dist/browser-locations.d.ts +5 -0
- package/dist/browser-locations.js +5469 -0
- package/dist/charging.d.ts +153 -0
- package/dist/charging.js +782 -0
- package/dist/charging_client.d.ts +18 -0
- package/dist/charging_client.js +49 -0
- package/dist/configuration.d.ts +121 -0
- package/dist/configuration.js +128 -0
- package/dist/crypto/secrets.d.ts +44 -0
- package/dist/crypto/secrets.js +164 -0
- package/dist/events/events-client.d.ts +59 -0
- package/dist/events/events-client.js +120 -0
- package/dist/events/scrapely-event-manager.d.ts +13 -0
- package/dist/events/scrapely-event-manager.js +61 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +95 -0
- package/dist/log-env.d.ts +1 -0
- package/dist/log-env.js +12 -0
- package/dist/manifest.d.ts +30 -0
- package/dist/manifest.js +47 -0
- package/dist/patch-browser-pool.d.ts +66 -0
- package/dist/patch-browser-pool.js +827 -0
- package/dist/proxy-configuration.d.ts +118 -0
- package/dist/proxy-configuration.js +88 -0
- package/dist/request-list.d.ts +57 -0
- package/dist/request-list.js +79 -0
- package/dist/runner/docker-manager.d.ts +88 -0
- package/dist/runner/docker-manager.js +283 -0
- package/dist/runner/index.d.ts +2 -0
- package/dist/runner/index.js +7 -0
- package/dist/runner/timeout-manager.d.ts +43 -0
- package/dist/runner/timeout-manager.js +90 -0
- package/dist/scrapely-proxy-configuration.d.ts +99 -0
- package/dist/scrapely-proxy-configuration.js +559 -0
- package/dist/storage/api-storage-client.d.ts +199 -0
- package/dist/storage/api-storage-client.js +910 -0
- package/dist/storage/billing-tracker.d.ts +163 -0
- package/dist/storage/billing-tracker.js +260 -0
- package/dist/storage/dataset-client.d.ts +43 -0
- package/dist/storage/dataset-client.js +232 -0
- package/dist/storage/dataset-collection-client.d.ts +8 -0
- package/dist/storage/dataset-collection-client.js +75 -0
- package/dist/storage/dataset-push-script.d.ts +18 -0
- package/dist/storage/dataset-push-script.js +38 -0
- package/dist/storage/dragonfly-client.d.ts +94 -0
- package/dist/storage/dragonfly-client.js +167 -0
- package/dist/storage/index.d.ts +11 -0
- package/dist/storage/index.js +25 -0
- package/dist/storage/key-value-store-client.d.ts +61 -0
- package/dist/storage/key-value-store-client.js +306 -0
- package/dist/storage/kv-store-collection-client.d.ts +8 -0
- package/dist/storage/kv-store-collection-client.js +76 -0
- package/dist/storage/kvs-iteration.d.ts +17 -0
- package/dist/storage/kvs-iteration.js +126 -0
- package/dist/storage/log-manager.d.ts +114 -0
- package/dist/storage/log-manager.js +244 -0
- package/dist/storage/patch-storage.d.ts +11 -0
- package/dist/storage/patch-storage.js +177 -0
- package/dist/storage/request-queue-client.d.ts +93 -0
- package/dist/storage/request-queue-client.js +522 -0
- package/dist/storage/rq-collection-client.d.ts +8 -0
- package/dist/storage/rq-collection-client.js +89 -0
- package/dist/storage/rq-list-requests.d.ts +59 -0
- package/dist/storage/rq-list-requests.js +114 -0
- package/dist/storage/rq-lock-script.d.ts +56 -0
- package/dist/storage/rq-lock-script.js +127 -0
- package/dist/storage/run-metadata.d.ts +98 -0
- package/dist/storage/run-metadata.js +256 -0
- package/dist/storage/s3-client.d.ts +103 -0
- package/dist/storage/s3-client.js +302 -0
- package/dist/storage/ttl-manager.d.ts +70 -0
- package/dist/storage/ttl-manager.js +115 -0
- package/dist/storage/utils.d.ts +3 -0
- package/dist/storage/utils.js +19 -0
- package/dist/storage-identifiers.d.ts +20 -0
- package/dist/storage-identifiers.js +6 -0
- package/dist/utils.d.ts +54 -0
- package/dist/utils.js +63 -0
- package/dist/validator.d.ts +24 -0
- package/dist/validator.js +158 -0
- package/package.json +90 -9
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.upperBoundSorted = upperBoundSorted;
|
|
4
|
+
exports.listRequestsPage = listRequestsPage;
|
|
5
|
+
/**
|
|
6
|
+
* Index of the first element strictly greater than `target` in a
|
|
7
|
+
* lexicographically sorted array (binary search).
|
|
8
|
+
*
|
|
9
|
+
* Unlike indexOf(), it returns the correct position even when `target` itself
|
|
10
|
+
* is absent, so a cursor item deleted between pages advances pagination past
|
|
11
|
+
* its old position instead of restarting from page 1 (which used to loop
|
|
12
|
+
* pagination clients back to the first page forever). This also matches the
|
|
13
|
+
* documented exclusiveStartId semantics: "all requests up to this one
|
|
14
|
+
* (including) are skipped from the result".
|
|
15
|
+
*/
|
|
16
|
+
function upperBoundSorted(sortedIds, target) {
|
|
17
|
+
let lo = 0;
|
|
18
|
+
let hi = sortedIds.length;
|
|
19
|
+
while (lo < hi) {
|
|
20
|
+
const mid = (lo + hi) >>> 1;
|
|
21
|
+
if (sortedIds[mid] <= target) {
|
|
22
|
+
lo = mid + 1;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
hi = mid;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return lo;
|
|
29
|
+
}
|
|
30
|
+
/** Map a stored request JSON blob to the API response shape. */
|
|
31
|
+
function parseStoredRequest(raw) {
|
|
32
|
+
const req = JSON.parse(raw);
|
|
33
|
+
return {
|
|
34
|
+
id: req.id,
|
|
35
|
+
retryCount: req.retryCount ?? 0,
|
|
36
|
+
uniqueKey: req.uniqueKey,
|
|
37
|
+
url: req.url,
|
|
38
|
+
method: req.method ?? 'GET',
|
|
39
|
+
loadedUrl: req.loadedUrl ?? null,
|
|
40
|
+
payload: req.payload ?? null,
|
|
41
|
+
noRetry: req.noRetry ?? false,
|
|
42
|
+
errorMessages: req.errorMessages ?? null,
|
|
43
|
+
headers: req.headers ?? null,
|
|
44
|
+
userData: req.userData ?? null,
|
|
45
|
+
handledAt: req.handledAt ?? null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* One page of request-queue listing: filtering + cursor pagination in two
|
|
50
|
+
* round-trips, shared by the storage client and the API server route.
|
|
51
|
+
*
|
|
52
|
+
* Round-trip 1 — a single pipeline:
|
|
53
|
+
* - HKEYS: all request IDs (field names only, not the bodies);
|
|
54
|
+
* - ZRANGEBYSCORE locked (now +inf — only when filtering by 'locked'. The
|
|
55
|
+
* locked ZSET is scored by lockUntil ms, so one exclusive-bound range read
|
|
56
|
+
* exactly replaces the old per-id ZSCORE loop (score > now);
|
|
57
|
+
* - ZRANGE pending 0 -1 — only when filtering by 'pending'.
|
|
58
|
+
* Round-trip 2 — one HMGET for the page's request bodies.
|
|
59
|
+
*/
|
|
60
|
+
async function listRequestsPage(redis, keys, params) {
|
|
61
|
+
const hasLockedFilter = params.filter?.includes('locked') ?? false;
|
|
62
|
+
const hasPendingFilter = params.filter?.includes('pending') ?? false;
|
|
63
|
+
const readPipeline = redis.pipeline();
|
|
64
|
+
readPipeline.hkeys(keys.requestsKey);
|
|
65
|
+
if (hasLockedFilter)
|
|
66
|
+
readPipeline.zrangebyscore(keys.lockedKey, `(${params.now}`, '+inf');
|
|
67
|
+
// ioredis 6 types: stop must be string | Buffer (not number)
|
|
68
|
+
if (hasPendingFilter)
|
|
69
|
+
readPipeline.zrange(keys.pendingKey, 0, '-1');
|
|
70
|
+
const results = await readPipeline.exec();
|
|
71
|
+
const at = (i) => {
|
|
72
|
+
const value = results?.[i]?.[1];
|
|
73
|
+
return Array.isArray(value) ? value : [];
|
|
74
|
+
};
|
|
75
|
+
const allRequestIds = at(0);
|
|
76
|
+
// Sort IDs for consistent lexicographic ordering (API contract)
|
|
77
|
+
allRequestIds.sort();
|
|
78
|
+
const activeLockedIds = hasLockedFilter ? new Set(at(1)) : new Set();
|
|
79
|
+
const pendingIds = hasPendingFilter
|
|
80
|
+
? new Set(at(hasLockedFilter ? 2 : 1))
|
|
81
|
+
: new Set();
|
|
82
|
+
// Filter requests based on filter parameter
|
|
83
|
+
let filteredIds = allRequestIds;
|
|
84
|
+
if (hasLockedFilter && hasPendingFilter) {
|
|
85
|
+
// Both filters: return locked OR pending (not handled-only requests)
|
|
86
|
+
filteredIds = allRequestIds.filter(id => activeLockedIds.has(id) || pendingIds.has(id));
|
|
87
|
+
}
|
|
88
|
+
else if (hasLockedFilter) {
|
|
89
|
+
// Only locked: requests with non-expired locks
|
|
90
|
+
filteredIds = allRequestIds.filter(id => activeLockedIds.has(id));
|
|
91
|
+
}
|
|
92
|
+
else if (hasPendingFilter) {
|
|
93
|
+
// Only pending: requests in pending set
|
|
94
|
+
filteredIds = allRequestIds.filter(id => pendingIds.has(id));
|
|
95
|
+
}
|
|
96
|
+
// If no filter, return all requests
|
|
97
|
+
// Advance past the cursor (first ID strictly greater than startId —
|
|
98
|
+
// see upperBoundSorted for why this must not be indexOf()).
|
|
99
|
+
const startIndex = params.startId ? upperBoundSorted(filteredIds, params.startId) : 0;
|
|
100
|
+
// Apply pagination
|
|
101
|
+
const paginatedIds = filteredIds.slice(startIndex, startIndex + params.limit);
|
|
102
|
+
const hasMore = startIndex + params.limit < filteredIds.length;
|
|
103
|
+
// Fetch full request data for the page in one round-trip
|
|
104
|
+
const items = [];
|
|
105
|
+
if (paginatedIds.length > 0) {
|
|
106
|
+
const raws = await redis.hmget(keys.requestsKey, ...paginatedIds);
|
|
107
|
+
for (const raw of raws) {
|
|
108
|
+
if (!raw)
|
|
109
|
+
continue;
|
|
110
|
+
items.push(parseStoredRequest(raw));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { items, hasMore };
|
|
114
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type Redis from 'ioredis';
|
|
2
|
+
/**
|
|
3
|
+
* Atomic list-and-lock. Returns a two-element Lua table:
|
|
4
|
+
* { lockedIds, ZCARD(lockedKey) }
|
|
5
|
+
* which ioredis delivers as [string[], number]. The ZCARD is taken after the
|
|
6
|
+
* script's expired-lock purge and after this call's own locks are added, so it
|
|
7
|
+
* equals the number of active locks held by ANY client — exactly what the
|
|
8
|
+
* Apify `queueHasLockedRequests` spec describes ("locked by any client, either
|
|
9
|
+
* the one calling the endpoint or a different one"). Callers get the flag from
|
|
10
|
+
* this same atomic reply instead of re-reading the whole locked + pending sets.
|
|
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";
|
|
13
|
+
export interface ListAndLockHeadKeys {
|
|
14
|
+
pendingKey: string;
|
|
15
|
+
lockedKey: string;
|
|
16
|
+
lockOwnersKey: string;
|
|
17
|
+
requestsKey: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ListAndLockHeadParams {
|
|
20
|
+
now: number;
|
|
21
|
+
lockUntil: number;
|
|
22
|
+
limit: number;
|
|
23
|
+
ownerValue: string;
|
|
24
|
+
/** How many pending IDs to scan (should be >= limit). */
|
|
25
|
+
scanLimit?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface ListAndLockHeadAtomicResult {
|
|
28
|
+
/** IDs locked by this call, in queue-head order. */
|
|
29
|
+
lockedIds: string[];
|
|
30
|
+
/**
|
|
31
|
+
* Active locks (lockUntil > now) immediately after this atomic operation,
|
|
32
|
+
* including the locks this call just created (any client's locks count).
|
|
33
|
+
*/
|
|
34
|
+
activeLockCount: number;
|
|
35
|
+
}
|
|
36
|
+
export declare function listAndLockHeadAtomic(redis: Redis, keys: ListAndLockHeadKeys, params: ListAndLockHeadParams): Promise<ListAndLockHeadAtomicResult>;
|
|
37
|
+
/** Returns true if lockOwners entry matches the caller's clientKey (and optional runId). */
|
|
38
|
+
export declare function matchesLockOwner(owner: string, clientKey: string, runId?: string | null): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Atomic unlock-by-owner: scans lockOwners, unlocks every request whose owner
|
|
41
|
+
* matches the clientKey (and optional runId) using the same rules as the old
|
|
42
|
+
* per-request loop, optionally re-queueing unlocked requests at the forefront.
|
|
43
|
+
* Returns the number of unlocked requests. One round-trip, no race window
|
|
44
|
+
* between ZREM and HDEL, and the whole owners hash never leaves the server.
|
|
45
|
+
*/
|
|
46
|
+
export declare const UNLOCK_REQUESTS_LUA = "\nlocal lockedKey = KEYS[1]\nlocal lockOwnersKey = KEYS[2]\nlocal pendingKey = KEYS[3]\nlocal clientKey = ARGV[1]\nlocal runId = ARGV[2]\nlocal forefront = ARGV[3]\n\nlocal expectedOwner = clientKey\nif runId ~= '' then\n expectedOwner = clientKey .. ':' .. runId\nend\n\nlocal clientKeyPrefix = clientKey .. ':'\n\nlocal flat = redis.call('HGETALL', lockOwnersKey)\nlocal unlockedCount = 0\n\nfor i = 1, #flat, 2 do\n local requestId = flat[i]\n local owner = flat[i + 1]\n -- Per Apify spec: in an Actor run, unlock both (1) requests locked by this\n -- specific run (clientKey:runId) and (2) requests locked by the same\n -- clientKey without run context (clientKey), but NOT requests locked by the\n -- same clientKey in DIFFERENT runs (clientKey:otherRunId).\n if owner == expectedOwner or owner == clientKey\n or (runId == '' and string.sub(owner, 1, #clientKeyPrefix) == clientKeyPrefix) then\n redis.call('ZREM', lockedKey, requestId)\n redis.call('HDEL', lockOwnersKey, requestId)\n if forefront == '1' then\n redis.call('ZADD', pendingKey, 0, requestId)\n end\n unlockedCount = unlockedCount + 1\n end\nend\n\nreturn unlockedCount\n";
|
|
47
|
+
export interface UnlockRequestsKeys {
|
|
48
|
+
lockedKey: string;
|
|
49
|
+
lockOwnersKey: string;
|
|
50
|
+
pendingKey: string;
|
|
51
|
+
}
|
|
52
|
+
export declare function unlockRequestsAtomic(redis: Redis, keys: UnlockRequestsKeys, params: {
|
|
53
|
+
clientKey: string;
|
|
54
|
+
forefront?: boolean;
|
|
55
|
+
runId?: string;
|
|
56
|
+
}): Promise<number>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UNLOCK_REQUESTS_LUA = exports.LIST_AND_LOCK_HEAD_LUA = void 0;
|
|
4
|
+
exports.listAndLockHeadAtomic = listAndLockHeadAtomic;
|
|
5
|
+
exports.matchesLockOwner = matchesLockOwner;
|
|
6
|
+
exports.unlockRequestsAtomic = unlockRequestsAtomic;
|
|
7
|
+
/**
|
|
8
|
+
* Atomic list-and-lock. Returns a two-element Lua table:
|
|
9
|
+
* { lockedIds, ZCARD(lockedKey) }
|
|
10
|
+
* which ioredis delivers as [string[], number]. The ZCARD is taken after the
|
|
11
|
+
* script's expired-lock purge and after this call's own locks are added, so it
|
|
12
|
+
* equals the number of active locks held by ANY client — exactly what the
|
|
13
|
+
* Apify `queueHasLockedRequests` spec describes ("locked by any client, either
|
|
14
|
+
* the one calling the endpoint or a different one"). Callers get the flag from
|
|
15
|
+
* this same atomic reply instead of re-reading the whole locked + pending sets.
|
|
16
|
+
*/
|
|
17
|
+
exports.LIST_AND_LOCK_HEAD_LUA = `
|
|
18
|
+
local pendingKey = KEYS[1]
|
|
19
|
+
local lockedKey = KEYS[2]
|
|
20
|
+
local lockOwnersKey = KEYS[3]
|
|
21
|
+
local requestsKey = KEYS[4]
|
|
22
|
+
local now = tonumber(ARGV[1])
|
|
23
|
+
local lockUntil = tonumber(ARGV[2])
|
|
24
|
+
local limit = tonumber(ARGV[3])
|
|
25
|
+
local ownerValue = ARGV[4]
|
|
26
|
+
local scanLimit = tonumber(ARGV[5])
|
|
27
|
+
|
|
28
|
+
redis.call('ZREMRANGEBYSCORE', lockedKey, '-inf', now)
|
|
29
|
+
|
|
30
|
+
local pendingIds = redis.call('ZRANGE', pendingKey, 0, scanLimit - 1)
|
|
31
|
+
local lockedIds = {}
|
|
32
|
+
|
|
33
|
+
for _, id in ipairs(pendingIds) do
|
|
34
|
+
if #lockedIds >= limit then break end
|
|
35
|
+
local lockScore = redis.call('ZSCORE', lockedKey, id)
|
|
36
|
+
if not lockScore or tonumber(lockScore) <= now then
|
|
37
|
+
if redis.call('HEXISTS', requestsKey, id) == 1 then
|
|
38
|
+
redis.call('ZADD', lockedKey, lockUntil, id)
|
|
39
|
+
redis.call('HSET', lockOwnersKey, id, ownerValue)
|
|
40
|
+
table.insert(lockedIds, id)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
-- Everything left in lockedKey is an active lock (score > now): the purge
|
|
46
|
+
-- above removed the expired ones and we just added ours. An O(1) ZCARD here
|
|
47
|
+
-- replaces the callers' old tail of ZRANGEBYSCORE(locked) + full
|
|
48
|
+
-- ZRANGE(pending, 0, -1) scans on every head fetch.
|
|
49
|
+
return { lockedIds, redis.call('ZCARD', lockedKey) }
|
|
50
|
+
`;
|
|
51
|
+
async function listAndLockHeadAtomic(redis, keys, params) {
|
|
52
|
+
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));
|
|
54
|
+
// Expected reply shape: [string[], number]. Tolerate degraded replies the
|
|
55
|
+
// same way the old string[] version did (null / JSON-stringified table).
|
|
56
|
+
if (!Array.isArray(result) || result.length < 2) {
|
|
57
|
+
return { lockedIds: [], activeLockCount: 0 };
|
|
58
|
+
}
|
|
59
|
+
let lockedIds = result[0];
|
|
60
|
+
if (typeof lockedIds === 'string') {
|
|
61
|
+
try {
|
|
62
|
+
lockedIds = JSON.parse(lockedIds);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
lockedIds = [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(lockedIds))
|
|
69
|
+
lockedIds = [];
|
|
70
|
+
const count = result[1];
|
|
71
|
+
const activeLockCount = typeof count === 'number' ? count : parseInt(String(count), 10) || 0;
|
|
72
|
+
return { lockedIds, activeLockCount };
|
|
73
|
+
}
|
|
74
|
+
/** Returns true if lockOwners entry matches the caller's clientKey (and optional runId). */
|
|
75
|
+
function matchesLockOwner(owner, clientKey, runId) {
|
|
76
|
+
const expectedOwner = runId ? `${clientKey}:${runId}` : clientKey;
|
|
77
|
+
return owner === expectedOwner || owner === clientKey;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Atomic unlock-by-owner: scans lockOwners, unlocks every request whose owner
|
|
81
|
+
* matches the clientKey (and optional runId) using the same rules as the old
|
|
82
|
+
* per-request loop, optionally re-queueing unlocked requests at the forefront.
|
|
83
|
+
* Returns the number of unlocked requests. One round-trip, no race window
|
|
84
|
+
* between ZREM and HDEL, and the whole owners hash never leaves the server.
|
|
85
|
+
*/
|
|
86
|
+
exports.UNLOCK_REQUESTS_LUA = `
|
|
87
|
+
local lockedKey = KEYS[1]
|
|
88
|
+
local lockOwnersKey = KEYS[2]
|
|
89
|
+
local pendingKey = KEYS[3]
|
|
90
|
+
local clientKey = ARGV[1]
|
|
91
|
+
local runId = ARGV[2]
|
|
92
|
+
local forefront = ARGV[3]
|
|
93
|
+
|
|
94
|
+
local expectedOwner = clientKey
|
|
95
|
+
if runId ~= '' then
|
|
96
|
+
expectedOwner = clientKey .. ':' .. runId
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
local clientKeyPrefix = clientKey .. ':'
|
|
100
|
+
|
|
101
|
+
local flat = redis.call('HGETALL', lockOwnersKey)
|
|
102
|
+
local unlockedCount = 0
|
|
103
|
+
|
|
104
|
+
for i = 1, #flat, 2 do
|
|
105
|
+
local requestId = flat[i]
|
|
106
|
+
local owner = flat[i + 1]
|
|
107
|
+
-- Per Apify spec: in an Actor run, unlock both (1) requests locked by this
|
|
108
|
+
-- specific run (clientKey:runId) and (2) requests locked by the same
|
|
109
|
+
-- clientKey without run context (clientKey), but NOT requests locked by the
|
|
110
|
+
-- same clientKey in DIFFERENT runs (clientKey:otherRunId).
|
|
111
|
+
if owner == expectedOwner or owner == clientKey
|
|
112
|
+
or (runId == '' and string.sub(owner, 1, #clientKeyPrefix) == clientKeyPrefix) then
|
|
113
|
+
redis.call('ZREM', lockedKey, requestId)
|
|
114
|
+
redis.call('HDEL', lockOwnersKey, requestId)
|
|
115
|
+
if forefront == '1' then
|
|
116
|
+
redis.call('ZADD', pendingKey, 0, requestId)
|
|
117
|
+
end
|
|
118
|
+
unlockedCount = unlockedCount + 1
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
return unlockedCount
|
|
123
|
+
`;
|
|
124
|
+
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');
|
|
126
|
+
return typeof result === 'number' ? result : parseInt(String(result), 10) || 0;
|
|
127
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { DragonflyClient } from './dragonfly-client.js';
|
|
2
|
+
import type { UserPlan } from './ttl-manager.js';
|
|
3
|
+
/**
|
|
4
|
+
* Metadata for a single actor run
|
|
5
|
+
*/
|
|
6
|
+
export interface RunMetadata {
|
|
7
|
+
/** Unique run ID */
|
|
8
|
+
runId: string;
|
|
9
|
+
/** User ID who initiated the run */
|
|
10
|
+
userId: string;
|
|
11
|
+
/** User's plan (affects TTL) */
|
|
12
|
+
plan: UserPlan;
|
|
13
|
+
/** When the run was created */
|
|
14
|
+
createdAt: Date;
|
|
15
|
+
/** When the run finished (null if still running) */
|
|
16
|
+
finishedAt: Date | null;
|
|
17
|
+
/** Storage IDs created for this run */
|
|
18
|
+
storageIds: {
|
|
19
|
+
dataset: string;
|
|
20
|
+
kvStore: string;
|
|
21
|
+
requestQueue: string;
|
|
22
|
+
};
|
|
23
|
+
/** Current run status */
|
|
24
|
+
status: RunStatus;
|
|
25
|
+
/** Total storage bytes used (for billing) */
|
|
26
|
+
storageBytes: number;
|
|
27
|
+
/** Container ID if running in Docker */
|
|
28
|
+
containerId?: string;
|
|
29
|
+
/** Actor name/version */
|
|
30
|
+
actorName?: string;
|
|
31
|
+
actorVersion?: string;
|
|
32
|
+
}
|
|
33
|
+
export type RunStatus = 'running' | 'success' | 'failed' | 'timed_out' | 'killed';
|
|
34
|
+
/**
|
|
35
|
+
* Manages run metadata in Dragonfly.
|
|
36
|
+
* Keys are stored with TTL matching the storage TTL so metadata is cleaned up
|
|
37
|
+
* when the associated storage expires.
|
|
38
|
+
*/
|
|
39
|
+
export declare class RunMetadataManager {
|
|
40
|
+
private client;
|
|
41
|
+
constructor(client: DragonflyClient);
|
|
42
|
+
/**
|
|
43
|
+
* Get the Redis sorted-set key that indexes all run IDs for a user.
|
|
44
|
+
* Sorted by createdAt timestamp (score) so ZREVRANGE gives most-recent-first.
|
|
45
|
+
*/
|
|
46
|
+
private getUserRunsKey;
|
|
47
|
+
/**
|
|
48
|
+
* Create a new run metadata entry
|
|
49
|
+
*/
|
|
50
|
+
create(data: Omit<RunMetadata, 'finishedAt' | 'storageBytes'> & {
|
|
51
|
+
storageBytes?: number;
|
|
52
|
+
}): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Get run metadata by ID
|
|
55
|
+
*/
|
|
56
|
+
get(runId: string): Promise<RunMetadata | null>;
|
|
57
|
+
/**
|
|
58
|
+
* Update run metadata.
|
|
59
|
+
* Silently no-ops if the run does not exist (prevents ghost hash creation).
|
|
60
|
+
*/
|
|
61
|
+
update(runId: string, updates: Partial<Pick<RunMetadata, 'status' | 'storageBytes' | 'finishedAt' | 'containerId'>>): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Mark a run as finished with a specific status
|
|
64
|
+
*/
|
|
65
|
+
finish(runId: string, status: RunStatus, storageBytes?: number): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Delete run metadata and remove from the user's sorted-set index.
|
|
68
|
+
*/
|
|
69
|
+
delete(runId: string): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* List runs by user ID, most-recent first.
|
|
72
|
+
*
|
|
73
|
+
* Uses a per-user sorted set (ZADD on create, ZREM on delete) instead of a
|
|
74
|
+
* full KEYS scan so lookups are O(log N + M) rather than O(N-total-runs).
|
|
75
|
+
* Stale entries left behind by TTL expiry are detected and pruned lazily.
|
|
76
|
+
*/
|
|
77
|
+
listByUser(userId: string): Promise<RunMetadata[]>;
|
|
78
|
+
/**
|
|
79
|
+
* Enforce the top-N indefinite retention policy for a user.
|
|
80
|
+
*
|
|
81
|
+
* - The KEEP_N_RUNS most-recent runs get PERSIST (no TTL → kept forever).
|
|
82
|
+
* - Runs beyond that window that currently have no TTL (were previously in the
|
|
83
|
+
* top N) get the plan-based TTL re-applied so they will eventually expire.
|
|
84
|
+
* - The per-user sorted set is PERSISTed while protected runs exist, and gets
|
|
85
|
+
* a plan-based TTL when no protected runs remain (prevents a memory leak for
|
|
86
|
+
* inactive users whose sorted set would otherwise never expire).
|
|
87
|
+
*
|
|
88
|
+
* Called after every create() and delete() so the window stays accurate.
|
|
89
|
+
*
|
|
90
|
+
* Performance: previously used N sequential awaits per phase; now each phase is
|
|
91
|
+
* a single pipelined round-trip regardless of the number of runs.
|
|
92
|
+
*/
|
|
93
|
+
private refreshTopNProtection;
|
|
94
|
+
/**
|
|
95
|
+
* Get the Redis key for a run
|
|
96
|
+
*/
|
|
97
|
+
private getRunKey;
|
|
98
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RunMetadataManager = void 0;
|
|
4
|
+
const billing_tracker_js_1 = require("./billing-tracker.js");
|
|
5
|
+
/**
|
|
6
|
+
* Number of most-recent runs per user that are retained indefinitely (no TTL).
|
|
7
|
+
* Mirrors Apify's model: the 10 latest runs are always accessible regardless of plan.
|
|
8
|
+
* Older runs beyond this window fall back to the standard plan-based TTL.
|
|
9
|
+
*/
|
|
10
|
+
const KEEP_N_RUNS = 10;
|
|
11
|
+
/**
|
|
12
|
+
* Manages run metadata in Dragonfly.
|
|
13
|
+
* Keys are stored with TTL matching the storage TTL so metadata is cleaned up
|
|
14
|
+
* when the associated storage expires.
|
|
15
|
+
*/
|
|
16
|
+
class RunMetadataManager {
|
|
17
|
+
client;
|
|
18
|
+
constructor(client) {
|
|
19
|
+
this.client = client;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Get the Redis sorted-set key that indexes all run IDs for a user.
|
|
23
|
+
* Sorted by createdAt timestamp (score) so ZREVRANGE gives most-recent-first.
|
|
24
|
+
*/
|
|
25
|
+
getUserRunsKey(userId) {
|
|
26
|
+
return this.client.key('users', userId, 'runs');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Create a new run metadata entry
|
|
30
|
+
*/
|
|
31
|
+
async create(data) {
|
|
32
|
+
const key = this.getRunKey(data.runId);
|
|
33
|
+
const metadata = {
|
|
34
|
+
...data,
|
|
35
|
+
finishedAt: null,
|
|
36
|
+
storageBytes: data.storageBytes ?? 0,
|
|
37
|
+
};
|
|
38
|
+
await this.client.redis.hset(key, {
|
|
39
|
+
runId: metadata.runId,
|
|
40
|
+
userId: metadata.userId,
|
|
41
|
+
plan: metadata.plan,
|
|
42
|
+
createdAt: metadata.createdAt.toISOString(),
|
|
43
|
+
finishedAt: '',
|
|
44
|
+
storageIds: JSON.stringify(metadata.storageIds),
|
|
45
|
+
status: metadata.status,
|
|
46
|
+
storageBytes: String(metadata.storageBytes),
|
|
47
|
+
containerId: metadata.containerId ?? '',
|
|
48
|
+
actorName: metadata.actorName ?? '',
|
|
49
|
+
actorVersion: metadata.actorVersion ?? '',
|
|
50
|
+
});
|
|
51
|
+
// Register in the per-user sorted set (score = ms timestamp → most-recent-first via ZREVRANGE)
|
|
52
|
+
const userRunsKey = this.getUserRunsKey(metadata.userId);
|
|
53
|
+
await this.client.redis.zadd(userRunsKey, metadata.createdAt.getTime(), metadata.runId);
|
|
54
|
+
// Set TTL on run metadata - use dataset TTL as reference (longest retention)
|
|
55
|
+
const ttl = this.client.ttlManager.getTtl('dataset', metadata.plan, false);
|
|
56
|
+
if (ttl) {
|
|
57
|
+
await this.client.redis.expire(key, ttl);
|
|
58
|
+
}
|
|
59
|
+
// Enforce top-N indefinite retention — will PERSIST this run if it's in the
|
|
60
|
+
// most-recent KEEP_N_RUNS and may re-apply TTL to any run that was just demoted.
|
|
61
|
+
await this.refreshTopNProtection(metadata.userId);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Get run metadata by ID
|
|
65
|
+
*/
|
|
66
|
+
async get(runId) {
|
|
67
|
+
const key = this.getRunKey(runId);
|
|
68
|
+
const data = await this.client.redis.hgetall(key);
|
|
69
|
+
if (!data || !data.runId)
|
|
70
|
+
return null;
|
|
71
|
+
return {
|
|
72
|
+
runId: data.runId,
|
|
73
|
+
userId: data.userId,
|
|
74
|
+
plan: data.plan,
|
|
75
|
+
createdAt: new Date(data.createdAt),
|
|
76
|
+
finishedAt: data.finishedAt ? new Date(data.finishedAt) : null,
|
|
77
|
+
storageIds: JSON.parse(data.storageIds),
|
|
78
|
+
status: data.status,
|
|
79
|
+
storageBytes: parseInt(data.storageBytes || '0', 10),
|
|
80
|
+
containerId: data.containerId || undefined,
|
|
81
|
+
actorName: data.actorName || undefined,
|
|
82
|
+
actorVersion: data.actorVersion || undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Update run metadata.
|
|
87
|
+
* Silently no-ops if the run does not exist (prevents ghost hash creation).
|
|
88
|
+
*/
|
|
89
|
+
async update(runId, updates) {
|
|
90
|
+
const key = this.getRunKey(runId);
|
|
91
|
+
// Guard: only update if the hash already exists
|
|
92
|
+
const exists = await this.client.redis.exists(key);
|
|
93
|
+
if (!exists)
|
|
94
|
+
return;
|
|
95
|
+
const updateData = {};
|
|
96
|
+
if (updates.status !== undefined) {
|
|
97
|
+
updateData.status = updates.status;
|
|
98
|
+
}
|
|
99
|
+
if (updates.storageBytes !== undefined) {
|
|
100
|
+
updateData.storageBytes = String(updates.storageBytes);
|
|
101
|
+
}
|
|
102
|
+
if (updates.finishedAt !== undefined && updates.finishedAt !== null) {
|
|
103
|
+
updateData.finishedAt = updates.finishedAt.toISOString();
|
|
104
|
+
}
|
|
105
|
+
if (updates.containerId !== undefined) {
|
|
106
|
+
updateData.containerId = updates.containerId;
|
|
107
|
+
}
|
|
108
|
+
if (Object.keys(updateData).length > 0) {
|
|
109
|
+
await this.client.redis.hset(key, updateData);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Mark a run as finished with a specific status
|
|
114
|
+
*/
|
|
115
|
+
async finish(runId, status, storageBytes) {
|
|
116
|
+
await this.update(runId, {
|
|
117
|
+
status,
|
|
118
|
+
finishedAt: new Date(),
|
|
119
|
+
...(storageBytes !== undefined && { storageBytes }),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Delete run metadata and remove from the user's sorted-set index.
|
|
124
|
+
*/
|
|
125
|
+
async delete(runId) {
|
|
126
|
+
// Fetch metadata first so we know the userId for the sorted-set cleanup
|
|
127
|
+
const metadata = await this.get(runId);
|
|
128
|
+
const key = this.getRunKey(runId);
|
|
129
|
+
await this.client.redis.del(key);
|
|
130
|
+
if (metadata) {
|
|
131
|
+
await new billing_tracker_js_1.BillingTracker(this.client).releaseStorageBytes(runId, metadata.userId);
|
|
132
|
+
const userRunsKey = this.getUserRunsKey(metadata.userId);
|
|
133
|
+
await this.client.redis.zrem(userRunsKey, runId);
|
|
134
|
+
// Re-evaluate top-N so the run that was sitting at position N+1 (if any)
|
|
135
|
+
// gets promoted to PERSIST now that there's a slot open.
|
|
136
|
+
await this.refreshTopNProtection(metadata.userId);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* List runs by user ID, most-recent first.
|
|
141
|
+
*
|
|
142
|
+
* Uses a per-user sorted set (ZADD on create, ZREM on delete) instead of a
|
|
143
|
+
* full KEYS scan so lookups are O(log N + M) rather than O(N-total-runs).
|
|
144
|
+
* Stale entries left behind by TTL expiry are detected and pruned lazily.
|
|
145
|
+
*/
|
|
146
|
+
async listByUser(userId) {
|
|
147
|
+
const userRunsKey = this.getUserRunsKey(userId);
|
|
148
|
+
// ZREVRANGE returns members sorted by score descending (most-recent first)
|
|
149
|
+
const runIds = await this.client.redis.zrevrange(userRunsKey, 0, -1);
|
|
150
|
+
const runs = [];
|
|
151
|
+
const staleIds = [];
|
|
152
|
+
for (const runId of runIds) {
|
|
153
|
+
const metadata = await this.get(runId);
|
|
154
|
+
if (!metadata) {
|
|
155
|
+
// Run hash expired via TTL or was deleted without going through delete()
|
|
156
|
+
staleIds.push(runId);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
runs.push(metadata);
|
|
160
|
+
}
|
|
161
|
+
// Lazily prune stale entries from the sorted set
|
|
162
|
+
if (staleIds.length > 0) {
|
|
163
|
+
await this.client.redis.zrem(userRunsKey, ...staleIds);
|
|
164
|
+
}
|
|
165
|
+
return runs; // Already in descending createdAt order from ZREVRANGE
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Enforce the top-N indefinite retention policy for a user.
|
|
169
|
+
*
|
|
170
|
+
* - The KEEP_N_RUNS most-recent runs get PERSIST (no TTL → kept forever).
|
|
171
|
+
* - Runs beyond that window that currently have no TTL (were previously in the
|
|
172
|
+
* top N) get the plan-based TTL re-applied so they will eventually expire.
|
|
173
|
+
* - The per-user sorted set is PERSISTed while protected runs exist, and gets
|
|
174
|
+
* a plan-based TTL when no protected runs remain (prevents a memory leak for
|
|
175
|
+
* inactive users whose sorted set would otherwise never expire).
|
|
176
|
+
*
|
|
177
|
+
* Called after every create() and delete() so the window stays accurate.
|
|
178
|
+
*
|
|
179
|
+
* Performance: previously used N sequential awaits per phase; now each phase is
|
|
180
|
+
* a single pipelined round-trip regardless of the number of runs.
|
|
181
|
+
*/
|
|
182
|
+
async refreshTopNProtection(userId) {
|
|
183
|
+
const userRunsKey = this.getUserRunsKey(userId);
|
|
184
|
+
const allRunIds = await this.client.redis.zrevrange(userRunsKey, 0, -1);
|
|
185
|
+
// All runs have been deleted — clean up the now-empty sorted set immediately
|
|
186
|
+
// rather than leaving it to drift in memory indefinitely.
|
|
187
|
+
if (allRunIds.length === 0) {
|
|
188
|
+
await this.client.redis.del(userRunsKey);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const topIds = allRunIds.slice(0, KEEP_N_RUNS);
|
|
192
|
+
const olderIds = allRunIds.slice(KEEP_N_RUNS);
|
|
193
|
+
// ── Phase 1: PERSIST all top-N run keys and the sorted set itself ─────────
|
|
194
|
+
// The sorted set must outlive the runs it indexes, so it gets PERSIST too.
|
|
195
|
+
if (topIds.length > 0) {
|
|
196
|
+
const persistPipeline = this.client.redis.pipeline();
|
|
197
|
+
for (const runId of topIds) {
|
|
198
|
+
persistPipeline.persist(this.getRunKey(runId));
|
|
199
|
+
}
|
|
200
|
+
persistPipeline.persist(userRunsKey);
|
|
201
|
+
await persistPipeline.exec();
|
|
202
|
+
}
|
|
203
|
+
// ── Phase 2: Batch-read TTLs for all older-than-top-N run keys ───────────
|
|
204
|
+
if (olderIds.length === 0)
|
|
205
|
+
return;
|
|
206
|
+
const ttlPipeline = this.client.redis.pipeline();
|
|
207
|
+
for (const runId of olderIds) {
|
|
208
|
+
ttlPipeline.ttl(this.getRunKey(runId));
|
|
209
|
+
}
|
|
210
|
+
const ttlResults = await ttlPipeline.exec();
|
|
211
|
+
// Collect only the runs that were demoted (exist but have no expiry, TTL = -1)
|
|
212
|
+
const demotedIds = olderIds.filter((_, i) => ttlResults?.[i]?.[1] === -1);
|
|
213
|
+
if (demotedIds.length === 0) {
|
|
214
|
+
// No demoted runs, but if top-N is also empty we still need to set the
|
|
215
|
+
// sorted-set TTL so inactive users' index keys don't live forever.
|
|
216
|
+
if (topIds.length === 0) {
|
|
217
|
+
const sortedSetTtl = this.client.ttlManager.getTtl('dataset', 'free', false);
|
|
218
|
+
if (sortedSetTtl)
|
|
219
|
+
await this.client.redis.expire(userRunsKey, sortedSetTtl, 'NX');
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// ── Phase 3: Batch-read the plan field for each demoted run ──────────────
|
|
224
|
+
const planPipeline = this.client.redis.pipeline();
|
|
225
|
+
for (const runId of demotedIds) {
|
|
226
|
+
planPipeline.hget(this.getRunKey(runId), 'plan');
|
|
227
|
+
}
|
|
228
|
+
const planResults = await planPipeline.exec();
|
|
229
|
+
// ── Phase 4: Apply plan-based TTLs in a single pipeline ──────────────────
|
|
230
|
+
const expirePipeline = this.client.redis.pipeline();
|
|
231
|
+
for (let i = 0; i < demotedIds.length; i++) {
|
|
232
|
+
const runId = demotedIds[i];
|
|
233
|
+
const plan = (planResults?.[i]?.[1] ?? 'free');
|
|
234
|
+
const expireSecs = this.client.ttlManager.getTtl('dataset', plan, false);
|
|
235
|
+
if (expireSecs) {
|
|
236
|
+
expirePipeline.expire(this.getRunKey(runId), expireSecs);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// If there are no top-N protected runs, also set the sorted-set TTL so it
|
|
240
|
+
// doesn't persist forever once all remaining runs age out of protection.
|
|
241
|
+
// NX ensures we only apply this once; subsequent calls won't shorten it.
|
|
242
|
+
if (topIds.length === 0) {
|
|
243
|
+
const sortedSetTtl = this.client.ttlManager.getTtl('dataset', 'free', false);
|
|
244
|
+
if (sortedSetTtl)
|
|
245
|
+
expirePipeline.expire(userRunsKey, sortedSetTtl, 'NX');
|
|
246
|
+
}
|
|
247
|
+
await expirePipeline.exec();
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Get the Redis key for a run
|
|
251
|
+
*/
|
|
252
|
+
getRunKey(runId) {
|
|
253
|
+
return this.client.key('run', runId);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
exports.RunMetadataManager = RunMetadataManager;
|