@crawlee/core 4.0.0-beta.88 → 4.0.0-beta.89

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.
@@ -3,28 +3,21 @@ import { addTimeoutToPromise } from '@apify/timeout';
3
3
  import { betterClearInterval, betterSetInterval } from '@apify/utilities';
4
4
  import { CriticalError } from '../errors.js';
5
5
  import { serviceLocator } from '../service_locator.js';
6
- import { Snapshotter } from './snapshotter.js';
7
- import { SystemStatus } from './system_status.js';
8
6
  /**
9
7
  * Manages a pool of asynchronous resource-intensive tasks that are executed in parallel.
10
- * The pool only starts new tasks if there is enough free CPU and memory available
11
- * and the Javascript event loop is not blocked.
12
- *
13
- * The information about the CPU and memory usage is obtained by the {@link Snapshotter} class,
14
- * which makes regular snapshots of system resources that may be either local
15
- * or from the Apify cloud infrastructure in case the process is running on the Apify platform.
16
- * Meaningful data gathered from these snapshots is provided to `AutoscaledPool` by the {@link SystemStatus} class.
8
+ * The pool only starts new tasks while its {@link IConcurrencySystem|concurrency system} reports free capacity
9
+ * that governor is what monitors CPU, memory and event loop load and autoscales the concurrency budget.
17
10
  *
18
11
  * Before running the pool, you need to implement the following three functions:
19
- * {@link AutoscaledPoolOptions.runTaskFunction},
20
- * {@link AutoscaledPoolOptions.isTaskReadyFunction} and
21
- * {@link AutoscaledPoolOptions.isFinishedFunction}.
12
+ * {@link AutoscaledPoolOptions.runTaskFunction|`runTaskFunction`},
13
+ * {@link AutoscaledPoolPredicateOptions.isTaskReadyFunction|`isTaskReadyFunction`} and
14
+ * {@link AutoscaledPoolPredicateOptions.isFinishedFunction|`isFinishedFunction`}.
22
15
  *
23
16
  * The auto-scaled pool is started by calling the {@link AutoscaledPool.run} function.
24
- * The pool periodically queries the {@link AutoscaledPoolOptions.isTaskReadyFunction} function
25
- * for more tasks, managing optimal concurrency, until the function resolves to `false`. The pool then queries
26
- * the {@link AutoscaledPoolOptions.isFinishedFunction}. If it resolves to `true`, the run finishes after all running tasks complete.
27
- * If it resolves to `false`, it assumes there will be more tasks available later and keeps periodically querying for tasks.
17
+ * The pool periodically queries `isTaskReadyFunction` for more tasks, managing optimal concurrency, until the function
18
+ * resolves to `false`. The pool then queries `isFinishedFunction`. If it resolves to `true`, the run finishes after all
19
+ * running tasks complete. If it resolves to `false`, it assumes there will be more tasks available later and keeps
20
+ * periodically querying for tasks.
28
21
  * If any of the tasks throws then the {@link AutoscaledPool.run} function rejects the promise with an error.
29
22
  *
30
23
  * The pool evaluates whether it should start a new task every time one of the tasks finishes
@@ -33,8 +26,12 @@ import { SystemStatus } from './system_status.js';
33
26
  * **Example usage:**
34
27
  *
35
28
  * ```javascript
29
+ * const concurrencySystem = new ConcurrencySystem({ maxConcurrency: 50 });
30
+ * await concurrencySystem.start();
31
+ *
36
32
  * const pool = new AutoscaledPool({
37
- * maxConcurrency: 50,
33
+ * concurrencySystem,
34
+ * consumer: { id: 'my-pool' },
38
35
  * runTaskFunction: async () => {
39
36
  * // Run some resource-intensive asynchronous operation here.
40
37
  * },
@@ -49,169 +46,108 @@ import { SystemStatus } from './system_status.js';
49
46
  * }
50
47
  * });
51
48
  *
52
- * await pool.run();
49
+ * try {
50
+ * await pool.run();
51
+ * } finally {
52
+ * await concurrencySystem.stop();
53
+ * }
53
54
  * ```
54
55
  * @category Scaling
55
56
  */
56
57
  export class AutoscaledPool {
57
58
  log;
58
59
  // Configurable properties.
59
- desiredConcurrencyRatio;
60
- scaleUpStepRatio;
61
- scaleDownStepRatio;
62
60
  maybeRunIntervalMillis;
63
- loggingIntervalMillis;
64
- autoscaleIntervalMillis;
65
61
  taskTimeoutMillis;
66
62
  runTaskFunction;
67
63
  isFinishedFunction;
68
64
  isTaskReadyFunction;
69
- maxTasksPerMinute;
65
+ concurrencySystem;
66
+ consumer;
70
67
  // Internal properties.
71
- _minConcurrency;
72
- _maxConcurrency;
73
- _desiredConcurrency;
74
- _currentConcurrency = 0;
75
68
  isStopped = false;
76
- lastLoggingTime;
77
69
  resolve = null;
78
70
  reject = null;
79
- snapshotter;
80
- /** Additional SystemStatus loadSignals - tracked here for initialization and cleanup */
81
- loadSignals;
82
- systemStatus;
83
- autoscaleInterval;
84
71
  maybeRunInterval;
85
72
  queryingIsTaskReady;
86
73
  queryingIsFinished;
87
- tasksDonePerSecondInterval;
88
- _tasksPerMinute = Array.from({ length: 60 }, () => 0);
74
+ /**
75
+ * This pool's *own* in-flight task count, as opposed to {@link AutoscaledPool.currentConcurrency}, which is the
76
+ * (possibly shared) governor's total. `pause()` and `maybeFinish()` care only about this pool draining.
77
+ */
78
+ ownConcurrency = 0;
89
79
  constructor(options) {
90
80
  ow(options, ow.object.exactShape({
91
81
  runTaskFunction: ow.function,
92
82
  isFinishedFunction: ow.function,
93
83
  isTaskReadyFunction: ow.function,
94
- maxConcurrency: ow.optional.number.integer.greaterThanOrEqual(1),
95
- minConcurrency: ow.optional.number.integer.greaterThanOrEqual(1),
96
- desiredConcurrency: ow.optional.number.integer.greaterThanOrEqual(1),
97
- desiredConcurrencyRatio: ow.optional.number.greaterThan(0).lessThan(1),
98
- scaleUpStepRatio: ow.optional.number.greaterThan(0).lessThan(1),
99
- scaleDownStepRatio: ow.optional.number.greaterThan(0).lessThan(1),
100
84
  maybeRunIntervalSecs: ow.optional.number.greaterThan(0),
101
- loggingIntervalSecs: ow.any(ow.number.greaterThan(0), ow.nullOrUndefined),
102
- autoscaleIntervalSecs: ow.optional.number.greaterThan(0),
103
85
  taskTimeoutSecs: ow.optional.number.greaterThanOrEqual(0),
104
- systemStatusOptions: ow.optional.object,
105
- snapshotterOptions: ow.optional.object,
106
86
  log: ow.optional.object,
107
- maxTasksPerMinute: ow.optional.number.integerOrInfinite.greaterThanOrEqual(1),
87
+ concurrencySystem: ow.object,
88
+ consumer: ow.object.partialShape({ id: ow.string.nonEmpty }),
108
89
  }));
109
- const { runTaskFunction, isFinishedFunction, isTaskReadyFunction, maxConcurrency = 200, minConcurrency = 1, desiredConcurrency, desiredConcurrencyRatio = 0.9, scaleUpStepRatio = 0.05, scaleDownStepRatio = 0.05, maybeRunIntervalSecs = 0.5, loggingIntervalSecs = 60, taskTimeoutSecs = 0, autoscaleIntervalSecs = 10, systemStatusOptions, snapshotterOptions, log = serviceLocator.getLogger(), maxTasksPerMinute = Infinity, } = options;
90
+ const { runTaskFunction, isFinishedFunction, isTaskReadyFunction, maybeRunIntervalSecs = 0.5, taskTimeoutSecs = 0, log = serviceLocator.getLogger(), concurrencySystem, consumer, } = options;
110
91
  this.log = log.child({ prefix: 'AutoscaledPool' });
111
92
  // Configurable properties.
112
- this.desiredConcurrencyRatio = desiredConcurrencyRatio;
113
- this.scaleUpStepRatio = scaleUpStepRatio;
114
- this.scaleDownStepRatio = scaleDownStepRatio;
115
93
  this.maybeRunIntervalMillis = maybeRunIntervalSecs * 1000;
116
- this.loggingIntervalMillis = loggingIntervalSecs * 1000;
117
- this.autoscaleIntervalMillis = autoscaleIntervalSecs * 1000;
118
94
  this.taskTimeoutMillis = taskTimeoutSecs * 1000;
119
95
  this.runTaskFunction = runTaskFunction;
120
96
  this.isFinishedFunction = isFinishedFunction;
121
97
  this.isTaskReadyFunction = isTaskReadyFunction;
122
- this.maxTasksPerMinute = maxTasksPerMinute;
98
+ this.concurrencySystem = concurrencySystem;
99
+ this.consumer = consumer;
123
100
  // Internal properties.
124
- this._minConcurrency = minConcurrency;
125
- this._maxConcurrency = maxConcurrency;
126
- this._desiredConcurrency = Math.min(desiredConcurrency ?? minConcurrency, maxConcurrency);
127
- this._currentConcurrency = 0;
128
101
  this.isStopped = false;
129
102
  this.resolve = null;
130
103
  this.reject = null;
131
- this.autoscale = this.autoscale.bind(this);
132
104
  this.maybeRunTask = this.maybeRunTask.bind(this);
133
- this.incrementTasksDonePerSecond = this.incrementTasksDonePerSecond.bind(this);
134
- // Create instances with correct options.
135
- const ssoCopy = { ...systemStatusOptions };
136
- ssoCopy.snapshotter ??= new Snapshotter({
137
- ...snapshotterOptions,
138
- log: this.log,
139
- });
140
- this.snapshotter = ssoCopy.snapshotter;
141
- this.loadSignals = ssoCopy.loadSignals ?? [];
142
- this.systemStatus = new SystemStatus(ssoCopy);
143
105
  }
144
106
  /**
145
- * Gets the minimum number of tasks running in parallel.
146
- */
147
- get minConcurrency() {
148
- return this._minConcurrency;
149
- }
150
- /**
151
- * Sets the minimum number of tasks running in parallel.
107
+ * The governor backing this pool, as supplied to the constructor exposed as the read-only
108
+ * {@link IConcurrencySystem} contract.
152
109
  *
153
- * *WARNING:* If you set this value too high with respect to the available system memory and CPU, your code might run extremely slow or crash.
154
- * If you're not sure, just keep the default value and the concurrency will scale up automatically.
155
- */
156
- set minConcurrency(value) {
157
- ow(value, ow.optional.number.integer.greaterThanOrEqual(1));
158
- this._minConcurrency = value;
159
- }
160
- /**
161
- * Gets the maximum number of tasks running in parallel.
110
+ * This and the two getters below are telemetry only: concurrency is configured and tuned on the concrete
111
+ * {@link ConcurrencySystem} its owner holds, never through the pool.
162
112
  */
163
- get maxConcurrency() {
164
- return this._maxConcurrency;
113
+ get system() {
114
+ return this.concurrencySystem;
165
115
  }
166
- /**
167
- * Sets the maximum number of tasks running in parallel.
168
- */
169
- set maxConcurrency(value) {
170
- ow(value, ow.optional.number.integer.greaterThanOrEqual(1));
171
- this._maxConcurrency = value;
172
- }
173
- /**
174
- * Gets the desired concurrency for the pool,
175
- * which is an estimated number of parallel tasks that the system can currently support.
176
- */
116
+ /** The estimated number of parallel tasks the governor can currently support. */
177
117
  get desiredConcurrency() {
178
- return this._desiredConcurrency;
179
- }
180
- /**
181
- * Sets the desired concurrency for the pool, i.e. the number of tasks that should be running
182
- * in parallel if there's large enough supply of tasks.
183
- */
184
- set desiredConcurrency(value) {
185
- ow(value, ow.optional.number.integer.greaterThanOrEqual(1));
186
- this._desiredConcurrency = value;
118
+ return this.concurrencySystem.desiredConcurrency;
187
119
  }
188
120
  /**
189
- * Gets the number of parallel tasks currently running in the pool.
121
+ * The number of parallel tasks currently booked against the governor. When it is shared, this counts every
122
+ * borrowing pool's tasks, not just this one's.
190
123
  */
191
124
  get currentConcurrency() {
192
- return this._currentConcurrency;
125
+ return this.concurrencySystem.currentConcurrency;
193
126
  }
194
127
  /**
195
128
  * Runs the auto-scaled pool. Returns a promise that gets resolved or rejected once
196
129
  * all the tasks are finished or one of them fails.
130
+ *
131
+ * Throws if the {@link IConcurrencySystem|concurrency system} it borrows was never started — the pool assumes
132
+ * a running governor and cannot start one it does not own.
197
133
  */
198
134
  async run() {
135
+ // Checked here, on an awaited path — the capacity queries inside the task loop run from intervals and
136
+ // `setImmediate`, where a throw would become an unhandled rejection and hang `run()` forever.
137
+ if (!this.concurrencySystem.isRunning) {
138
+ throw new CriticalError('The ConcurrencySystem this AutoscaledPool borrows has not been started, so system load would not be ' +
139
+ 'monitored and the concurrency would never be adjusted. Whoever creates a ConcurrencySystem owns ' +
140
+ 'its lifecycle: call `await concurrencySystem.start()` before running the pools or crawlers that ' +
141
+ 'use it, and `await concurrencySystem.stop()` once they are all done.');
142
+ }
199
143
  const poolPromise = new Promise((resolve, reject) => {
200
144
  this.resolve = resolve;
201
145
  this.reject = reject;
202
146
  });
203
- await this.snapshotter.start();
204
- await Promise.all(this.loadSignals.map((s) => s.start()));
205
- // This interval checks the system status and updates the desired concurrency accordingly.
206
- this.autoscaleInterval = betterSetInterval(this.autoscale, this.autoscaleIntervalMillis);
207
147
  // This is here because if we scale down to let's say 1, then after each promise is finished
208
148
  // this.maybeRunTask() doesn't trigger another one. So if that 1 instance gets stuck it results
209
149
  // in the crawler getting stuck and even after scaling up it never triggers another promise.
210
150
  this.maybeRunInterval = betterSetInterval(this.maybeRunTask, this.maybeRunIntervalMillis);
211
- if (this.maxTasksPerMinute !== Infinity) {
212
- // Start the interval that resets the counter of tasks per minute.
213
- this.tasksDonePerSecondInterval = betterSetInterval(this.incrementTasksDonePerSecond, 1000);
214
- }
215
151
  try {
216
152
  await poolPromise;
217
153
  }
@@ -249,6 +185,10 @@ export class AutoscaledPool {
249
185
  *
250
186
  * The promise returned from the {@link AutoscaledPool.run} function will not resolve
251
187
  * when `.pause()` is invoked (unlike abort, which resolves it).
188
+ *
189
+ * > *NOTE:* Pausing the pool does not suspend the (possibly shared) {@link ConcurrencySystem} — its
190
+ * autoscaling and resource monitoring keep running, since other pools borrowing it may still be active. To silence
191
+ * it during a long pause, its owner can `stop()` and `start()` it again.
252
192
  */
253
193
  async pause(timeoutSecs) {
254
194
  if (this.isStopped)
@@ -267,7 +207,7 @@ export class AutoscaledPool {
267
207
  }, timeoutSecs);
268
208
  }
269
209
  interval = setInterval(() => {
270
- if (this._currentConcurrency <= 0) {
210
+ if (this.ownConcurrency <= 0) {
271
211
  // Clean up timeout and interval to prevent process hanging.
272
212
  if (timeout)
273
213
  clearTimeout(timeout);
@@ -316,17 +256,14 @@ export class AutoscaledPool {
316
256
  this.log.perf('Task will not run. Waiting for a ready task.');
317
257
  return done();
318
258
  }
319
- // - we would exceed desired concurrency.
320
- if (this._currentConcurrency >= this._desiredConcurrency) {
321
- this.log.perf('Task will not run. Desired concurrency achieved.');
322
- return done();
323
- }
324
- // - system is overloaded now and we are at or above minConcurrency
325
- const currentStatus = this.systemStatus.getCurrentStatus();
326
- const { isSystemIdle } = currentStatus;
327
- if (!isSystemIdle && this._currentConcurrency >= this._minConcurrency) {
328
- this.log.perf('Task will not be run. System is overloaded.', currentStatus);
329
- return done();
259
+ // - the budget has room for us.
260
+ if (!this.concurrencySystem.hasCapacityForTask(this.consumer)) {
261
+ done();
262
+ // A shared governor's budget can stay saturated by another pool indefinitely, so we still have to be able
263
+ // to notice that *this* pool has run out of work — `maybeFinish()` is the only thing that ever resolves
264
+ // `run()`. It no-ops while this pool has tasks of its own in flight, which is every case in which an
265
+ // unshared governor reports no capacity.
266
+ return this.maybeFinish();
330
267
  }
331
268
  // - a task is ready.
332
269
  this.queryingIsTaskReady = true;
@@ -354,17 +291,14 @@ export class AutoscaledPool {
354
291
  // No tasks could mean that we're finished with all tasks.
355
292
  return this.maybeFinish();
356
293
  }
357
- // - we have already reached the maximum tasks per minute
358
- // we need to check this *after* checking if a task is ready to prevent hanging the pool
359
- // for an extra minute if there are no more tasks
360
- if (this.isOverMaxRequestLimit) {
361
- this.log.perf('Task will not run. Maximum tasks per minute reached.');
294
+ // - the budget still has room. Re-checked atomically, because another pool sharing the governor may have taken
295
+ // the last free slot while we awaited `isTaskReadyFunction` above.
296
+ if (!this.concurrencySystem.tryRegisterTaskStart(this.consumer)) {
362
297
  return done();
363
298
  }
299
+ this.ownConcurrency++;
364
300
  try {
365
301
  // Everything's fine. Run task.
366
- this._currentConcurrency++;
367
- this._tasksPerMinute[0]++;
368
302
  // Try to run next task to build up concurrency,
369
303
  // but defer it so it doesn't create a cycle.
370
304
  setImmediate(this.maybeRunTask);
@@ -379,8 +313,8 @@ export class AutoscaledPool {
379
313
  await this.runTaskFunction();
380
314
  }
381
315
  this.log.perf('Task finished.');
382
- this._currentConcurrency--;
383
- // Run task after the previous one finished.
316
+ // Run task after the previous one finished. Only on success: a failed task rejects the pool, and
317
+ // nudging the loop afterwards could start work on an already destroyed pool.
384
318
  setImmediate(this.maybeRunTask);
385
319
  }
386
320
  catch (e) {
@@ -397,85 +331,11 @@ export class AutoscaledPool {
397
331
  this.reject(err);
398
332
  }
399
333
  }
400
- return undefined;
401
- }
402
- /**
403
- * Gets called every autoScaleIntervalSecs and evaluates the current system status.
404
- * If the system IS NOT overloaded and the settings allow it, it scales up.
405
- * If the system IS overloaded and the settings allow it, it scales down.
406
- */
407
- autoscale(intervalCallback) {
408
- // Don't scale if paused.
409
- if (this.isStopped)
410
- return intervalCallback();
411
- // Don't scale if we've hit the maximum requests per minute
412
- if (this.isOverMaxRequestLimit)
413
- return intervalCallback();
414
- // Only scale up if:
415
- // - system has not been overloaded lately.
416
- const systemStatus = this.systemStatus.getHistoricalStatus();
417
- const { isSystemIdle } = systemStatus;
418
- // - we're not already at max concurrency.
419
- const weAreNotAtMax = this._desiredConcurrency < this._maxConcurrency;
420
- // - current concurrency reaches at least the given ratio of desired concurrency.
421
- const minCurrentConcurrency = Math.floor(this._desiredConcurrency * this.desiredConcurrencyRatio);
422
- const weAreReachingDesiredConcurrency = this._currentConcurrency >= minCurrentConcurrency;
423
- if (isSystemIdle && weAreNotAtMax && weAreReachingDesiredConcurrency)
424
- this.scaleUp(systemStatus);
425
- // Always scale down if:
426
- // - the system has been overloaded lately.
427
- const isSystemOverloaded = !isSystemIdle;
428
- // - we're over min concurrency.
429
- const weAreNotAtMin = this._desiredConcurrency > this._minConcurrency;
430
- if (isSystemOverloaded && weAreNotAtMin)
431
- this.scaleDown(systemStatus);
432
- // On periodic intervals, print comprehensive log information
433
- if (this.loggingIntervalMillis > 0) {
434
- const now = Date.now();
435
- if (this.lastLoggingTime == null) {
436
- this.lastLoggingTime = now;
437
- }
438
- else if (now > this.lastLoggingTime + this.loggingIntervalMillis) {
439
- this.lastLoggingTime = now;
440
- this.log.info('state', {
441
- currentConcurrency: this._currentConcurrency,
442
- desiredConcurrency: this._desiredConcurrency,
443
- systemStatus,
444
- });
445
- }
334
+ finally {
335
+ this.concurrencySystem.registerTaskEnd(this.consumer);
336
+ this.ownConcurrency--;
446
337
  }
447
- // Start a new interval cycle.
448
- return intervalCallback();
449
- }
450
- /**
451
- * Scales the pool up by increasing
452
- * the desired concurrency by the scaleUpStepRatio.
453
- *
454
- * @param systemStatus for logging
455
- */
456
- scaleUp(systemStatus) {
457
- const step = Math.ceil(this._desiredConcurrency * this.scaleUpStepRatio);
458
- this._desiredConcurrency = Math.min(this._maxConcurrency, this._desiredConcurrency + step);
459
- this.log.debug('scaling up', {
460
- oldConcurrency: this._desiredConcurrency - step,
461
- newConcurrency: this._desiredConcurrency,
462
- systemStatus,
463
- });
464
- }
465
- /**
466
- * Scales the pool down by decreasing
467
- * the desired concurrency by the scaleDownStepRatio.
468
- *
469
- * @param systemStatus for logging
470
- */
471
- scaleDown(systemStatus) {
472
- const step = Math.ceil(this._desiredConcurrency * this.scaleDownStepRatio);
473
- this._desiredConcurrency = Math.max(this._minConcurrency, this._desiredConcurrency - step);
474
- this.log.debug('scaling down', {
475
- oldConcurrency: this._desiredConcurrency + step,
476
- newConcurrency: this._desiredConcurrency,
477
- systemStatus,
478
- });
338
+ return undefined;
479
339
  }
480
340
  /**
481
341
  * If there are no running tasks and this.isFinishedFunction() returns true then closes
@@ -486,7 +346,7 @@ export class AutoscaledPool {
486
346
  async maybeFinish() {
487
347
  if (this.queryingIsFinished)
488
348
  return;
489
- if (this._currentConcurrency > 0)
349
+ if (this.ownConcurrency > 0)
490
350
  return;
491
351
  this.queryingIsFinished = true;
492
352
  try {
@@ -512,23 +372,6 @@ export class AutoscaledPool {
512
372
  async destroy() {
513
373
  this.resolve = null;
514
374
  this.reject = null;
515
- betterClearInterval(this.autoscaleInterval);
516
375
  betterClearInterval(this.maybeRunInterval);
517
- if (this.tasksDonePerSecondInterval)
518
- betterClearInterval(this.tasksDonePerSecondInterval);
519
- if (this.snapshotter)
520
- await this.snapshotter.stop();
521
- await Promise.all(this.loadSignals.map((s) => s.stop()));
522
- }
523
- incrementTasksDonePerSecond(intervalCallback) {
524
- this._tasksPerMinute.unshift(0);
525
- this._tasksPerMinute.pop();
526
- return intervalCallback();
527
- }
528
- get isOverMaxRequestLimit() {
529
- if (this.maxTasksPerMinute === Infinity) {
530
- return false;
531
- }
532
- return this._tasksPerMinute.reduce((acc, curr) => acc + curr, 0) >= this.maxTasksPerMinute;
533
376
  }
534
377
  }
@@ -1,25 +1,59 @@
1
- import type { StorageBackend } from '@crawlee/types';
2
- import type { LoadSnapshot } from './load_signal.js';
3
- import { SnapshotStore } from './load_signal.js';
1
+ import type { LoadSignal, LoadSignalStartContext, LoadSnapshot } from './load_signal.js';
2
+ /**
3
+ * A snapshot produced by the built-in client (rate-limit) signal.
4
+ * @internal
5
+ */
4
6
  export interface ClientSnapshot extends LoadSnapshot {
5
7
  rateLimitErrorCount: number;
6
8
  }
9
+ /**
10
+ * Tuning for the built-in **client** (rate-limit) load signal, as accepted both by {@link ClientLoadSignal} and by
11
+ * the {@link LoadSignalsOptions.client|`client`} shorthand on {@link LoadSignalsOptions}.
12
+ */
7
13
  export interface ClientLoadSignalOptions {
8
- client: StorageBackend;
9
- clientSnapshotIntervalSecs?: number;
10
- maxClientErrors?: number;
14
+ /**
15
+ * Defines the interval of checking the current state of the remote API client, in seconds.
16
+ * @default 1
17
+ */
18
+ snapshotIntervalSecs?: number;
19
+ /**
20
+ * Defines the maximum number of new rate limit errors within the given interval.
21
+ * @default 3
22
+ */
23
+ maxErrors?: number;
24
+ /**
25
+ * Maximum ratio of overloaded snapshots in a sample before the client counts as overloaded.
26
+ * @default 0.3
27
+ */
11
28
  overloadedRatio?: number;
12
- snapshotHistoryMillis?: number;
13
29
  }
14
30
  /**
15
- * Periodically checks the storage client for rate-limit errors (HTTP 429)
16
- * and reports overload when the error delta exceeds a threshold.
31
+ * Periodically checks the storage backend for rate-limit errors (HTTP 429) and reports overload when the error delta
32
+ * exceeds a threshold.
33
+ *
34
+ * Built by default; construct one yourself only to wrap or adapt it — see {@link LoadSignal}.
35
+ *
36
+ * Switch it off entirely ({@link LoadSignalsOptions.client|`client: false`}) if the storage backend reports no
37
+ * rate-limit statistics, since it otherwise polls it every second to no purpose.
38
+ *
39
+ * @category Scaling
17
40
  */
18
- // @ts-ignore optional peer dependency or compatibility with es2022
19
- export declare function createClientLoadSignal(options: ClientLoadSignalOptions): Omit<import("./load_signal.js").LoadSignal, "getSample"> & {
20
- store: SnapshotStore<ClientSnapshot>;
21
- handle: (cb: () => unknown) => void;
22
- getSample(sampleDurationMillis?: number): ClientSnapshot[];
23
- };
24
- /** @internal Return type for backward compat in Snapshotter facade */
25
- export type ClientLoadSignal = ReturnType<typeof createClientLoadSignal>;
41
+ export declare class ClientLoadSignal implements LoadSignal {
42
+ readonly name = "clientInfo";
43
+ readonly overloadedRatio: number;
44
+ private readonly store;
45
+ private readonly intervalMillis;
46
+ private readonly maxErrors;
47
+ private interval?;
48
+ private client?;
49
+ constructor(options?: ClientLoadSignalOptions);
50
+ start(context: LoadSignalStartContext): Promise<void>;
51
+ stop(): Promise<void>;
52
+ getSample(sampleDurationMillis?: number): LoadSnapshot[];
53
+ /**
54
+ * Records one snapshot, overloaded when rate-limit errors grew by more than the configured limit since the
55
+ * previous one.
56
+ * @internal Also lets tests drive the measurement without waiting on a timer.
57
+ */
58
+ handle(intervalCallback: () => unknown): void;
59
+ }
@@ -1,36 +1,73 @@
1
+ import { betterClearInterval, betterSetInterval } from '@apify/utilities';
2
+ import { serviceLocator } from '../service_locator.js';
1
3
  import { SnapshotStore } from './load_signal.js';
2
4
  const CLIENT_RATE_LIMIT_ERROR_RETRY_COUNT = 2;
3
5
  /**
4
- * Periodically checks the storage client for rate-limit errors (HTTP 429)
5
- * and reports overload when the error delta exceeds a threshold.
6
+ * Periodically checks the storage backend for rate-limit errors (HTTP 429) and reports overload when the error delta
7
+ * exceeds a threshold.
8
+ *
9
+ * Built by default; construct one yourself only to wrap or adapt it — see {@link LoadSignal}.
10
+ *
11
+ * Switch it off entirely ({@link LoadSignalsOptions.client|`client: false`}) if the storage backend reports no
12
+ * rate-limit statistics, since it otherwise polls it every second to no purpose.
13
+ *
14
+ * @category Scaling
6
15
  */
7
- export function createClientLoadSignal(options) {
8
- const maxClientErrors = options.maxClientErrors ?? 3;
9
- const signal = SnapshotStore.fromInterval({
10
- name: 'clientInfo',
11
- overloadedRatio: options.overloadedRatio ?? 0.3,
12
- intervalMillis: (options.clientSnapshotIntervalSecs ?? 1) * 1000,
13
- snapshotHistoryMillis: options.snapshotHistoryMillis,
14
- handler(store, intervalCallback) {
15
- const now = new Date();
16
- const allErrorCounts = options.client.stats?.rateLimitErrors ?? [];
17
- const currentErrCount = allErrorCounts[CLIENT_RATE_LIMIT_ERROR_RETRY_COUNT] || 0;
18
- const snapshot = {
19
- createdAt: now,
20
- isOverloaded: false,
21
- rateLimitErrorCount: currentErrCount,
22
- };
23
- const all = store.getAll();
24
- const previousSnapshot = all[all.length - 1];
25
- if (previousSnapshot) {
26
- const { rateLimitErrorCount } = previousSnapshot;
27
- const delta = currentErrCount - rateLimitErrorCount;
28
- if (delta > maxClientErrors)
29
- snapshot.isOverloaded = true;
30
- }
31
- store.push(snapshot, now);
32
- intervalCallback();
33
- },
34
- });
35
- return signal;
16
+ export class ClientLoadSignal {
17
+ name = 'clientInfo';
18
+ overloadedRatio;
19
+ store = new SnapshotStore();
20
+ intervalMillis;
21
+ maxErrors;
22
+ interval;
23
+ client;
24
+ constructor(options = {}) {
25
+ this.overloadedRatio = options.overloadedRatio ?? 0.3;
26
+ this.intervalMillis = (options.snapshotIntervalSecs ?? 1) * 1000;
27
+ this.maxErrors = options.maxErrors ?? 3;
28
+ this.handle = this.handle.bind(this);
29
+ }
30
+ async start(context) {
31
+ this.store.useSampleWindow(context.maxSampleWindowMillis);
32
+ // A new session starts from a clean slate, or its first measurement diffs the error count against the previous
33
+ // session's possibly against a different backend, since the client is resolved afresh just below.
34
+ this.store.clear();
35
+ // Resolved here rather than in the constructor, where asking for the backend would instantiate a default one
36
+ // as a side effect - long before the crawler that owns the run has had a chance to register its own.
37
+ this.client = serviceLocator.getStorageBackend();
38
+ this.interval = betterSetInterval(this.handle, this.intervalMillis);
39
+ }
40
+ async stop() {
41
+ if (this.interval)
42
+ betterClearInterval(this.interval);
43
+ this.interval = undefined;
44
+ this.client = undefined;
45
+ }
46
+ getSample(sampleDurationMillis) {
47
+ return this.store.getSample(sampleDurationMillis);
48
+ }
49
+ /**
50
+ * Records one snapshot, overloaded when rate-limit errors grew by more than the configured limit since the
51
+ * previous one.
52
+ * @internal Also lets tests drive the measurement without waiting on a timer.
53
+ */
54
+ handle(intervalCallback) {
55
+ const now = new Date();
56
+ const allErrorCounts = this.client?.stats?.rateLimitErrors ?? [];
57
+ const currentErrCount = allErrorCounts[CLIENT_RATE_LIMIT_ERROR_RETRY_COUNT] || 0;
58
+ const snapshot = {
59
+ createdAt: now,
60
+ isOverloaded: false,
61
+ rateLimitErrorCount: currentErrCount,
62
+ };
63
+ const all = this.store.getAll();
64
+ const previousSnapshot = all[all.length - 1];
65
+ if (previousSnapshot) {
66
+ const delta = currentErrCount - previousSnapshot.rateLimitErrorCount;
67
+ if (delta > this.maxErrors)
68
+ snapshot.isOverloaded = true;
69
+ }
70
+ this.store.push(snapshot, now);
71
+ intervalCallback();
72
+ }
36
73
  }