@crawlee/core 4.0.0-beta.146 → 4.0.0-beta.147
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/package.json +6 -6
- package/storages/request_list.d.ts +2 -6
- package/storages/request_list.js +6 -9
- package/storages/request_loader.d.ts +47 -15
- package/storages/request_loader.js +36 -1
- package/storages/request_manager.d.ts +76 -0
- package/storages/request_manager_tandem.d.ts +12 -9
- package/storages/request_manager_tandem.js +29 -20
- package/storages/request_queue.d.ts +14 -17
- package/storages/request_queue.js +23 -28
- package/storages/sitemap_request_loader.d.ts +2 -6
- package/storages/sitemap_request_loader.js +11 -10
- package/storages/throttling_request_manager.d.ts +45 -62
- package/storages/throttling_request_manager.js +226 -92
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { URL } from 'node:url';
|
|
2
2
|
import { getDomain } from 'tldts';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { PersistentRateLimitError } from '../errors.js';
|
|
5
4
|
import { asyncifyIterable } from '../iterables.js';
|
|
6
5
|
import { serviceLocator } from '../service_locator.js';
|
|
7
6
|
import { normalizeHostname } from '../url.js';
|
|
8
7
|
import { parseArgument, schemas } from '../validators.js';
|
|
9
8
|
import { drainRequestBatches } from './batched_adds.js';
|
|
10
9
|
import { KeyValueStore } from './key_value_store.js';
|
|
10
|
+
import { joinRequestSourceStatuses } from './request_loader.js';
|
|
11
11
|
import { RequestQueue } from './request_queue.js';
|
|
12
12
|
const throttlingRequestManagerOptionsSchema = z.strictObject({
|
|
13
|
-
inner: schemas.anyObject,
|
|
13
|
+
inner: z.union([schemas.anyObject, schemas.anyFunction]).optional(),
|
|
14
14
|
domains: z.union([schemas.arrayOf(z.string().nonempty(), 'non-empty strings'), z.literal('all')]),
|
|
15
15
|
requestManagerOpener: schemas.anyFunction.optional(),
|
|
16
16
|
baseDelaySecs: schemas.anyNumber.refine((value) => value > 0, 'Expected a number greater than 0').optional(),
|
|
@@ -23,13 +23,6 @@ const throttlingRequestManagerOptionsSchema = z.strictObject({
|
|
|
23
23
|
maxThrottledDomains: schemas.anyNumber.refine((value) => value > 0, 'Expected a number greater than 0').optional(),
|
|
24
24
|
persistStateKey: z.string().nonempty().optional(),
|
|
25
25
|
});
|
|
26
|
-
/** Whether `manager` can pace requests per domain. */
|
|
27
|
-
export function supportsDomainThrottling(manager) {
|
|
28
|
-
const candidate = manager;
|
|
29
|
-
return (typeof candidate?.recordDomainDelay === 'function' &&
|
|
30
|
-
typeof candidate.setCrawlDelay === 'function' &&
|
|
31
|
-
typeof candidate.assertNoStalledDomains === 'function');
|
|
32
|
-
}
|
|
33
26
|
/** The moment a domain may be dispatched to again - whichever of its two independent clocks runs longer. */
|
|
34
27
|
function throttledUntil(state) {
|
|
35
28
|
return Math.max(state.backoffUntil, state.crawlDelayUntil);
|
|
@@ -59,8 +52,9 @@ const DEFAULT_PERSIST_STATE_KEY = 'CRAWLEE_THROTTLED_DOMAINS';
|
|
|
59
52
|
*
|
|
60
53
|
* {@link ThrottlingRequestManager.fetchNextRequest|`fetchNextRequest()`} serves the domain that has been waiting
|
|
61
54
|
* longest and skips any that are backing off, falling back to the wrapped manager. It never blocks: while every
|
|
62
|
-
* remaining request belongs to a throttled domain it returns `null` and
|
|
63
|
-
* reports `
|
|
55
|
+
* remaining request belongs to a throttled domain it returns `null` and
|
|
56
|
+
* {@link ThrottlingRequestManager.checkReadiness|`checkReadiness()`} reports `waiting` with the moment the
|
|
57
|
+
* earliest of them comes due, so the crawler idles instead of holding a concurrency slot open.
|
|
64
58
|
*
|
|
65
59
|
* Each throttled domain runs two independent clocks, and may be dispatched to once **both** have run out:
|
|
66
60
|
* - **Backoff**, set by HTTP 429 responses - honouring `Retry-After`, and otherwise doubling from `baseDelaySecs`.
|
|
@@ -75,12 +69,19 @@ const DEFAULT_PERSIST_STATE_KEY = 'CRAWLEE_THROTTLED_DOMAINS';
|
|
|
75
69
|
* Which domains get those clocks is {@link ThrottlingRequestManagerOptions.domains|`domains`} - a list, or
|
|
76
70
|
* `'all'` for every domain the crawl encounters.
|
|
77
71
|
*
|
|
72
|
+
* Pass one as a crawler's `requestManager`; the `sameDomainDelaySecs` shorthand builds one with `domains: 'all'`
|
|
73
|
+
* and `throttleBy: 'registrableDomain'`. Construct it yourself to name the domains or tune the delays - one
|
|
74
|
+
* covering every domain also makes `sameDomainDelaySecs` land on it as a floor rather than adding a second pacer.
|
|
75
|
+
*
|
|
76
|
+
* Signals - 429s, robots.txt `Crawl-delay`, that floor - arrive through
|
|
77
|
+
* {@link IRequestManager.recordPacingSignal|`recordPacingSignal`}, which wrapping managers forward, so this
|
|
78
|
+
* works wherever it sits in a composition, including inside a {@link RequestManagerTandem}.
|
|
79
|
+
*
|
|
78
80
|
* **Example usage:**
|
|
79
81
|
*
|
|
80
82
|
* ```ts
|
|
81
83
|
* const crawler = new CheerioCrawler({
|
|
82
84
|
* requestManager: new ThrottlingRequestManager({
|
|
83
|
-
* inner: await RequestQueue.open(),
|
|
84
85
|
* domains: ['api.example.com', 'slow-site.org'],
|
|
85
86
|
* }),
|
|
86
87
|
* requestHandler: async ({ request }) => { ... },
|
|
@@ -91,7 +92,9 @@ const DEFAULT_PERSIST_STATE_KEY = 'CRAWLEE_THROTTLED_DOMAINS';
|
|
|
91
92
|
*/
|
|
92
93
|
export class ThrottlingRequestManager {
|
|
93
94
|
config;
|
|
94
|
-
#
|
|
95
|
+
#innerFactory;
|
|
96
|
+
#innerPromise;
|
|
97
|
+
#resolvedInner;
|
|
95
98
|
#requestManagerOpener;
|
|
96
99
|
#baseDelayMs;
|
|
97
100
|
#maxDelayMs;
|
|
@@ -128,8 +131,10 @@ export class ThrottlingRequestManager {
|
|
|
128
131
|
* restart sees an empty map, reports the crawl finished, and strands whatever the previous run left in them.
|
|
129
132
|
*/
|
|
130
133
|
#subManagersReady;
|
|
131
|
-
/** Batches still being added in the background; keeps {@link ThrottlingRequestManager.
|
|
134
|
+
/** Batches still being added in the background; keeps {@link ThrottlingRequestManager.checkReadiness} honest. */
|
|
132
135
|
#inProgressBatchCount = 0;
|
|
136
|
+
/** The latest {@link setExpectedRequestProcessingTimeSecs} hint, kept for a wrapped manager resolved later. */
|
|
137
|
+
#expectedRequestProcessingSecs;
|
|
133
138
|
#warnedAbout = new Set();
|
|
134
139
|
/** Whether any domain at all may end up throttled - listed up front, or discovered as the crawl runs. */
|
|
135
140
|
get #throttlingEnabled() {
|
|
@@ -138,7 +143,19 @@ export class ThrottlingRequestManager {
|
|
|
138
143
|
constructor(options, config = serviceLocator.getConfiguration()) {
|
|
139
144
|
this.config = config;
|
|
140
145
|
parseArgument(options, throttlingRequestManagerOptionsSchema, 'ThrottlingRequestManagerOptions');
|
|
141
|
-
|
|
146
|
+
if (options.inner === undefined) {
|
|
147
|
+
// Deferred like any other factory, so a manager nobody fetches from opens no storage. The opener is
|
|
148
|
+
// assigned below and read only when this runs.
|
|
149
|
+
this.#innerFactory = () => this.#requestManagerOpener(null, { configuration: this.config });
|
|
150
|
+
}
|
|
151
|
+
else if (typeof options.inner === 'function') {
|
|
152
|
+
this.#innerFactory = options.inner;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
// Nothing to open: resolved from the start, so `innerManager` and bookkeeping see it immediately.
|
|
156
|
+
this.#resolvedInner = options.inner;
|
|
157
|
+
this.#innerFactory = () => this.#resolvedInner;
|
|
158
|
+
}
|
|
142
159
|
this.#requestManagerOpener =
|
|
143
160
|
options.requestManagerOpener ??
|
|
144
161
|
((idOrAlias, opts) => RequestQueue.open(idOrAlias, opts));
|
|
@@ -180,9 +197,30 @@ export class ThrottlingRequestManager {
|
|
|
180
197
|
// so those stay paced per hostname.
|
|
181
198
|
return getDomain(normalized, { mixedInputs: false }) ?? normalized;
|
|
182
199
|
}
|
|
183
|
-
/**
|
|
200
|
+
/**
|
|
201
|
+
* The wrapped manager, holding every request whose domain is not throttled. `undefined` until an `inner`
|
|
202
|
+
* passed as a factory is resolved - reading this never forces it, because a getter should not open a queue
|
|
203
|
+
* behind a caller's back.
|
|
204
|
+
*/
|
|
184
205
|
get innerManager() {
|
|
185
|
-
return this.#
|
|
206
|
+
return this.#resolvedInner;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Resolves the wrapped manager, opening a factory `inner` on first use and memoizing it.
|
|
210
|
+
*
|
|
211
|
+
* Memoized for identity as much as for cost: {@link addRequestsBatched} groups by manager identity and
|
|
212
|
+
* {@link reclaimRequest} compares against it, so a fresh instance per call would split batches and reclaim
|
|
213
|
+
* requests into a manager that never handed them out.
|
|
214
|
+
*/
|
|
215
|
+
async #getInner() {
|
|
216
|
+
if (this.#resolvedInner === undefined) {
|
|
217
|
+
this.#innerPromise ??= Promise.resolve(this.#innerFactory());
|
|
218
|
+
this.#resolvedInner = await this.#innerPromise;
|
|
219
|
+
if (this.#expectedRequestProcessingSecs !== undefined) {
|
|
220
|
+
await this.#resolvedInner.setExpectedRequestProcessingTimeSecs?.(this.#expectedRequestProcessingSecs);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return this.#resolvedInner;
|
|
186
224
|
}
|
|
187
225
|
/** Warns once about sources that cannot be routed by domain, because their URLs are not known yet. */
|
|
188
226
|
#warnIfNotRoutable(requestLike) {
|
|
@@ -222,7 +260,7 @@ export class ThrottlingRequestManager {
|
|
|
222
260
|
await this.#ensureSubManagers();
|
|
223
261
|
const domain = this.#extractDomain(url);
|
|
224
262
|
if (!domain || !(this.#listedDomains.has(domain) || this.#throttlesEveryDomain)) {
|
|
225
|
-
return this.#
|
|
263
|
+
return this.#getInner();
|
|
226
264
|
}
|
|
227
265
|
if (!this.#listedDomains.has(domain) && !this.#discoveredDomains.has(domain)) {
|
|
228
266
|
if (this.#discoveredDomains.size >= this.#maxThrottledDomains) {
|
|
@@ -319,11 +357,77 @@ export class ThrottlingRequestManager {
|
|
|
319
357
|
.map((state) => state.domain);
|
|
320
358
|
}
|
|
321
359
|
/**
|
|
322
|
-
* Records a
|
|
360
|
+
* Records a pacing signal: a refusal puts the URL's domain into backoff, a declared interval becomes its
|
|
361
|
+
* crawl delay, and a crawl-wide floor raises {@link recordEverywhereFloor|the floor under all of them}.
|
|
323
362
|
*
|
|
324
|
-
* @returns `false` if the domain is not
|
|
363
|
+
* @returns `false` if the domain the signal covers is not throttled, in which case this is a no-op.
|
|
364
|
+
* @throws If the signal's scope is one this manager cannot honour - see {@link assertScopeHonourable}.
|
|
365
|
+
* @inheritdoc
|
|
325
366
|
*/
|
|
326
|
-
|
|
367
|
+
recordPacingSignal(signal) {
|
|
368
|
+
if (signal.reason === 'minIntervalEverywhere') {
|
|
369
|
+
return this.#recordEverywhereFloor(signal.intervalMs, signal.scope);
|
|
370
|
+
}
|
|
371
|
+
this.#assertScopeHonourable(signal.scope);
|
|
372
|
+
return signal.reason === 'rateLimited'
|
|
373
|
+
? this.#recordRateLimit(signal.url, signal.waitMs)
|
|
374
|
+
: this.#recordDeclaredInterval(signal.url, signal.intervalMs);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* The runtime form of {@link ThrottlingRequestManagerOptions.minCrawlDelaySecs|`minCrawlDelaySecs`}: raises
|
|
378
|
+
* the floor under every throttled domain's crawl delay.
|
|
379
|
+
*
|
|
380
|
+
* @returns `false` if this manager paces nothing at all, so there is no floor to raise.
|
|
381
|
+
* @throws If it paces some domains but not all - holding back only those would leave the rest of what the
|
|
382
|
+
* floor covers unpaced.
|
|
383
|
+
*/
|
|
384
|
+
#recordEverywhereFloor(intervalMs, scope) {
|
|
385
|
+
// Before the scope check: a manager that paces nothing has no grouping worth objecting about, and
|
|
386
|
+
// `false` lets the caller pace it from outside.
|
|
387
|
+
if (!this.#throttlingEnabled) {
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
this.#assertScopeHonourable(scope);
|
|
391
|
+
if (!this.#throttlesEveryDomain) {
|
|
392
|
+
throw new Error(`Cannot honour a crawl-delay floor covering every domain: this manager only paces the domains ` +
|
|
393
|
+
`it was given (${Array.from(this.#listedDomains).join(', ')}), so everything else would run ` +
|
|
394
|
+
`unpaced. Set \`domains: 'all'\` to pace whatever the crawl encounters, or declare the floor ` +
|
|
395
|
+
`on this manager yourself via \`minCrawlDelaySecs\`.`);
|
|
396
|
+
}
|
|
397
|
+
this.#minCrawlDelayMs = Math.max(this.#minCrawlDelayMs, intervalMs);
|
|
398
|
+
this.log.debug(`Crawl-delay floor for every domain set to ${(this.#minCrawlDelayMs / 1000).toFixed(1)}s`);
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Throws unless a signal scoped to `scope` can be honoured as declared or wider.
|
|
403
|
+
*
|
|
404
|
+
* Holding a domain back means holding its queue back, so
|
|
405
|
+
* {@link ThrottlingRequestManagerOptions.throttleBy|`throttleBy`} is the finest granularity this manager
|
|
406
|
+
* can express: a narrower scope is honoured by pacing the whole group, while a wider or unknown one must
|
|
407
|
+
* throw rather than under-apply, because pacing one group would leave the rest of what it covers unpaced.
|
|
408
|
+
*/
|
|
409
|
+
#assertScopeHonourable(scope) {
|
|
410
|
+
// No scope means the reporter cannot tell how far the signal reaches, so our own grouping is as good
|
|
411
|
+
// an answer as there is.
|
|
412
|
+
if (scope === undefined || scope === this.#throttleBy) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (scope === 'hostname' && this.#throttleBy === 'registrableDomain') {
|
|
416
|
+
// Only debug: grouping by registrable domain deliberately paces whole sites, subdomains included.
|
|
417
|
+
this.log.debug(`Applying a pacing signal scoped to "hostname" across the whole registrable domain, because that ` +
|
|
418
|
+
`is how this manager groups requests (\`throttleBy\`). Sibling subdomains are paced with it.`);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
throw new Error(`Cannot honour a pacing signal scoped to "${scope}": this manager groups requests by ` +
|
|
422
|
+
`"${this.#throttleBy}" and holds one request queue per group, so that is the widest scope it can ` +
|
|
423
|
+
`hold back at once. Applying the signal anyway would leave part of what it covers unpaced. ` +
|
|
424
|
+
(scope === 'registrableDomain'
|
|
425
|
+
? 'Set `throttleBy: "registrableDomain"` to group requests that way.'
|
|
426
|
+
: 'This manager only understands the scopes "hostname" and "registrableDomain"; pace by ' +
|
|
427
|
+
`"${scope}" with a request manager that groups requests by it.`));
|
|
428
|
+
}
|
|
429
|
+
/** Puts the URL's domain into backoff after a refusal, doubling from `baseDelaySecs` if it keeps refusing. */
|
|
430
|
+
#recordRateLimit(url, waitMs) {
|
|
327
431
|
const state = this.#getDomainState(url);
|
|
328
432
|
if (!state) {
|
|
329
433
|
return false;
|
|
@@ -348,10 +452,10 @@ export class ThrottlingRequestManager {
|
|
|
348
452
|
state.consecutive429Count = 0;
|
|
349
453
|
}
|
|
350
454
|
state.consecutive429Count += 1;
|
|
351
|
-
const
|
|
352
|
-
let delayMs =
|
|
455
|
+
const waitGiven = waitMs !== undefined;
|
|
456
|
+
let delayMs = waitGiven ? waitMs : this.#baseDelayMs * Math.pow(2, state.consecutive429Count - 1);
|
|
353
457
|
if (delayMs > this.#maxDelayMs) {
|
|
354
|
-
const source =
|
|
458
|
+
const source = waitGiven ? 'requested wait' : 'exponential backoff';
|
|
355
459
|
this.log.warning(`Capping ${source} delay of ${(delayMs / 1000).toFixed(1)}s for domain "${state.domain}" ` +
|
|
356
460
|
`to maxDelaySecs (${(this.#maxDelayMs / 1000).toFixed(1)}s); the domain may continue to rate-limit. ` +
|
|
357
461
|
`Consider increasing maxDelaySecs if this recurs.`);
|
|
@@ -364,14 +468,14 @@ export class ThrottlingRequestManager {
|
|
|
364
468
|
return true;
|
|
365
469
|
}
|
|
366
470
|
/**
|
|
367
|
-
* Records
|
|
471
|
+
* Records a declared minimum interval for a domain, which becomes its crawl delay unless
|
|
368
472
|
* {@link ThrottlingRequestManagerOptions.minCrawlDelaySecs|`minCrawlDelaySecs`} asks for longer.
|
|
369
473
|
*
|
|
370
474
|
* The first value wins, so a robots.txt re-fetch cannot change the cadence mid-crawl.
|
|
371
475
|
*
|
|
372
476
|
* @returns `false` if the domain is not throttled, in which case this is a no-op.
|
|
373
477
|
*/
|
|
374
|
-
|
|
478
|
+
#recordDeclaredInterval(url, intervalMs) {
|
|
375
479
|
const domain = this.#extractDomain(url);
|
|
376
480
|
if (!domain || !(this.#listedDomains.has(domain) || this.#throttlesEveryDomain)) {
|
|
377
481
|
return false;
|
|
@@ -380,45 +484,11 @@ export class ThrottlingRequestManager {
|
|
|
380
484
|
// the sub-queue it will end up pacing.
|
|
381
485
|
const state = this.#ensureDomainState(domain);
|
|
382
486
|
if (state.declaredCrawlDelayMs === null) {
|
|
383
|
-
state.declaredCrawlDelayMs =
|
|
384
|
-
this.log.debug(`Set crawl-delay for domain "${state.domain}" to ${
|
|
487
|
+
state.declaredCrawlDelayMs = intervalMs;
|
|
488
|
+
this.log.debug(`Set crawl-delay for domain "${state.domain}" to ${(intervalMs / 1000).toFixed(1)}s`);
|
|
385
489
|
}
|
|
386
490
|
return true;
|
|
387
491
|
}
|
|
388
|
-
/**
|
|
389
|
-
* Throws {@link PersistentRateLimitError} if any domain has been rate-limiting us past
|
|
390
|
-
* {@link ThrottlingRequestManagerOptions.maxDomainStallSecs|`maxDomainStallSecs`} without letting a single
|
|
391
|
-
* request through.
|
|
392
|
-
*
|
|
393
|
-
* A domain qualifies only while it still has queued requests and is actively rate-limiting - a domain that
|
|
394
|
-
* has simply run out of work is finished, not stalled, and one being waited out under a long robots.txt
|
|
395
|
-
* `Crawl-delay` is being obeyed, not stonewalled.
|
|
396
|
-
*/
|
|
397
|
-
async assertNoStalledDomains() {
|
|
398
|
-
await this.#ensureSubManagers();
|
|
399
|
-
const now = Date.now();
|
|
400
|
-
const candidates = Array.from(this.domainStates.values()).filter(
|
|
401
|
-
// Together: it is still turning us away, and has been doing so without a break for longer than the
|
|
402
|
-
// window. A domain that has simply been idle starts this clock at its first 429 rather than
|
|
403
|
-
// arriving with the idle time already on it.
|
|
404
|
-
(state) => state.rateLimitedSince !== 0 &&
|
|
405
|
-
now - state.lastRateLimitedAt <= this.#maxDomainStallMs &&
|
|
406
|
-
now - state.rateLimitedSince > this.#maxDomainStallMs);
|
|
407
|
-
const stalled = (await Promise.all(candidates.map(async (state) => {
|
|
408
|
-
const subManager = await this.#subManagers.get(state.domain);
|
|
409
|
-
return subManager && !(await subManager.isEmpty()) ? state : null;
|
|
410
|
-
}))).filter((state) => state !== null);
|
|
411
|
-
if (stalled.length === 0) {
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
const summary = stalled
|
|
415
|
-
.map((state) => `"${state.domain}" (${((now - state.rateLimitedSince) / 1000).toFixed(0)}s)`)
|
|
416
|
-
.join(', ');
|
|
417
|
-
throw new PersistentRateLimitError(`Giving up: ${summary} rate-limited every request for longer than maxDomainStallSecs ` +
|
|
418
|
-
`(${(this.#maxDomainStallMs / 1000).toFixed(0)}s). Waiting longer will not help - lower the ` +
|
|
419
|
-
`crawler's concurrency, or drop these domains. Their requests are still queued, so re-running ` +
|
|
420
|
-
`with \`purgeOnStart\` disabled will resume them if the rate limit lifts.`);
|
|
421
|
-
}
|
|
422
492
|
/** Records that a domain let a request through, which ends any rate-limit run stall detection was timing. */
|
|
423
493
|
#recordProgress(url) {
|
|
424
494
|
const state = this.#getDomainState(url);
|
|
@@ -511,8 +581,9 @@ export class ThrottlingRequestManager {
|
|
|
511
581
|
*/
|
|
512
582
|
async #managerHolding(request) {
|
|
513
583
|
const key = request.id ?? request.uniqueKey;
|
|
584
|
+
// Only a fetch from the wrapped manager puts a key here, so it is necessarily resolved already.
|
|
514
585
|
if (this.#inFlightFromInner.delete(key)) {
|
|
515
|
-
return this.#
|
|
586
|
+
return this.#resolvedInner;
|
|
516
587
|
}
|
|
517
588
|
return this.#selectManagerOrThrow(request.url);
|
|
518
589
|
}
|
|
@@ -526,39 +597,94 @@ export class ThrottlingRequestManager {
|
|
|
526
597
|
return this.#sumOverManagers((manager) => manager.getHandledCount());
|
|
527
598
|
}
|
|
528
599
|
/**
|
|
529
|
-
*
|
|
600
|
+
* Reports whether anything can be dispatched right now, and if not, when — or why never.
|
|
530
601
|
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
602
|
+
* One traversal of the domain clocks answers all of it: only domains whose delays have run out are probed,
|
|
603
|
+
* the rest merely contribute the moment they come due. Throttled requests count as outstanding work, so a
|
|
604
|
+
* crawler gated on this idles for the backoff instead of concluding it is done.
|
|
605
|
+
*
|
|
606
|
+
* `ready` from anywhere else outranks a stalling domain and is returned without looking at the stall clocks,
|
|
607
|
+
* so one hopeless domain never ends a crawl making progress elsewhere. It cannot outrank itself, though -
|
|
608
|
+
* see the traversal.
|
|
533
609
|
*/
|
|
534
|
-
async
|
|
610
|
+
async checkReadiness() {
|
|
535
611
|
await this.#ensureSubManagers();
|
|
536
|
-
const
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
612
|
+
const now = Date.now();
|
|
613
|
+
const dispatchable = [];
|
|
614
|
+
let readyAt;
|
|
615
|
+
let stallCandidates;
|
|
616
|
+
for (const state of this.domainStates.values()) {
|
|
617
|
+
// A `Crawl-delay` can give a domain a clock before its first request gives it a queue - nothing
|
|
618
|
+
// to fetch from and nothing to wait for until then.
|
|
619
|
+
if (!this.#subManagers.has(state.domain)) {
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (
|
|
623
|
+
// Together: still turning us away, and has been without a break for longer than the window - a
|
|
624
|
+
// domain that has merely been idle starts this clock at its first 429, not with idle time on it.
|
|
625
|
+
state.rateLimitedSince !== 0 &&
|
|
626
|
+
now - state.lastRateLimitedAt <= this.#maxDomainStallMs &&
|
|
627
|
+
now - state.rateLimitedSince > this.#maxDomainStallMs) {
|
|
628
|
+
// Deliberately not dispatchable: a domain that has refused every request for the whole window
|
|
629
|
+
// is not progress just because its backoff momentarily lapsed - that is what it does between 429s.
|
|
630
|
+
(stallCandidates ??= []).push(state);
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
const until = throttledUntil(state);
|
|
634
|
+
if (now >= until) {
|
|
635
|
+
dispatchable.push(this.#subManagers.get(state.domain));
|
|
636
|
+
}
|
|
637
|
+
else if (readyAt === undefined || until < readyAt) {
|
|
638
|
+
readyAt = until;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const probed = (await Promise.all([
|
|
642
|
+
(await this.#getInner()).checkReadiness(),
|
|
643
|
+
...dispatchable.map(async (subManager) => (await subManager).checkReadiness()),
|
|
644
|
+
])).reduce(joinRequestSourceStatuses);
|
|
645
|
+
// Anything dispatchable outranks a stalled or throttled domain, so we stop here without touching them.
|
|
646
|
+
if (probed.status === 'ready') {
|
|
647
|
+
return probed;
|
|
648
|
+
}
|
|
649
|
+
if (stallCandidates !== undefined) {
|
|
650
|
+
const stalled = (await Promise.all(stallCandidates.map(async (state) => (await (await this.#subManagers.get(state.domain)).checkReadiness()).status === 'ready'
|
|
651
|
+
? state
|
|
652
|
+
: null))).filter((state) => state !== null);
|
|
653
|
+
if (stalled.length > 0) {
|
|
654
|
+
const summary = stalled
|
|
655
|
+
.map((state) => `"${state.domain}" (${((now - state.rateLimitedSince) / 1000).toFixed(0)}s)`)
|
|
656
|
+
.join(', ');
|
|
657
|
+
return {
|
|
658
|
+
status: 'stalled',
|
|
659
|
+
reason: `${summary} rate-limited every request for longer than maxDomainStallSecs ` +
|
|
660
|
+
`(${(this.#maxDomainStallMs / 1000).toFixed(0)}s). Waiting longer will not help - lower the ` +
|
|
661
|
+
`crawler's concurrency, or drop these domains. Their requests are still queued, so ` +
|
|
662
|
+
`re-running with \`purgeOnStart\` disabled will resume them if the rate limit lifts.`,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
544
665
|
}
|
|
545
|
-
|
|
666
|
+
if (readyAt !== undefined) {
|
|
667
|
+
return joinRequestSourceStatuses(probed, { status: 'waiting', readyAt });
|
|
668
|
+
}
|
|
669
|
+
// Batches still landing in the background are work nobody can see in a queue yet.
|
|
670
|
+
if (this.#inProgressBatchCount > 0 && probed.status === 'finished') {
|
|
671
|
+
return { status: 'waiting' };
|
|
672
|
+
}
|
|
673
|
+
return probed;
|
|
546
674
|
}
|
|
547
675
|
/**
|
|
548
676
|
* Empties every manager and clears the accumulated backoff. A robots.txt `Crawl-delay` is a property of the
|
|
549
677
|
* site rather than of the run, so it survives.
|
|
550
678
|
*/
|
|
551
679
|
async purge() {
|
|
552
|
-
await this.#
|
|
553
|
-
await this
|
|
680
|
+
await this.#purgeDomainQueues();
|
|
681
|
+
await this.#resolvedInner?.purge?.();
|
|
554
682
|
}
|
|
555
683
|
/**
|
|
556
|
-
* Empties the per-domain queues, leaving the wrapped manager alone
|
|
557
|
-
*
|
|
558
|
-
* Those queues are this manager's own no matter who owns the one it wraps, which is what makes this safe to
|
|
559
|
-
* call where a full {@link ThrottlingRequestManager.purge|`purge()`} would not be.
|
|
684
|
+
* Empties the per-domain queues, leaving the wrapped manager alone - those queues are this manager's own no
|
|
685
|
+
* matter who owns the one it wraps.
|
|
560
686
|
*/
|
|
561
|
-
async purgeDomainQueues() {
|
|
687
|
+
async #purgeDomainQueues() {
|
|
562
688
|
const subManagers = await this.#getSubManagers();
|
|
563
689
|
await Promise.all(subManagers.map(async (manager) => manager.purge?.()));
|
|
564
690
|
for (const state of this.domainStates.values()) {
|
|
@@ -571,27 +697,35 @@ export class ThrottlingRequestManager {
|
|
|
571
697
|
}
|
|
572
698
|
}
|
|
573
699
|
async setExpectedRequestProcessingTimeSecs(secs) {
|
|
700
|
+
// Remembered so a manager opened later still gets the hint, without opening one now just to pass it along.
|
|
701
|
+
this.#expectedRequestProcessingSecs = secs;
|
|
574
702
|
await this.#forEachManager((manager) => manager.setExpectedRequestProcessingTimeSecs?.(secs));
|
|
575
703
|
}
|
|
704
|
+
/**
|
|
705
|
+
* Runs `fn` over the sub-queues and, if it has been resolved, the wrapped manager - bookkeeping never forces
|
|
706
|
+
* a lazily-opened `inner`, since there is no point opening a queue purely to tell it something.
|
|
707
|
+
*/
|
|
576
708
|
async #forEachManager(fn) {
|
|
709
|
+
const managers = await this.#getSubManagers();
|
|
710
|
+
if (this.#resolvedInner !== undefined) {
|
|
711
|
+
managers.push(this.#resolvedInner);
|
|
712
|
+
}
|
|
577
713
|
// `fn` targets optional members, so it may return nothing - the wrapper normalizes that for `Promise.all`.
|
|
578
|
-
await Promise.all(
|
|
714
|
+
await Promise.all(managers.map(async (manager) => fn(manager)));
|
|
579
715
|
}
|
|
580
716
|
async #sumOverManagers(fn) {
|
|
581
|
-
|
|
717
|
+
// Counts have to include the wrapped manager, so this one does resolve it.
|
|
718
|
+
const counts = await Promise.all([await this.#getInner(), ...(await this.#getSubManagers())].map(fn));
|
|
582
719
|
return counts.reduce((a, b) => a + b, 0);
|
|
583
720
|
}
|
|
584
|
-
async #everyManager(fn) {
|
|
585
|
-
const results = await Promise.all([this.#inner, ...(await this.#getSubManagers())].map(fn));
|
|
586
|
-
return results.every(Boolean);
|
|
587
|
-
}
|
|
588
721
|
/**
|
|
589
722
|
* Returns the next request from a domain that is not backing off, or from the inner manager.
|
|
590
723
|
*
|
|
591
724
|
* Returns `null` while every remaining request belongs to a throttled domain - it never waits the backoff
|
|
592
725
|
* out, because a consumer parked in here holds a concurrency slot, which the autoscaler reads as spare
|
|
593
|
-
* capacity and answers by scaling up. Callers poll instead, and
|
|
594
|
-
* reports `
|
|
726
|
+
* capacity and answers by scaling up. Callers poll instead, and
|
|
727
|
+
* {@link ThrottlingRequestManager.checkReadiness|`checkReadiness()`} reports `waiting` meanwhile so the
|
|
728
|
+
* crawler's task loop idles rather than spins.
|
|
595
729
|
*/
|
|
596
730
|
async fetchNextRequest() {
|
|
597
731
|
await this.#ensureSubManagers();
|
|
@@ -612,7 +746,7 @@ export class ThrottlingRequestManager {
|
|
|
612
746
|
// No dispatch to pace, so the domain keeps its slot.
|
|
613
747
|
state.crawlDelayUntil = crawlDelayUntilBefore;
|
|
614
748
|
}
|
|
615
|
-
const request = await this.#
|
|
749
|
+
const request = await (await this.#getInner()).fetchNextRequest();
|
|
616
750
|
if (request !== null) {
|
|
617
751
|
this.#inFlightFromInner.add(request.id ?? request.uniqueKey);
|
|
618
752
|
}
|