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.
Files changed (86) hide show
  1. package/README.md +119 -1
  2. package/dist/actor.d.ts +703 -0
  3. package/dist/actor.js +1441 -0
  4. package/dist/browser-configuration.d.ts +58 -0
  5. package/dist/browser-configuration.js +128 -0
  6. package/dist/browser-locations.d.ts +5 -0
  7. package/dist/browser-locations.js +5469 -0
  8. package/dist/charging.d.ts +153 -0
  9. package/dist/charging.js +782 -0
  10. package/dist/charging_client.d.ts +18 -0
  11. package/dist/charging_client.js +49 -0
  12. package/dist/configuration.d.ts +121 -0
  13. package/dist/configuration.js +128 -0
  14. package/dist/crypto/secrets.d.ts +44 -0
  15. package/dist/crypto/secrets.js +164 -0
  16. package/dist/events/events-client.d.ts +59 -0
  17. package/dist/events/events-client.js +120 -0
  18. package/dist/events/scrapely-event-manager.d.ts +13 -0
  19. package/dist/events/scrapely-event-manager.js +61 -0
  20. package/dist/index.d.ts +29 -0
  21. package/dist/index.js +95 -0
  22. package/dist/log-env.d.ts +1 -0
  23. package/dist/log-env.js +12 -0
  24. package/dist/manifest.d.ts +30 -0
  25. package/dist/manifest.js +47 -0
  26. package/dist/patch-browser-pool.d.ts +66 -0
  27. package/dist/patch-browser-pool.js +827 -0
  28. package/dist/proxy-configuration.d.ts +118 -0
  29. package/dist/proxy-configuration.js +88 -0
  30. package/dist/request-list.d.ts +57 -0
  31. package/dist/request-list.js +79 -0
  32. package/dist/runner/docker-manager.d.ts +88 -0
  33. package/dist/runner/docker-manager.js +283 -0
  34. package/dist/runner/index.d.ts +2 -0
  35. package/dist/runner/index.js +7 -0
  36. package/dist/runner/timeout-manager.d.ts +43 -0
  37. package/dist/runner/timeout-manager.js +90 -0
  38. package/dist/scrapely-proxy-configuration.d.ts +99 -0
  39. package/dist/scrapely-proxy-configuration.js +559 -0
  40. package/dist/storage/api-storage-client.d.ts +199 -0
  41. package/dist/storage/api-storage-client.js +910 -0
  42. package/dist/storage/billing-tracker.d.ts +163 -0
  43. package/dist/storage/billing-tracker.js +260 -0
  44. package/dist/storage/dataset-client.d.ts +43 -0
  45. package/dist/storage/dataset-client.js +232 -0
  46. package/dist/storage/dataset-collection-client.d.ts +8 -0
  47. package/dist/storage/dataset-collection-client.js +75 -0
  48. package/dist/storage/dataset-push-script.d.ts +18 -0
  49. package/dist/storage/dataset-push-script.js +38 -0
  50. package/dist/storage/dragonfly-client.d.ts +94 -0
  51. package/dist/storage/dragonfly-client.js +167 -0
  52. package/dist/storage/index.d.ts +11 -0
  53. package/dist/storage/index.js +25 -0
  54. package/dist/storage/key-value-store-client.d.ts +61 -0
  55. package/dist/storage/key-value-store-client.js +306 -0
  56. package/dist/storage/kv-store-collection-client.d.ts +8 -0
  57. package/dist/storage/kv-store-collection-client.js +76 -0
  58. package/dist/storage/kvs-iteration.d.ts +17 -0
  59. package/dist/storage/kvs-iteration.js +126 -0
  60. package/dist/storage/log-manager.d.ts +114 -0
  61. package/dist/storage/log-manager.js +244 -0
  62. package/dist/storage/patch-storage.d.ts +11 -0
  63. package/dist/storage/patch-storage.js +177 -0
  64. package/dist/storage/request-queue-client.d.ts +93 -0
  65. package/dist/storage/request-queue-client.js +522 -0
  66. package/dist/storage/rq-collection-client.d.ts +8 -0
  67. package/dist/storage/rq-collection-client.js +89 -0
  68. package/dist/storage/rq-list-requests.d.ts +59 -0
  69. package/dist/storage/rq-list-requests.js +114 -0
  70. package/dist/storage/rq-lock-script.d.ts +56 -0
  71. package/dist/storage/rq-lock-script.js +127 -0
  72. package/dist/storage/run-metadata.d.ts +98 -0
  73. package/dist/storage/run-metadata.js +256 -0
  74. package/dist/storage/s3-client.d.ts +103 -0
  75. package/dist/storage/s3-client.js +302 -0
  76. package/dist/storage/ttl-manager.d.ts +70 -0
  77. package/dist/storage/ttl-manager.js +115 -0
  78. package/dist/storage/utils.d.ts +3 -0
  79. package/dist/storage/utils.js +19 -0
  80. package/dist/storage-identifiers.d.ts +20 -0
  81. package/dist/storage-identifiers.js +6 -0
  82. package/dist/utils.d.ts +54 -0
  83. package/dist/utils.js +63 -0
  84. package/dist/validator.d.ts +24 -0
  85. package/dist/validator.js +158 -0
  86. 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;