@crawlee/core 3.17.1-beta.5 → 3.17.1-beta.50
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/autoscaling/autoscaled_pool.js +4 -1
- package/autoscaling/snapshotter.d.ts +2 -2
- package/cookie_utils.js +2 -0
- package/http_clients/got-scraping-http-client.js +8 -0
- package/package.json +5 -5
- package/router.d.ts +55 -14
- package/router.js +25 -24
- package/storages/request_dedup_cache.d.ts +23 -0
- package/storages/request_dedup_cache.js +66 -0
- package/storages/request_list.js +3 -1
- package/storages/request_provider.d.ts +7 -0
- package/storages/request_provider.js +39 -9
|
@@ -415,14 +415,17 @@ class AutoscaledPool {
|
|
|
415
415
|
this.isStopped = true;
|
|
416
416
|
await new Promise((resolve, reject) => {
|
|
417
417
|
let timeout;
|
|
418
|
+
let interval;
|
|
418
419
|
if (timeoutSecs) {
|
|
419
420
|
timeout = setTimeout(() => {
|
|
421
|
+
// Clean up the polling interval to prevent it from leaking on timeout.
|
|
422
|
+
clearInterval(interval);
|
|
420
423
|
const err = new Error("The pool's running tasks did not finish" +
|
|
421
424
|
`in ${timeoutSecs} secs after pool.pause() invocation.`);
|
|
422
425
|
reject(err);
|
|
423
426
|
}, timeoutSecs);
|
|
424
427
|
}
|
|
425
|
-
|
|
428
|
+
interval = setInterval(() => {
|
|
426
429
|
if (this._currentConcurrency <= 0) {
|
|
427
430
|
// Clean up timeout and interval to prevent process hanging.
|
|
428
431
|
if (timeout)
|
|
@@ -35,13 +35,13 @@ export interface SnapshotterOptions {
|
|
|
35
35
|
/**
|
|
36
36
|
* Defines the maximum number of new rate limit errors within
|
|
37
37
|
* the given interval.
|
|
38
|
-
* @default
|
|
38
|
+
* @default 3
|
|
39
39
|
*/
|
|
40
40
|
maxClientErrors?: number;
|
|
41
41
|
/**
|
|
42
42
|
* Sets the interval in seconds for which a history of resource snapshots
|
|
43
43
|
* will be kept. Increasing this to very high numbers will affect performance.
|
|
44
|
-
* @default
|
|
44
|
+
* @default 30
|
|
45
45
|
*/
|
|
46
46
|
snapshotHistorySecs?: number;
|
|
47
47
|
/** @internal */
|
package/cookie_utils.js
CHANGED
|
@@ -107,6 +107,8 @@ function mergeCookies(url, sourceCookies) {
|
|
|
107
107
|
if (!cookieString)
|
|
108
108
|
continue;
|
|
109
109
|
const cookie = tough_cookie_1.Cookie.parse(cookieString);
|
|
110
|
+
if (!cookie)
|
|
111
|
+
throw new errors_1.CookieParseError(cookieString);
|
|
110
112
|
const similarKeyCookie = jar.getCookiesSync(url).find((c) => {
|
|
111
113
|
return cookie.key !== c.key && cookie.key.toLowerCase() === c.key.toLowerCase();
|
|
112
114
|
});
|
|
@@ -22,6 +22,14 @@ class GotScrapingHttpClient {
|
|
|
22
22
|
});
|
|
23
23
|
return {
|
|
24
24
|
...gotResult,
|
|
25
|
+
complete: gotResult.complete,
|
|
26
|
+
headers: gotResult.headers,
|
|
27
|
+
ip: gotResult.ip,
|
|
28
|
+
redirectUrls: gotResult.redirectUrls,
|
|
29
|
+
statusCode: gotResult.statusCode,
|
|
30
|
+
statusMessage: gotResult.statusMessage,
|
|
31
|
+
trailers: gotResult.trailers,
|
|
32
|
+
url: gotResult.url,
|
|
25
33
|
body: gotResult.body,
|
|
26
34
|
request: { url: request.url, ...gotResult.request },
|
|
27
35
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlee/core",
|
|
3
|
-
"version": "3.17.1-beta.
|
|
3
|
+
"version": "3.17.1-beta.50",
|
|
4
4
|
"description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=16.0.0"
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"@apify/pseudo_url": "^2.0.30",
|
|
60
60
|
"@apify/timeout": "^0.3.0",
|
|
61
61
|
"@apify/utilities": "^2.7.10",
|
|
62
|
-
"@crawlee/memory-storage": "3.17.1-beta.
|
|
63
|
-
"@crawlee/types": "3.17.1-beta.
|
|
64
|
-
"@crawlee/utils": "3.17.1-beta.
|
|
62
|
+
"@crawlee/memory-storage": "3.17.1-beta.50",
|
|
63
|
+
"@crawlee/types": "3.17.1-beta.50",
|
|
64
|
+
"@crawlee/utils": "3.17.1-beta.50",
|
|
65
65
|
"@sapphire/async-queue": "^1.5.1",
|
|
66
66
|
"@vladfrangu/async_event_emitter": "^2.2.2",
|
|
67
67
|
"csv-stringify": "^6.2.0",
|
|
@@ -83,5 +83,5 @@
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
},
|
|
86
|
-
"gitHead": "
|
|
86
|
+
"gitHead": "adfd432d80ce08c0f0f04bbfce7dc53efc28aeb0"
|
|
87
87
|
}
|
package/router.d.ts
CHANGED
|
@@ -2,13 +2,26 @@ import type { Dictionary } from '@crawlee/types';
|
|
|
2
2
|
import type { CrawlingContext, LoadedRequest, RestrictedCrawlingContext } from './crawlers/crawler_commons';
|
|
3
3
|
import type { Request } from './request';
|
|
4
4
|
import type { Awaitable } from './typedefs';
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The crawling context received by a route handler, with `request.userData` narrowed to `UserData`.
|
|
7
|
+
*/
|
|
8
|
+
export type RouterHandlerContext<Context, UserData extends Dictionary> = Omit<Context, 'request'> & {
|
|
9
|
+
request: LoadedRequest<Request<UserData>>;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The set of labels accepted by {@link Router.addHandler}. When the router declares a concrete
|
|
13
|
+
* route map (e.g. `{ PRODUCT: ...; CATEGORY: ... }`), only those labels (plus symbols) are
|
|
14
|
+
* allowed — unknown labels become a compile-time error. When the map is left open (the default
|
|
15
|
+
* `Record<string, ...>`), any string or symbol label is accepted, preserving the original behaviour.
|
|
16
|
+
*/
|
|
17
|
+
export type RouterLabel<Routes extends Record<keyof Routes, Dictionary>> = string extends keyof Routes ? string | symbol : (keyof Routes & string) | symbol;
|
|
18
|
+
export interface RouterHandler<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'> = CrawlingContext, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>> extends Router<Context, Routes> {
|
|
6
19
|
(ctx: Context): Awaitable<void>;
|
|
7
20
|
}
|
|
8
21
|
export type GetUserDataFromRequest<T> = T extends Request<infer Y> ? Y : never;
|
|
9
|
-
export type RouterRoutes<Context,
|
|
10
|
-
[
|
|
11
|
-
request: Request<
|
|
22
|
+
export type RouterRoutes<Context, Routes extends Record<keyof Routes, Dictionary>> = {
|
|
23
|
+
[Label in keyof Routes]: (ctx: Omit<Context, 'request'> & {
|
|
24
|
+
request: Request<Routes[Label]>;
|
|
12
25
|
}) => Awaitable<void>;
|
|
13
26
|
};
|
|
14
27
|
/**
|
|
@@ -75,8 +88,30 @@ export type RouterRoutes<Context, UserData extends Dictionary> = {
|
|
|
75
88
|
* ctx.log.info('...');
|
|
76
89
|
* });
|
|
77
90
|
* ```
|
|
91
|
+
*
|
|
92
|
+
* To get `request.userData` typed per label, declare a route map and pass it as the second
|
|
93
|
+
* type argument. The label passed to {@link Router.addHandler} then drives the type of
|
|
94
|
+
* `request.userData`, and unknown labels are rejected at compile time:
|
|
95
|
+
*
|
|
96
|
+
* ```ts
|
|
97
|
+
* import { createCheerioRouter, CheerioCrawlingContext } from 'crawlee';
|
|
98
|
+
*
|
|
99
|
+
* interface Routes {
|
|
100
|
+
* PRODUCT: { sku: string; price: number };
|
|
101
|
+
* CATEGORY: { categoryId: string };
|
|
102
|
+
* }
|
|
103
|
+
*
|
|
104
|
+
* const router = createCheerioRouter<CheerioCrawlingContext, Routes>();
|
|
105
|
+
*
|
|
106
|
+
* router.addHandler('PRODUCT', async ({ request }) => {
|
|
107
|
+
* request.userData.sku; // string
|
|
108
|
+
* request.userData.price; // number
|
|
109
|
+
* });
|
|
110
|
+
*
|
|
111
|
+
* router.addHandler('TYPO', async () => {}); // compile error: not a known label
|
|
112
|
+
* ```
|
|
78
113
|
*/
|
|
79
|
-
export declare class Router<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'
|
|
114
|
+
export declare class Router<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'>, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>> {
|
|
80
115
|
private readonly routes;
|
|
81
116
|
private readonly middlewares;
|
|
82
117
|
/**
|
|
@@ -85,17 +120,22 @@ export declare class Router<Context extends Omit<RestrictedCrawlingContext, 'enq
|
|
|
85
120
|
*/
|
|
86
121
|
protected constructor();
|
|
87
122
|
/**
|
|
88
|
-
* Registers new route handler for given label.
|
|
123
|
+
* Registers new route handler for given label. When the router declares a route map, the
|
|
124
|
+
* `label` is restricted to the declared labels and `request.userData` is typed accordingly.
|
|
125
|
+
*/
|
|
126
|
+
addHandler<Label extends keyof Routes & string>(label: Label, handler: (ctx: RouterHandlerContext<Context, Routes[Label]>) => Awaitable<void>): void;
|
|
127
|
+
/**
|
|
128
|
+
* Registers new route handler for given label, explicitly typing `request.userData` via the
|
|
129
|
+
* `UserData` type argument. Useful when the router has no declared route map (the open default)
|
|
130
|
+
* and you want to type a single handler, or to register a handler under a `symbol` label.
|
|
89
131
|
*/
|
|
90
|
-
addHandler<UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(label:
|
|
91
|
-
request: LoadedRequest<Request<UserData>>;
|
|
92
|
-
}) => Awaitable<void>): void;
|
|
132
|
+
addHandler<UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(label: RouterLabel<Routes>, handler: (ctx: RouterHandlerContext<Context, UserData>) => Awaitable<void>): void;
|
|
93
133
|
/**
|
|
94
|
-
* Registers default route handler.
|
|
134
|
+
* Registers default route handler. As a fallback it can receive any request (including labels not
|
|
135
|
+
* declared in the route map), so `request.userData` defaults to the context's `userData` type
|
|
136
|
+
* (loosely typed by default). Pass an explicit `UserData` type argument to narrow it.
|
|
95
137
|
*/
|
|
96
|
-
addDefaultHandler<UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(handler: (ctx:
|
|
97
|
-
request: LoadedRequest<Request<UserData>>;
|
|
98
|
-
}) => Awaitable<void>): void;
|
|
138
|
+
addDefaultHandler<UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(handler: (ctx: RouterHandlerContext<Context, UserData>) => Awaitable<void>): void;
|
|
99
139
|
/**
|
|
100
140
|
* Registers a middleware that will be fired before the matching route handler.
|
|
101
141
|
* Multiple middlewares can be registered, they will be fired in the same order.
|
|
@@ -129,5 +169,6 @@ export declare class Router<Context extends Omit<RestrictedCrawlingContext, 'enq
|
|
|
129
169
|
* await crawler.run();
|
|
130
170
|
* ```
|
|
131
171
|
*/
|
|
132
|
-
static create<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'> = CrawlingContext,
|
|
172
|
+
static create<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'> = CrawlingContext, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>>(routes?: RouterRoutes<Context, Routes>): RouterHandler<Context, Routes>;
|
|
173
|
+
static create<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'> = CrawlingContext, UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(routes?: RouterRoutes<Context, Record<string, UserData>>): RouterHandler<Context, Record<string, UserData>>;
|
|
133
174
|
}
|
package/router.js
CHANGED
|
@@ -67,6 +67,28 @@ const defaultRoute = Symbol('default-route');
|
|
|
67
67
|
* ctx.log.info('...');
|
|
68
68
|
* });
|
|
69
69
|
* ```
|
|
70
|
+
*
|
|
71
|
+
* To get `request.userData` typed per label, declare a route map and pass it as the second
|
|
72
|
+
* type argument. The label passed to {@link Router.addHandler} then drives the type of
|
|
73
|
+
* `request.userData`, and unknown labels are rejected at compile time:
|
|
74
|
+
*
|
|
75
|
+
* ```ts
|
|
76
|
+
* import { createCheerioRouter, CheerioCrawlingContext } from 'crawlee';
|
|
77
|
+
*
|
|
78
|
+
* interface Routes {
|
|
79
|
+
* PRODUCT: { sku: string; price: number };
|
|
80
|
+
* CATEGORY: { categoryId: string };
|
|
81
|
+
* }
|
|
82
|
+
*
|
|
83
|
+
* const router = createCheerioRouter<CheerioCrawlingContext, Routes>();
|
|
84
|
+
*
|
|
85
|
+
* router.addHandler('PRODUCT', async ({ request }) => {
|
|
86
|
+
* request.userData.sku; // string
|
|
87
|
+
* request.userData.price; // number
|
|
88
|
+
* });
|
|
89
|
+
*
|
|
90
|
+
* router.addHandler('TYPO', async () => {}); // compile error: not a known label
|
|
91
|
+
* ```
|
|
70
92
|
*/
|
|
71
93
|
class Router {
|
|
72
94
|
/**
|
|
@@ -87,15 +109,14 @@ class Router {
|
|
|
87
109
|
value: []
|
|
88
110
|
});
|
|
89
111
|
}
|
|
90
|
-
/**
|
|
91
|
-
* Registers new route handler for given label.
|
|
92
|
-
*/
|
|
93
112
|
addHandler(label, handler) {
|
|
94
113
|
this.validate(label);
|
|
95
114
|
this.routes.set(label, handler);
|
|
96
115
|
}
|
|
97
116
|
/**
|
|
98
|
-
* Registers default route handler.
|
|
117
|
+
* Registers default route handler. As a fallback it can receive any request (including labels not
|
|
118
|
+
* declared in the route map), so `request.userData` defaults to the context's `userData` type
|
|
119
|
+
* (loosely typed by default). Pass an explicit `UserData` type argument to narrow it.
|
|
99
120
|
*/
|
|
100
121
|
addDefaultHandler(handler) {
|
|
101
122
|
this.validate(defaultRoute);
|
|
@@ -133,26 +154,6 @@ class Router {
|
|
|
133
154
|
throw new Error(message);
|
|
134
155
|
}
|
|
135
156
|
}
|
|
136
|
-
/**
|
|
137
|
-
* Creates new router instance. This instance can then serve as a `requestHandler` of your crawler.
|
|
138
|
-
*
|
|
139
|
-
* ```ts
|
|
140
|
-
* import { Router, CheerioCrawler, CheerioCrawlingContext } from 'crawlee';
|
|
141
|
-
*
|
|
142
|
-
* const router = Router.create<CheerioCrawlingContext>();
|
|
143
|
-
* router.addHandler('label-a', async (ctx) => {
|
|
144
|
-
* ctx.log.info('...');
|
|
145
|
-
* });
|
|
146
|
-
* router.addDefaultHandler(async (ctx) => {
|
|
147
|
-
* ctx.log.info('...');
|
|
148
|
-
* });
|
|
149
|
-
*
|
|
150
|
-
* const crawler = new CheerioCrawler({
|
|
151
|
-
* requestHandler: router,
|
|
152
|
-
* });
|
|
153
|
-
* await crawler.run();
|
|
154
|
-
* ```
|
|
155
|
-
*/
|
|
156
157
|
static create(routes) {
|
|
157
158
|
const router = new Router();
|
|
158
159
|
const obj = Object.create(Function.prototype);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fixed-size, direct-mapped cache for `uniqueKey`-based request deduplication.
|
|
3
|
+
*
|
|
4
|
+
* `RequestProvider.requestCache` only remembers the first batch of requests, so repeated
|
|
5
|
+
* `addRequestsBatched()` calls with overlapping URLs re-submit already-enqueued requests
|
|
6
|
+
* (https://github.com/apify/crawlee/issues/3120). This is a separate, cheaper cache we can populate on
|
|
7
|
+
* every batch: a fixed number of slots indexed by a hash of the request's cache key, storing the
|
|
8
|
+
* server-assigned `requestId`. Memory is capped by the slot count regardless of the working set size;
|
|
9
|
+
* a hash collision just overwrites a slot, causing an occasional cache miss (a harmless re-submission)
|
|
10
|
+
* but never a false hit — so a genuinely new request is never dropped.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export declare class RequestDeduplicationCache {
|
|
15
|
+
private readonly size;
|
|
16
|
+
private keys;
|
|
17
|
+
private ids;
|
|
18
|
+
constructor(size?: number);
|
|
19
|
+
get(cacheKey: string): string | null;
|
|
20
|
+
add(cacheKey: string, requestId: string): void;
|
|
21
|
+
clear(): void;
|
|
22
|
+
private indexOf;
|
|
23
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RequestDeduplicationCache = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* A fixed-size, direct-mapped cache for `uniqueKey`-based request deduplication.
|
|
6
|
+
*
|
|
7
|
+
* `RequestProvider.requestCache` only remembers the first batch of requests, so repeated
|
|
8
|
+
* `addRequestsBatched()` calls with overlapping URLs re-submit already-enqueued requests
|
|
9
|
+
* (https://github.com/apify/crawlee/issues/3120). This is a separate, cheaper cache we can populate on
|
|
10
|
+
* every batch: a fixed number of slots indexed by a hash of the request's cache key, storing the
|
|
11
|
+
* server-assigned `requestId`. Memory is capped by the slot count regardless of the working set size;
|
|
12
|
+
* a hash collision just overwrites a slot, causing an occasional cache miss (a harmless re-submission)
|
|
13
|
+
* but never a false hit — so a genuinely new request is never dropped.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
class RequestDeduplicationCache {
|
|
18
|
+
// The slot count is the same for every queue, so it's a fixed default rather than a per-consumer option.
|
|
19
|
+
constructor(size = 1000000) {
|
|
20
|
+
Object.defineProperty(this, "size", {
|
|
21
|
+
enumerable: true,
|
|
22
|
+
configurable: true,
|
|
23
|
+
writable: true,
|
|
24
|
+
value: size
|
|
25
|
+
});
|
|
26
|
+
Object.defineProperty(this, "keys", {
|
|
27
|
+
enumerable: true,
|
|
28
|
+
configurable: true,
|
|
29
|
+
writable: true,
|
|
30
|
+
value: void 0
|
|
31
|
+
});
|
|
32
|
+
Object.defineProperty(this, "ids", {
|
|
33
|
+
enumerable: true,
|
|
34
|
+
configurable: true,
|
|
35
|
+
writable: true,
|
|
36
|
+
value: void 0
|
|
37
|
+
});
|
|
38
|
+
this.keys = new Array(size);
|
|
39
|
+
this.ids = new Array(size);
|
|
40
|
+
}
|
|
41
|
+
get(cacheKey) {
|
|
42
|
+
const index = this.indexOf(cacheKey);
|
|
43
|
+
return this.keys[index] === cacheKey ? this.ids[index] : null;
|
|
44
|
+
}
|
|
45
|
+
add(cacheKey, requestId) {
|
|
46
|
+
const index = this.indexOf(cacheKey);
|
|
47
|
+
this.keys[index] = cacheKey;
|
|
48
|
+
this.ids[index] = requestId;
|
|
49
|
+
}
|
|
50
|
+
clear() {
|
|
51
|
+
this.keys = new Array(this.size);
|
|
52
|
+
this.ids = new Array(this.size);
|
|
53
|
+
}
|
|
54
|
+
// A cheap FNV-1a hash of the cache key — avoids pulling in a dedicated hashing dependency.
|
|
55
|
+
indexOf(cacheKey) {
|
|
56
|
+
/* eslint-disable no-bitwise */
|
|
57
|
+
let hash = 0x811c9dc5;
|
|
58
|
+
for (let i = 0; i < cacheKey.length; i++) {
|
|
59
|
+
hash ^= cacheKey.charCodeAt(i);
|
|
60
|
+
hash = Math.imul(hash, 0x01000193);
|
|
61
|
+
}
|
|
62
|
+
return (hash >>> 0) % this.size;
|
|
63
|
+
/* eslint-enable no-bitwise */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.RequestDeduplicationCache = RequestDeduplicationCache;
|
package/storages/request_list.js
CHANGED
|
@@ -324,9 +324,11 @@ class RequestList {
|
|
|
324
324
|
const sourcesFromFunction = await this.sourcesFunction();
|
|
325
325
|
const sourcesFromFunctionCount = sourcesFromFunction.length;
|
|
326
326
|
for (let i = 0; i < sourcesFromFunctionCount; i++) {
|
|
327
|
-
const source = sourcesFromFunction
|
|
327
|
+
const source = sourcesFromFunction[i];
|
|
328
|
+
delete sourcesFromFunction[i];
|
|
328
329
|
this._addRequest(source);
|
|
329
330
|
}
|
|
331
|
+
sourcesFromFunction.length = 0;
|
|
330
332
|
}
|
|
331
333
|
catch (e) {
|
|
332
334
|
const err = e;
|
|
@@ -5,6 +5,7 @@ import { Configuration } from '../configuration';
|
|
|
5
5
|
import type { ProxyConfiguration } from '../proxy_configuration';
|
|
6
6
|
import type { InternalSource, RequestOptions, Source } from '../request';
|
|
7
7
|
import { Request } from '../request';
|
|
8
|
+
import { RequestDeduplicationCache } from './request_dedup_cache';
|
|
8
9
|
import type { IStorage, StorageManagerOptions } from './storage_manager';
|
|
9
10
|
export type RequestsLike = AsyncIterable<Source | string> | Iterable<Source | string> | (Source | string)[];
|
|
10
11
|
/**
|
|
@@ -74,6 +75,12 @@ export declare abstract class RequestProvider implements IStorage, IRequestManag
|
|
|
74
75
|
private initialHandledCount;
|
|
75
76
|
protected queueHeadIds: ListDictionary<string>;
|
|
76
77
|
protected requestCache: LruCache<RequestLruItem>;
|
|
78
|
+
/**
|
|
79
|
+
* Remembers the `requestId` of every request already submitted to the client — including background
|
|
80
|
+
* batches that `requestCache` skips — so overlapping URL sets aren't re-submitted.
|
|
81
|
+
* See {@link RequestDeduplicationCache} for why this is a separate, cheaper cache.
|
|
82
|
+
*/
|
|
83
|
+
protected requestSeenCache: RequestDeduplicationCache;
|
|
77
84
|
protected recentlyHandledRequestsCache: LruCache<boolean>;
|
|
78
85
|
protected queuePausedForMigration: boolean;
|
|
79
86
|
protected lastActivity: Date;
|
|
@@ -11,8 +11,14 @@ const configuration_1 = require("../configuration");
|
|
|
11
11
|
const log_1 = require("../log");
|
|
12
12
|
const request_1 = require("../request");
|
|
13
13
|
const access_checking_1 = require("./access_checking");
|
|
14
|
+
const request_dedup_cache_1 = require("./request_dedup_cache");
|
|
14
15
|
const storage_manager_1 = require("./storage_manager");
|
|
15
16
|
const utils_2 = require("./utils");
|
|
17
|
+
/**
|
|
18
|
+
* Maximum number of consecutive batch-add attempts that make no progress before the remaining
|
|
19
|
+
* unprocessed requests are skipped, so permanently rejected requests don't retry forever.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_UNPROCESSED_REQUESTS_RETRIES = 3;
|
|
16
22
|
class RequestProvider {
|
|
17
23
|
constructor(options, config = configuration_1.Configuration.getGlobalConfig()) {
|
|
18
24
|
Object.defineProperty(this, "config", {
|
|
@@ -113,6 +119,17 @@ class RequestProvider {
|
|
|
113
119
|
writable: true,
|
|
114
120
|
value: void 0
|
|
115
121
|
});
|
|
122
|
+
/**
|
|
123
|
+
* Remembers the `requestId` of every request already submitted to the client — including background
|
|
124
|
+
* batches that `requestCache` skips — so overlapping URL sets aren't re-submitted.
|
|
125
|
+
* See {@link RequestDeduplicationCache} for why this is a separate, cheaper cache.
|
|
126
|
+
*/
|
|
127
|
+
Object.defineProperty(this, "requestSeenCache", {
|
|
128
|
+
enumerable: true,
|
|
129
|
+
configurable: true,
|
|
130
|
+
writable: true,
|
|
131
|
+
value: void 0
|
|
132
|
+
});
|
|
116
133
|
Object.defineProperty(this, "recentlyHandledRequestsCache", {
|
|
117
134
|
enumerable: true,
|
|
118
135
|
configurable: true,
|
|
@@ -151,6 +168,7 @@ class RequestProvider {
|
|
|
151
168
|
});
|
|
152
169
|
this.proxyConfiguration = options.proxyConfiguration;
|
|
153
170
|
this.requestCache = new datastructures_1.LruCache({ maxLength: options.requestCacheMaxSize });
|
|
171
|
+
this.requestSeenCache = new request_dedup_cache_1.RequestDeduplicationCache();
|
|
154
172
|
this.recentlyHandledRequestsCache = new datastructures_1.LruCache({ maxLength: options.recentlyHandledRequestsMaxSize });
|
|
155
173
|
this.log = log_1.log.child({ prefix: `${options.logPrefix}(${this.id}, ${this.name ?? 'no-name'})` });
|
|
156
174
|
const eventManager = config.getEventManager();
|
|
@@ -227,6 +245,7 @@ class RequestProvider {
|
|
|
227
245
|
};
|
|
228
246
|
const { requestId, wasAlreadyPresent } = queueOperationInfo;
|
|
229
247
|
this._cacheRequest(cacheKey, queueOperationInfo);
|
|
248
|
+
this.requestSeenCache.add(cacheKey, requestId);
|
|
230
249
|
if (!wasAlreadyPresent && !this.recentlyHandledRequestsCache.get(requestId)) {
|
|
231
250
|
this.assumedTotalCount++;
|
|
232
251
|
// Performance optimization: add request straight to head if possible
|
|
@@ -288,16 +307,17 @@ class RequestProvider {
|
|
|
288
307
|
const requestsToAdd = new Map();
|
|
289
308
|
for (const request of requests) {
|
|
290
309
|
const cacheKey = getCachedRequestId(request.uniqueKey);
|
|
310
|
+
// Prefer the full `requestCache` record; fall back to the dedup cache for background batches it skips.
|
|
291
311
|
const cachedInfo = this.requestCache.get(cacheKey);
|
|
292
|
-
|
|
293
|
-
|
|
312
|
+
const knownRequestId = cachedInfo?.id ?? this.requestSeenCache.get(cacheKey);
|
|
313
|
+
if (knownRequestId) {
|
|
314
|
+
request.id = knownRequestId;
|
|
294
315
|
results.processedRequests.push({
|
|
295
316
|
wasAlreadyPresent: true,
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
uniqueKey: cachedInfo.uniqueKey,
|
|
317
|
+
// The dedup cache doesn't track the handled state; only the full record does.
|
|
318
|
+
wasAlreadyHandled: cachedInfo?.isHandled ?? false,
|
|
319
|
+
requestId: knownRequestId,
|
|
320
|
+
uniqueKey: request.uniqueKey,
|
|
301
321
|
});
|
|
302
322
|
}
|
|
303
323
|
else if (!requestsToAdd.has(request.uniqueKey)) {
|
|
@@ -320,6 +340,8 @@ class RequestProvider {
|
|
|
320
340
|
if (cache) {
|
|
321
341
|
this._cacheRequest(cacheKey, { ...newRequest, forefront });
|
|
322
342
|
}
|
|
343
|
+
// Unlike `requestCache`, populate this on every batch (including background ones).
|
|
344
|
+
this.requestSeenCache.add(cacheKey, requestId);
|
|
323
345
|
if (!wasAlreadyPresent && !this.recentlyHandledRequestsCache.get(requestId)) {
|
|
324
346
|
this.assumedTotalCount++;
|
|
325
347
|
// Performance optimization: add request straight to head if possible
|
|
@@ -385,13 +407,20 @@ class RequestProvider {
|
|
|
385
407
|
const requestIterator = generateRequests();
|
|
386
408
|
const chunks = (0, utils_1.peekableAsyncIterable)((0, utils_1.chunkedAsyncIterable)(requestIterator, effectiveChunkSize));
|
|
387
409
|
const chunksIterator = chunks[Symbol.asyncIterator]();
|
|
388
|
-
const attemptToAddToQueueAndAddAnyUnprocessed = async (providedRequests, cache = true) => {
|
|
410
|
+
const attemptToAddToQueueAndAddAnyUnprocessed = async (providedRequests, cache = true, unsuccessfulAttempts = 0) => {
|
|
389
411
|
const resultsToReturn = [];
|
|
390
412
|
const apiResult = await this.addRequests(providedRequests, { forefront: options.forefront, cache });
|
|
391
413
|
resultsToReturn.push(...apiResult.processedRequests);
|
|
392
414
|
if (apiResult.unprocessedRequests.length) {
|
|
415
|
+
// Count attempts that make no progress, so permanently rejected requests (e.g. a malformed
|
|
416
|
+
// `userData` shape causing a 400) don't loop forever. Any progress resets the counter.
|
|
417
|
+
const attempts = apiResult.processedRequests.length ? 0 : unsuccessfulAttempts + 1;
|
|
418
|
+
if (attempts >= MAX_UNPROCESSED_REQUESTS_RETRIES) {
|
|
419
|
+
this.log.warning(`Some requests were consistently rejected by the request queue and will be skipped after ${MAX_UNPROCESSED_REQUESTS_RETRIES} attempts. This usually means the request data is malformed (e.g. an invalid 'userData' shape).`, { unprocessedRequests: apiResult.unprocessedRequests });
|
|
420
|
+
return resultsToReturn;
|
|
421
|
+
}
|
|
393
422
|
await (0, utils_1.sleep)(waitBetweenBatchesMillis);
|
|
394
|
-
resultsToReturn.push(...(await attemptToAddToQueueAndAddAnyUnprocessed(providedRequests.filter((r) => !apiResult.processedRequests.some((pr) => pr.uniqueKey === r.uniqueKey)), false)));
|
|
423
|
+
resultsToReturn.push(...(await attemptToAddToQueueAndAddAnyUnprocessed(providedRequests.filter((r) => !apiResult.processedRequests.some((pr) => pr.uniqueKey === r.uniqueKey)), false, attempts)));
|
|
395
424
|
}
|
|
396
425
|
return resultsToReturn;
|
|
397
426
|
};
|
|
@@ -546,6 +575,7 @@ class RequestProvider {
|
|
|
546
575
|
this.assumedTotalCount = 0;
|
|
547
576
|
this.assumedHandledCount = 0;
|
|
548
577
|
this.requestCache.clear();
|
|
578
|
+
this.requestSeenCache.clear();
|
|
549
579
|
}
|
|
550
580
|
/**
|
|
551
581
|
* Caches information about request to beware of unneeded addRequest() calls.
|