apify 4.0.0-beta.19 → 4.0.0-beta.21
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/dist/actor.d.ts +29 -6
- package/dist/actor.js +46 -47
- package/dist/apify_dataset_backend.d.ts +18 -0
- package/dist/apify_dataset_backend.js +33 -0
- package/dist/apify_key_value_store_backend.d.ts +23 -0
- package/dist/apify_key_value_store_backend.js +54 -0
- package/dist/apify_request_queue_backend.d.ts +78 -0
- package/dist/apify_request_queue_backend.js +114 -0
- package/dist/apify_request_queue_shared_backend.d.ts +44 -0
- package/dist/apify_request_queue_shared_backend.js +211 -0
- package/dist/apify_request_queue_single_backend.d.ts +52 -0
- package/dist/apify_request_queue_single_backend.js +256 -0
- package/dist/apify_storage_backend.d.ts +109 -0
- package/dist/apify_storage_backend.js +249 -0
- package/dist/configuration.d.ts +6 -4
- package/dist/configuration.js +4 -4
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -3
- package/dist/input-schemas.d.ts +1 -1
- package/dist/platform_event_manager.d.ts +2 -2
- package/dist/platform_event_manager.js +5 -5
- package/dist/proxy_configuration.d.ts +8 -2
- package/dist/proxy_configuration.js +21 -10
- package/dist/storage.d.ts +3 -3
- package/dist/storage.js +4 -4
- package/dist/utils.d.ts +5 -0
- package/dist/utils.js +11 -0
- package/package.json +6 -6
- package/dist/apify_storage_client.d.ts +0 -66
- package/dist/apify_storage_client.js +0 -200
- package/dist/key_value_store.d.ts +0 -21
- package/dist/key_value_store.js +0 -41
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { BatchAddRequestsResult, QueueOperationInfo, RequestQueueOperationOptions, RequestSchema, UpdateRequestSchema } from '@crawlee/types';
|
|
2
|
+
import { ApifyRequestQueueBackend } from './apify_request_queue_backend.js';
|
|
3
|
+
/**
|
|
4
|
+
* Request queue backend safe for multi-consumer scenarios on the Apify platform.
|
|
5
|
+
*
|
|
6
|
+
* Requests fetched via {@link fetchNextRequest} are locked server-side (`listAndLockHead`), so any
|
|
7
|
+
* number of clients — including other Actor runs — can process the same queue concurrently without
|
|
8
|
+
* handing out a request twice. The lock is held until the request is marked as handled or
|
|
9
|
+
* reclaimed, and its duration follows the consumer's expected processing time
|
|
10
|
+
* (see {@link setExpectedRequestProcessingTimeSecs}). This consistency costs roughly one extra API
|
|
11
|
+
* call per processed request compared to the single-consumer backend.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
export declare class ApifyRequestQueueSharedBackend extends ApifyRequestQueueBackend {
|
|
16
|
+
/** Ids of requests locked by this client and waiting to be handed out by `fetchNextRequest`. */
|
|
17
|
+
private readonly headIds;
|
|
18
|
+
/** Dedup records for requests known to exist on the platform, keyed by id. */
|
|
19
|
+
private readonly cachedRequestInfo;
|
|
20
|
+
/** Ids of requests currently being processed by this client. */
|
|
21
|
+
private readonly inProgressIds;
|
|
22
|
+
/** Whether the last head read reported any locked requests left in the queue (any client's). */
|
|
23
|
+
private queueHasLockedRequests?;
|
|
24
|
+
/** Set after a forefront insert — the next head read starts fresh so the insert is honored. */
|
|
25
|
+
private shouldCheckForefrontRequests;
|
|
26
|
+
/** Lock duration applied to fetched requests; raised via `setExpectedRequestProcessingTimeSecs`. */
|
|
27
|
+
private lockSecs;
|
|
28
|
+
/** Serializes head reads and reclaims — both reorder the shared head state. */
|
|
29
|
+
private readonly headLock;
|
|
30
|
+
setExpectedRequestProcessingTimeSecs(secs: number): Promise<void>;
|
|
31
|
+
addBatchOfRequests(requests: RequestSchema[], options?: RequestQueueOperationOptions): Promise<BatchAddRequestsResult>;
|
|
32
|
+
getRequest(uniqueKey: string): Promise<UpdateRequestSchema | undefined>;
|
|
33
|
+
fetchNextRequest(): Promise<UpdateRequestSchema | undefined>;
|
|
34
|
+
markRequestAsHandled(request: UpdateRequestSchema): Promise<QueueOperationInfo | undefined>;
|
|
35
|
+
reclaimRequest(request: UpdateRequestSchema, options?: RequestQueueOperationOptions): Promise<QueueOperationInfo | undefined>;
|
|
36
|
+
isEmpty(): Promise<boolean>;
|
|
37
|
+
isFinished(): Promise<boolean>;
|
|
38
|
+
/** Must be called with the head lock held. */
|
|
39
|
+
private ensureHeadIsNonEmpty;
|
|
40
|
+
/** Must be called with the head lock held. */
|
|
41
|
+
private listAndLockHead;
|
|
42
|
+
private isKnownOrExists;
|
|
43
|
+
private cacheRequestInfo;
|
|
44
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { LruCache } from '@apify/datastructures';
|
|
2
|
+
import log from '@apify/log';
|
|
3
|
+
import { ApifyRequestQueueBackend, AsyncLock } from './apify_request_queue_backend.js';
|
|
4
|
+
/** Maximum number of request dedup records cached locally. */
|
|
5
|
+
const MAX_CACHED_REQUESTS = 1_000_000;
|
|
6
|
+
/** Default lock duration for requests fetched via `fetchNextRequest`. */
|
|
7
|
+
const DEFAULT_REQUEST_LOCK_SECS = 3 * 60;
|
|
8
|
+
/** How many head requests to lock per `listAndLockHead` round-trip. */
|
|
9
|
+
const HEAD_LOCK_LIMIT = 25;
|
|
10
|
+
/**
|
|
11
|
+
* Request queue backend safe for multi-consumer scenarios on the Apify platform.
|
|
12
|
+
*
|
|
13
|
+
* Requests fetched via {@link fetchNextRequest} are locked server-side (`listAndLockHead`), so any
|
|
14
|
+
* number of clients — including other Actor runs — can process the same queue concurrently without
|
|
15
|
+
* handing out a request twice. The lock is held until the request is marked as handled or
|
|
16
|
+
* reclaimed, and its duration follows the consumer's expected processing time
|
|
17
|
+
* (see {@link setExpectedRequestProcessingTimeSecs}). This consistency costs roughly one extra API
|
|
18
|
+
* call per processed request compared to the single-consumer backend.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export class ApifyRequestQueueSharedBackend extends ApifyRequestQueueBackend {
|
|
23
|
+
/** Ids of requests locked by this client and waiting to be handed out by `fetchNextRequest`. */
|
|
24
|
+
headIds = [];
|
|
25
|
+
/** Dedup records for requests known to exist on the platform, keyed by id. */
|
|
26
|
+
cachedRequestInfo = new LruCache({ maxLength: MAX_CACHED_REQUESTS });
|
|
27
|
+
/** Ids of requests currently being processed by this client. */
|
|
28
|
+
inProgressIds = new Set();
|
|
29
|
+
/** Whether the last head read reported any locked requests left in the queue (any client's). */
|
|
30
|
+
queueHasLockedRequests;
|
|
31
|
+
/** Set after a forefront insert — the next head read starts fresh so the insert is honored. */
|
|
32
|
+
shouldCheckForefrontRequests = false;
|
|
33
|
+
/** Lock duration applied to fetched requests; raised via `setExpectedRequestProcessingTimeSecs`. */
|
|
34
|
+
lockSecs = DEFAULT_REQUEST_LOCK_SECS;
|
|
35
|
+
/** Serializes head reads and reclaims — both reorder the shared head state. */
|
|
36
|
+
headLock = new AsyncLock();
|
|
37
|
+
async setExpectedRequestProcessingTimeSecs(secs) {
|
|
38
|
+
// Only ever raise the lock duration — several consumers may share this client, and a
|
|
39
|
+
// short-lived one must not cut the reservation of a long-running one short.
|
|
40
|
+
this.lockSecs = Math.max(this.lockSecs, secs);
|
|
41
|
+
}
|
|
42
|
+
async addBatchOfRequests(requests, options = {}) {
|
|
43
|
+
const { forefront = false } = options;
|
|
44
|
+
// Skip requests this client already knows reached the platform — a platform write costs an
|
|
45
|
+
// API call and a paid write operation. Whether such a request has been handled in the
|
|
46
|
+
// meantime by another client is unknowable locally, so report its last known state.
|
|
47
|
+
const alreadyPresent = [];
|
|
48
|
+
const newRequests = [];
|
|
49
|
+
for (const request of requests) {
|
|
50
|
+
const id = this.requestIdFromUniqueKey(request.uniqueKey);
|
|
51
|
+
const cached = this.cachedRequestInfo.get(id);
|
|
52
|
+
if (cached) {
|
|
53
|
+
alreadyPresent.push({
|
|
54
|
+
requestId: id,
|
|
55
|
+
uniqueKey: request.uniqueKey,
|
|
56
|
+
wasAlreadyPresent: true,
|
|
57
|
+
wasAlreadyHandled: cached.wasAlreadyHandled || request.handledAt != null,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
newRequests.push(request);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
let result = { processedRequests: [], unprocessedRequests: [] };
|
|
65
|
+
if (newRequests.length > 0) {
|
|
66
|
+
result = await this.sendBatch(newRequests, forefront);
|
|
67
|
+
for (const processed of result.processedRequests) {
|
|
68
|
+
this.cacheRequestInfo(processed.requestId, { wasAlreadyHandled: processed.wasAlreadyHandled });
|
|
69
|
+
}
|
|
70
|
+
// A forefront insert changes the head order — have the next head read re-fetch the
|
|
71
|
+
// front of the queue instead of draining the local buffer first.
|
|
72
|
+
if (forefront) {
|
|
73
|
+
this.shouldCheckForefrontRequests = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
result.processedRequests.push(...alreadyPresent);
|
|
77
|
+
this.recordAddedRequests(result);
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
async getRequest(uniqueKey) {
|
|
81
|
+
// The queue is shared — another client may modify a request at any time, so always read
|
|
82
|
+
// through to the platform.
|
|
83
|
+
return this.getRequestById(this.requestIdFromUniqueKey(uniqueKey));
|
|
84
|
+
}
|
|
85
|
+
async fetchNextRequest() {
|
|
86
|
+
const id = await this.headLock.runExclusive(async () => {
|
|
87
|
+
await this.ensureHeadIsNonEmpty();
|
|
88
|
+
return this.headIds.shift();
|
|
89
|
+
});
|
|
90
|
+
if (!id)
|
|
91
|
+
return undefined;
|
|
92
|
+
// Head items carry only partial request data (no userData, payload or headers), so the
|
|
93
|
+
// full record has to be hydrated with a round-trip.
|
|
94
|
+
const request = await this.getRequestById(id);
|
|
95
|
+
if (!request) {
|
|
96
|
+
// The head read can briefly report a request the main table does not serve yet — leave
|
|
97
|
+
// it out of the local head; it will reappear in a later head read.
|
|
98
|
+
log.debug(`Request fetched from the queue head was not found (id: ${id}), will be retried later`);
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
if (request.handledAt) {
|
|
102
|
+
// Handled by another client in the meantime.
|
|
103
|
+
this.cacheRequestInfo(id, { wasAlreadyHandled: true });
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
this.inProgressIds.add(id);
|
|
107
|
+
return request;
|
|
108
|
+
}
|
|
109
|
+
async markRequestAsHandled(request) {
|
|
110
|
+
const id = this.requestIdFromUniqueKey(request.uniqueKey);
|
|
111
|
+
// Contract: marking a request that does not exist in the queue is a no-op — it must not be
|
|
112
|
+
// added as a side effect (the platform update endpoint would upsert it).
|
|
113
|
+
if (!(await this.isKnownOrExists(id))) {
|
|
114
|
+
this.inProgressIds.delete(id);
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
const handledAt = request.handledAt ?? new Date().toISOString();
|
|
118
|
+
const info = await this.updateRequestOnPlatform({ ...request, id, handledAt });
|
|
119
|
+
this.inProgressIds.delete(id);
|
|
120
|
+
this.cacheRequestInfo(id, { wasAlreadyHandled: true });
|
|
121
|
+
if (!info.wasAlreadyHandled) {
|
|
122
|
+
this.estimatedHandledRequestCount += 1;
|
|
123
|
+
}
|
|
124
|
+
return info;
|
|
125
|
+
}
|
|
126
|
+
async reclaimRequest(request, options = {}) {
|
|
127
|
+
const { forefront = false } = options;
|
|
128
|
+
const id = this.requestIdFromUniqueKey(request.uniqueKey);
|
|
129
|
+
// Same contract as `markRequestAsHandled` — never insert as a side effect.
|
|
130
|
+
if (!(await this.isKnownOrExists(id))) {
|
|
131
|
+
this.inProgressIds.delete(id);
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
return this.headLock.runExclusive(async () => {
|
|
135
|
+
const info = await this.updateRequestOnPlatform({ ...request, id, handledAt: undefined }, forefront);
|
|
136
|
+
// Release the server-side lock so the request becomes fetchable again immediately —
|
|
137
|
+
// by any consumer — rather than only after the lock expires.
|
|
138
|
+
try {
|
|
139
|
+
await this.client.deleteRequestLock(id, { forefront });
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
log.debug(`Failed to delete the lock of a reclaimed request (id: ${id}): ${err.message}`);
|
|
143
|
+
}
|
|
144
|
+
this.inProgressIds.delete(id);
|
|
145
|
+
this.cacheRequestInfo(id, { wasAlreadyHandled: false });
|
|
146
|
+
if (forefront) {
|
|
147
|
+
this.shouldCheckForefrontRequests = true;
|
|
148
|
+
}
|
|
149
|
+
if (info.wasAlreadyHandled) {
|
|
150
|
+
this.estimatedHandledRequestCount -= 1;
|
|
151
|
+
}
|
|
152
|
+
return info;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
async isEmpty() {
|
|
156
|
+
return this.headLock.runExclusive(async () => {
|
|
157
|
+
if (this.headIds.length > 0)
|
|
158
|
+
return false;
|
|
159
|
+
await this.listAndLockHead(1);
|
|
160
|
+
return this.headIds.length === 0;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
async isFinished() {
|
|
164
|
+
return this.headLock.runExclusive(async () => {
|
|
165
|
+
if (this.headIds.length > 0)
|
|
166
|
+
return false;
|
|
167
|
+
// The head read also refreshes `queueHasLockedRequests`, so the order matters here.
|
|
168
|
+
await this.listAndLockHead(1);
|
|
169
|
+
return this.headIds.length === 0 && !this.queueHasLockedRequests;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/** Must be called with the head lock held. */
|
|
173
|
+
async ensureHeadIsNonEmpty() {
|
|
174
|
+
if (this.headIds.length > 1 && !this.shouldCheckForefrontRequests) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
await this.listAndLockHead(HEAD_LOCK_LIMIT);
|
|
178
|
+
}
|
|
179
|
+
/** Must be called with the head lock held. */
|
|
180
|
+
async listAndLockHead(limit) {
|
|
181
|
+
// After a forefront insert the local buffer no longer starts at the true front of the
|
|
182
|
+
// queue — re-fetch the front and keep the already-locked leftovers for afterwards.
|
|
183
|
+
let leftoverIds = [];
|
|
184
|
+
if (this.shouldCheckForefrontRequests) {
|
|
185
|
+
leftoverIds = this.headIds.splice(0);
|
|
186
|
+
this.shouldCheckForefrontRequests = false;
|
|
187
|
+
}
|
|
188
|
+
const head = await this.client.listAndLockHead({ limit, lockSecs: this.lockSecs });
|
|
189
|
+
this.queueHasLockedRequests = head.queueHasLockedRequests;
|
|
190
|
+
for (const item of head.items) {
|
|
191
|
+
if (this.inProgressIds.has(item.id))
|
|
192
|
+
continue;
|
|
193
|
+
if (this.headIds.includes(item.id) || leftoverIds.includes(item.id))
|
|
194
|
+
continue;
|
|
195
|
+
this.cacheRequestInfo(item.id, { wasAlreadyHandled: false });
|
|
196
|
+
this.headIds.push(item.id);
|
|
197
|
+
}
|
|
198
|
+
this.headIds.push(...leftoverIds);
|
|
199
|
+
}
|
|
200
|
+
async isKnownOrExists(id) {
|
|
201
|
+
if (this.inProgressIds.has(id) || this.cachedRequestInfo.get(id)) {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
return (await this.getRequestById(id)) !== undefined;
|
|
205
|
+
}
|
|
206
|
+
cacheRequestInfo(id, info) {
|
|
207
|
+
// `LruCache.add` does not overwrite existing entries, so remove first.
|
|
208
|
+
this.cachedRequestInfo.remove(id);
|
|
209
|
+
this.cachedRequestInfo.add(id, info);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { BatchAddRequestsResult, QueueOperationInfo, RequestQueueOperationOptions, RequestSchema, UpdateRequestSchema } from '@crawlee/types';
|
|
2
|
+
import { ApifyRequestQueueBackend } from './apify_request_queue_backend.js';
|
|
3
|
+
/**
|
|
4
|
+
* Request queue backend optimized for single-consumer scenarios on the Apify platform.
|
|
5
|
+
*
|
|
6
|
+
* Minimizes API calls by keeping a local estimate of the queue head and a local cache of the
|
|
7
|
+
* requests this client added — a request fetched from the head is usually served straight from the
|
|
8
|
+
* cache, with no per-request round-trip and no server-side locking.
|
|
9
|
+
*
|
|
10
|
+
* ### Usage constraints
|
|
11
|
+
*
|
|
12
|
+
* - **Single consumer** — only one client may fetch and process requests from the queue at a time.
|
|
13
|
+
* - **Multiple producers allowed** — other clients may add requests concurrently, but their
|
|
14
|
+
* forefront requests may not be prioritized immediately, as this client relies on a local head
|
|
15
|
+
* estimate instead of frequent head fetching.
|
|
16
|
+
* - **Append-only queue** — other clients must not delete or modify existing requests, as such
|
|
17
|
+
* changes are not reflected in the local cache. Marking requests as handled elsewhere is
|
|
18
|
+
* tolerated, but may occasionally lead to a request being processed twice.
|
|
19
|
+
*
|
|
20
|
+
* If these constraints do not hold, use the shared backend (`requestQueueAccess: 'shared'`) instead.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
export declare class ApifyRequestQueueSingleBackend extends ApifyRequestQueueBackend {
|
|
25
|
+
/** Local estimate of the queue head — request ids in the order they should be fetched. */
|
|
26
|
+
private readonly headIds;
|
|
27
|
+
/** Unhandled full request objects added by (or fetched through) this client, keyed by id. */
|
|
28
|
+
private readonly cachedRequests;
|
|
29
|
+
/** Ids of requests known to be already handled — cheap dedup without caching full objects. */
|
|
30
|
+
private readonly handledIds;
|
|
31
|
+
/** Ids of requests currently being processed by this client. */
|
|
32
|
+
private readonly inProgressIds;
|
|
33
|
+
/** Memoized one-time prefetch of existing queue contents into the local caches. */
|
|
34
|
+
private initCachesPromise?;
|
|
35
|
+
addBatchOfRequests(requests: RequestSchema[], options?: RequestQueueOperationOptions): Promise<BatchAddRequestsResult>;
|
|
36
|
+
getRequest(uniqueKey: string): Promise<UpdateRequestSchema | undefined>;
|
|
37
|
+
fetchNextRequest(): Promise<UpdateRequestSchema | undefined>;
|
|
38
|
+
markRequestAsHandled(request: UpdateRequestSchema): Promise<QueueOperationInfo | undefined>;
|
|
39
|
+
reclaimRequest(request: UpdateRequestSchema, options?: RequestQueueOperationOptions): Promise<QueueOperationInfo | undefined>;
|
|
40
|
+
isEmpty(): Promise<boolean>;
|
|
41
|
+
isFinished(): Promise<boolean>;
|
|
42
|
+
private ensureHeadIsNonEmpty;
|
|
43
|
+
private listHead;
|
|
44
|
+
/**
|
|
45
|
+
* One-time prefetch of the existing queue contents into the local caches, so that re-added
|
|
46
|
+
* requests of a resurrected run are deduplicated locally (one read API call for the whole
|
|
47
|
+
* cache) instead of on the platform (one write operation per request).
|
|
48
|
+
*/
|
|
49
|
+
private initCaches;
|
|
50
|
+
private isKnownOrExists;
|
|
51
|
+
private cacheRequest;
|
|
52
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { LruCache } from '@apify/datastructures';
|
|
2
|
+
import log from '@apify/log';
|
|
3
|
+
import { ApifyRequestQueueBackend } from './apify_request_queue_backend.js';
|
|
4
|
+
/** Maximum number of full request objects cached locally. */
|
|
5
|
+
const MAX_CACHED_REQUESTS = 1_000_000;
|
|
6
|
+
/** The maximum head items read count, limited by the API. */
|
|
7
|
+
const MAX_HEAD_ITEMS = 1000;
|
|
8
|
+
/** How many new head items to aim for per `listHead` round-trip. */
|
|
9
|
+
const DESIRED_NEW_HEAD_ITEMS = 200;
|
|
10
|
+
/** How many existing requests to prefetch into the local caches on the first add. */
|
|
11
|
+
const INIT_CACHES_REQUEST_LIMIT = 10_000;
|
|
12
|
+
/**
|
|
13
|
+
* Request queue backend optimized for single-consumer scenarios on the Apify platform.
|
|
14
|
+
*
|
|
15
|
+
* Minimizes API calls by keeping a local estimate of the queue head and a local cache of the
|
|
16
|
+
* requests this client added — a request fetched from the head is usually served straight from the
|
|
17
|
+
* cache, with no per-request round-trip and no server-side locking.
|
|
18
|
+
*
|
|
19
|
+
* ### Usage constraints
|
|
20
|
+
*
|
|
21
|
+
* - **Single consumer** — only one client may fetch and process requests from the queue at a time.
|
|
22
|
+
* - **Multiple producers allowed** — other clients may add requests concurrently, but their
|
|
23
|
+
* forefront requests may not be prioritized immediately, as this client relies on a local head
|
|
24
|
+
* estimate instead of frequent head fetching.
|
|
25
|
+
* - **Append-only queue** — other clients must not delete or modify existing requests, as such
|
|
26
|
+
* changes are not reflected in the local cache. Marking requests as handled elsewhere is
|
|
27
|
+
* tolerated, but may occasionally lead to a request being processed twice.
|
|
28
|
+
*
|
|
29
|
+
* If these constraints do not hold, use the shared backend (`requestQueueAccess: 'shared'`) instead.
|
|
30
|
+
*
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
export class ApifyRequestQueueSingleBackend extends ApifyRequestQueueBackend {
|
|
34
|
+
/** Local estimate of the queue head — request ids in the order they should be fetched. */
|
|
35
|
+
headIds = [];
|
|
36
|
+
/** Unhandled full request objects added by (or fetched through) this client, keyed by id. */
|
|
37
|
+
cachedRequests = new LruCache({ maxLength: MAX_CACHED_REQUESTS });
|
|
38
|
+
/** Ids of requests known to be already handled — cheap dedup without caching full objects. */
|
|
39
|
+
handledIds = new Set();
|
|
40
|
+
/** Ids of requests currently being processed by this client. */
|
|
41
|
+
inProgressIds = new Set();
|
|
42
|
+
/** Memoized one-time prefetch of existing queue contents into the local caches. */
|
|
43
|
+
initCachesPromise;
|
|
44
|
+
async addBatchOfRequests(requests, options = {}) {
|
|
45
|
+
const { forefront = false } = options;
|
|
46
|
+
await (this.initCachesPromise ??= this.initCaches());
|
|
47
|
+
// Split the batch into requests we already know about (dedup them locally — a platform
|
|
48
|
+
// write costs an API call and a paid write operation) and genuinely new ones.
|
|
49
|
+
const alreadyPresent = [];
|
|
50
|
+
const newRequests = [];
|
|
51
|
+
for (const request of requests) {
|
|
52
|
+
const id = this.requestIdFromUniqueKey(request.uniqueKey);
|
|
53
|
+
if (this.handledIds.has(id)) {
|
|
54
|
+
alreadyPresent.push({
|
|
55
|
+
requestId: id,
|
|
56
|
+
uniqueKey: request.uniqueKey,
|
|
57
|
+
wasAlreadyPresent: true,
|
|
58
|
+
wasAlreadyHandled: true,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else if (this.cachedRequests.get(id)) {
|
|
62
|
+
alreadyPresent.push({
|
|
63
|
+
requestId: id,
|
|
64
|
+
uniqueKey: request.uniqueKey,
|
|
65
|
+
wasAlreadyPresent: true,
|
|
66
|
+
wasAlreadyHandled: request.handledAt != null,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
newRequests.push(request);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
let result = { processedRequests: [], unprocessedRequests: [] };
|
|
74
|
+
if (newRequests.length > 0) {
|
|
75
|
+
result = await this.sendBatch(newRequests, forefront);
|
|
76
|
+
// Commit the accepted requests to the local caches and the head estimate. The platform
|
|
77
|
+
// response is authoritative — a request it reports as already handled (e.g. handled by
|
|
78
|
+
// a previous run of a resurrected Actor beyond the prefetch limit) must not re-enter
|
|
79
|
+
// the head.
|
|
80
|
+
const processedByKey = new Map(result.processedRequests.map((processed) => [processed.uniqueKey, processed]));
|
|
81
|
+
for (const request of newRequests) {
|
|
82
|
+
const processed = processedByKey.get(request.uniqueKey);
|
|
83
|
+
if (!processed)
|
|
84
|
+
continue; // rejected by the platform, reported in `unprocessedRequests`
|
|
85
|
+
if (processed.wasAlreadyHandled) {
|
|
86
|
+
this.handledIds.add(processed.requestId);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
this.cacheRequest({ ...request, id: processed.requestId });
|
|
90
|
+
if (forefront) {
|
|
91
|
+
this.headIds.unshift(processed.requestId);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
this.headIds.push(processed.requestId);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
result.processedRequests.push(...alreadyPresent);
|
|
99
|
+
this.recordAddedRequests(result);
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
async getRequest(uniqueKey) {
|
|
103
|
+
const id = this.requestIdFromUniqueKey(uniqueKey);
|
|
104
|
+
const cached = this.cachedRequests.get(id);
|
|
105
|
+
if (cached)
|
|
106
|
+
return cached;
|
|
107
|
+
const request = await this.getRequestById(id);
|
|
108
|
+
if (!request)
|
|
109
|
+
return undefined;
|
|
110
|
+
// Requests already in progress are ones the client knows about — no caching needed.
|
|
111
|
+
if (!this.inProgressIds.has(id)) {
|
|
112
|
+
if (request.handledAt) {
|
|
113
|
+
this.handledIds.add(id);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
this.cacheRequest(request);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return request;
|
|
120
|
+
}
|
|
121
|
+
async fetchNextRequest() {
|
|
122
|
+
await this.ensureHeadIsNonEmpty();
|
|
123
|
+
while (this.headIds.length > 0) {
|
|
124
|
+
const id = this.headIds.shift();
|
|
125
|
+
if (this.inProgressIds.has(id) || this.handledIds.has(id)) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
this.inProgressIds.add(id);
|
|
129
|
+
// Requests added by this client are served straight from the cache; only requests
|
|
130
|
+
// discovered via `listHead` (added by another producer) need a round-trip.
|
|
131
|
+
const request = this.cachedRequests.get(id) ?? (await this.getRequestById(id));
|
|
132
|
+
if (!request) {
|
|
133
|
+
this.inProgressIds.delete(id);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (request.handledAt) {
|
|
137
|
+
// Handled elsewhere in the meantime — skip it and remember the outcome.
|
|
138
|
+
this.inProgressIds.delete(id);
|
|
139
|
+
this.handledIds.add(id);
|
|
140
|
+
this.cachedRequests.remove(id);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
return request;
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
async markRequestAsHandled(request) {
|
|
148
|
+
const id = this.requestIdFromUniqueKey(request.uniqueKey);
|
|
149
|
+
// Contract: marking a request that does not exist in the queue is a no-op — it must not be
|
|
150
|
+
// added as a side effect (the platform update endpoint would upsert it).
|
|
151
|
+
if (!(await this.isKnownOrExists(id))) {
|
|
152
|
+
this.inProgressIds.delete(id);
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const handledAt = request.handledAt ?? new Date().toISOString();
|
|
156
|
+
const info = await this.updateRequestOnPlatform({ ...request, id, handledAt });
|
|
157
|
+
this.inProgressIds.delete(id);
|
|
158
|
+
this.handledIds.add(id);
|
|
159
|
+
this.cachedRequests.remove(id);
|
|
160
|
+
if (!info.wasAlreadyHandled) {
|
|
161
|
+
this.estimatedHandledRequestCount += 1;
|
|
162
|
+
}
|
|
163
|
+
return info;
|
|
164
|
+
}
|
|
165
|
+
async reclaimRequest(request, options = {}) {
|
|
166
|
+
const { forefront = false } = options;
|
|
167
|
+
const id = this.requestIdFromUniqueKey(request.uniqueKey);
|
|
168
|
+
// Same contract as `markRequestAsHandled` — never insert as a side effect.
|
|
169
|
+
if (!(await this.isKnownOrExists(id))) {
|
|
170
|
+
this.inProgressIds.delete(id);
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
// Reclaiming returns the request to the queue for reprocessing.
|
|
174
|
+
const reclaimed = { ...request, id, handledAt: undefined };
|
|
175
|
+
const info = await this.updateRequestOnPlatform(reclaimed, forefront);
|
|
176
|
+
this.inProgressIds.delete(id);
|
|
177
|
+
this.handledIds.delete(id);
|
|
178
|
+
this.cacheRequest(reclaimed);
|
|
179
|
+
// Return the id to the local head estimate right away — the platform head read can lag a
|
|
180
|
+
// few seconds behind the update, and `isFinished` must never report `true` while a
|
|
181
|
+
// reclaimed request is still waiting to be reprocessed.
|
|
182
|
+
if (!this.headIds.includes(id)) {
|
|
183
|
+
if (forefront) {
|
|
184
|
+
this.headIds.unshift(id);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
this.headIds.push(id);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (info.wasAlreadyHandled) {
|
|
191
|
+
this.estimatedHandledRequestCount -= 1;
|
|
192
|
+
}
|
|
193
|
+
return info;
|
|
194
|
+
}
|
|
195
|
+
async isEmpty() {
|
|
196
|
+
await this.ensureHeadIsNonEmpty();
|
|
197
|
+
return this.headIds.length === 0;
|
|
198
|
+
}
|
|
199
|
+
async isFinished() {
|
|
200
|
+
return (await this.isEmpty()) && this.inProgressIds.size === 0;
|
|
201
|
+
}
|
|
202
|
+
async ensureHeadIsNonEmpty() {
|
|
203
|
+
if (this.headIds.length <= 1) {
|
|
204
|
+
await this.listHead();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async listHead() {
|
|
208
|
+
// The head read returns in-progress requests too, so fetch enough to find new ones.
|
|
209
|
+
const limit = Math.min(MAX_HEAD_ITEMS, DESIRED_NEW_HEAD_ITEMS + this.inProgressIds.size);
|
|
210
|
+
const head = await this.client.listHead({ limit });
|
|
211
|
+
for (const item of head.items) {
|
|
212
|
+
if (this.inProgressIds.has(item.id) || this.handledIds.has(item.id)) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// `headIds` is nearly drained whenever this runs (see `ensureHeadIsNonEmpty`), so the
|
|
216
|
+
// linear dedup scan stays cheap.
|
|
217
|
+
if (!this.headIds.includes(item.id)) {
|
|
218
|
+
this.headIds.push(item.id);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* One-time prefetch of the existing queue contents into the local caches, so that re-added
|
|
224
|
+
* requests of a resurrected run are deduplicated locally (one read API call for the whole
|
|
225
|
+
* cache) instead of on the platform (one write operation per request).
|
|
226
|
+
*/
|
|
227
|
+
async initCaches() {
|
|
228
|
+
try {
|
|
229
|
+
const response = await this.client.listRequests({ limit: INIT_CACHES_REQUEST_LIMIT });
|
|
230
|
+
for (const request of response.items) {
|
|
231
|
+
if (request.handledAt) {
|
|
232
|
+
this.handledIds.add(request.id);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
this.cacheRequest(request);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
// The prefetch is a cost optimization, not a correctness requirement — deduplication
|
|
241
|
+
// falls back to the platform.
|
|
242
|
+
log.warning(`Failed to prefetch the request queue contents into the local cache: ${err.message}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async isKnownOrExists(id) {
|
|
246
|
+
if (this.inProgressIds.has(id) || this.handledIds.has(id) || this.cachedRequests.get(id)) {
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
return (await this.getRequestById(id)) !== undefined;
|
|
250
|
+
}
|
|
251
|
+
cacheRequest(request) {
|
|
252
|
+
// `LruCache.add` does not overwrite existing entries, so remove first.
|
|
253
|
+
this.cachedRequests.remove(request.id);
|
|
254
|
+
this.cachedRequests.add(request.id, request);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import type { DatasetBackend, KeyValueStoreBackend, RequestQueueBackend, StorageBackend, StorageIdentifier } from '@crawlee/types';
|
|
3
|
+
import type { ApifyClient } from 'apify-client';
|
|
4
|
+
import type { RequestQueueAccessMode } from './apify_request_queue_backend.js';
|
|
5
|
+
import { type ChargeResult, type ChargingManager } from './charging.js';
|
|
6
|
+
import type { Configuration } from './configuration.js';
|
|
7
|
+
type StorageType = 'Dataset' | 'KeyValueStore' | 'RequestQueue';
|
|
8
|
+
/** Marks a dataset backend whose underlying client charges for pushed items (pay-per-event). @internal */
|
|
9
|
+
export declare const USES_PUSH_DATA_INTERCEPTION: unique symbol;
|
|
10
|
+
/**
|
|
11
|
+
* Context of a single `Actor.pushData()` call, shared with the intercepted
|
|
12
|
+
* `pushItems()` calls so they can (1) know which event to charge and
|
|
13
|
+
* (2) aggregate the {@link ChargeResult} across the multiple `pushItems()`
|
|
14
|
+
* calls a single `pushData()` may trigger (Crawlee batches large pushes).
|
|
15
|
+
*/
|
|
16
|
+
export interface PpeAwarePushDataContext {
|
|
17
|
+
eventName: string | undefined;
|
|
18
|
+
chargeResult?: ChargeResult;
|
|
19
|
+
}
|
|
20
|
+
export declare const pushDataChargingContext: AsyncLocalStorage<PpeAwarePushDataContext>;
|
|
21
|
+
export interface ApifyStorageBackendOptions {
|
|
22
|
+
/**
|
|
23
|
+
* SDK configuration providing the run's default storage ids and related environment values.
|
|
24
|
+
* Without it, opening storages requires an explicit id or name.
|
|
25
|
+
*/
|
|
26
|
+
configuration?: Configuration;
|
|
27
|
+
/**
|
|
28
|
+
* Determines how request queues opened through this backend are consumed —
|
|
29
|
+
* `'single'` (default) assumes this is the queue's only consumer and skips request locking for
|
|
30
|
+
* fewer (paid) API calls; `'shared'` locks requests server-side so any number of concurrent
|
|
31
|
+
* consumers can process the same queue safely.
|
|
32
|
+
*/
|
|
33
|
+
requestQueueAccess?: RequestQueueAccessMode;
|
|
34
|
+
/**
|
|
35
|
+
* Supplies the charging manager for pay-per-event runs, enabling the charging-aware default
|
|
36
|
+
* dataset client.
|
|
37
|
+
* @internal
|
|
38
|
+
*/
|
|
39
|
+
getChargingManager?: () => ChargingManager;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Bridges `apify-client`'s synchronous resource accessors (`dataset(id)`,
|
|
43
|
+
* `keyValueStore(id)`, `requestQueue(id, options?)`) to crawlee v4's
|
|
44
|
+
* `StorageBackend` interface (async factory methods accepting an `id`,
|
|
45
|
+
* a `name`, or an `alias`).
|
|
46
|
+
*
|
|
47
|
+
* For the run's default dataset it transparently swaps in a charging-aware
|
|
48
|
+
* dataset client (pay-per-event on `Actor.pushData()`), provided a charging
|
|
49
|
+
* manager is supplied and a default-dataset-item price is configured.
|
|
50
|
+
*
|
|
51
|
+
* `Actor` wires this up automatically; construct it directly only to use Apify
|
|
52
|
+
* platform storage with crawlee's storage classes outside of `Actor` — e.g. to
|
|
53
|
+
* read another run's output with an explicit token:
|
|
54
|
+
*
|
|
55
|
+
* ```ts
|
|
56
|
+
* import { ApifyClient, ApifyStorageBackend, Dataset } from 'apify';
|
|
57
|
+
*
|
|
58
|
+
* const client = new ApifyClient({ token });
|
|
59
|
+
* const dataset = await Dataset.open(datasetId, { storageBackend: new ApifyStorageBackend(client) });
|
|
60
|
+
* const { items } = await dataset.getData();
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export declare class ApifyStorageBackend implements StorageBackend {
|
|
64
|
+
private readonly client;
|
|
65
|
+
private readonly config?;
|
|
66
|
+
private readonly requestQueueAccess;
|
|
67
|
+
private readonly getChargingManager?;
|
|
68
|
+
/** Unnamed storages created for aliases in this process, so an alias maps to one storage. */
|
|
69
|
+
private readonly aliasIdCache;
|
|
70
|
+
/** Fallback request queue client key when the run id is unavailable — one per backend. */
|
|
71
|
+
private fallbackClientKey?;
|
|
72
|
+
constructor(client: ApifyClient, options?: ApifyStorageBackendOptions);
|
|
73
|
+
/**
|
|
74
|
+
* Partitions crawlee's storage-instance cache by API base URL and token, so the same storage
|
|
75
|
+
* opened through two differently-authenticated backends is cached separately. The request
|
|
76
|
+
* queue access mode is deliberately not part of the key — opening the same queue in `single`
|
|
77
|
+
* and `shared` mode at once is not supported, and whichever backend opens it first wins.
|
|
78
|
+
*/
|
|
79
|
+
getStorageBackendCacheKey(): string;
|
|
80
|
+
storageExists(id: string, type: StorageType): Promise<boolean>;
|
|
81
|
+
createDatasetBackend(options?: StorageIdentifier): Promise<DatasetBackend>;
|
|
82
|
+
createKeyValueStoreBackend(options?: StorageIdentifier): Promise<KeyValueStoreBackend>;
|
|
83
|
+
createRequestQueueBackend(options?: StorageIdentifier): Promise<RequestQueueBackend>;
|
|
84
|
+
/**
|
|
85
|
+
* A stable per-run client key makes the API's `hadMultipleClients` flag meaningful and lets a
|
|
86
|
+
* migrated or resurrected run re-acquire the request locks of its previous incarnation.
|
|
87
|
+
*/
|
|
88
|
+
private requestQueueClientKey;
|
|
89
|
+
/**
|
|
90
|
+
* Returns a charging-aware dataset client when `id` is the run's default
|
|
91
|
+
* dataset and a default-dataset-item price is configured; otherwise
|
|
92
|
+
* `undefined` (caller uses the plain client).
|
|
93
|
+
*/
|
|
94
|
+
private chargingDatasetClient;
|
|
95
|
+
/**
|
|
96
|
+
* Resolves a crawlee {@link StorageIdentifier} to a platform storage id.
|
|
97
|
+
*
|
|
98
|
+
* Aliases resolve to unnamed storages: the reserved `__default__` alias maps to the run's
|
|
99
|
+
* default storage, and other aliases to the storages declared in the Actor's schema (via the
|
|
100
|
+
* `ACTOR_STORAGES_JSON` environment variable, maintained by the platform). Outside the
|
|
101
|
+
* platform, an unnamed storage is created per alias instead (remembered for this process only).
|
|
102
|
+
*/
|
|
103
|
+
private resolveId;
|
|
104
|
+
/** Looks an alias up in the Actor's schema storages (the `ACTOR_STORAGES_JSON` env var). */
|
|
105
|
+
private aliasFromActorStorages;
|
|
106
|
+
private resourceClient;
|
|
107
|
+
private collectionClient;
|
|
108
|
+
}
|
|
109
|
+
export {};
|