@crawlee/core 4.0.0-beta.99 → 4.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/autoscaling/autoscaled_pool.d.ts +3 -21
  2. package/autoscaling/autoscaled_pool.js +105 -97
  3. package/autoscaling/concurrency_system.d.ts +7 -22
  4. package/autoscaling/concurrency_system.js +112 -100
  5. package/autoscaling/cpu_load_signal.d.ts +1 -2
  6. package/autoscaling/cpu_load_signal.js +11 -10
  7. package/autoscaling/event_loop_load_signal.d.ts +1 -4
  8. package/autoscaling/event_loop_load_signal.js +18 -18
  9. package/autoscaling/index.d.ts +1 -1
  10. package/autoscaling/index.js +1 -1
  11. package/autoscaling/load_signal.d.ts +8 -7
  12. package/autoscaling/load_signal.js +14 -12
  13. package/autoscaling/memory_load_signal.d.ts +3 -12
  14. package/autoscaling/memory_load_signal.js +41 -41
  15. package/autoscaling/snapshotter.d.ts +7 -10
  16. package/autoscaling/snapshotter.js +18 -18
  17. package/autoscaling/{client_load_signal.d.ts → storage_backend_load_signal.d.ts} +14 -17
  18. package/autoscaling/{client_load_signal.js → storage_backend_load_signal.js} +27 -27
  19. package/autoscaling/system_status.d.ts +9 -11
  20. package/autoscaling/system_status.js +13 -13
  21. package/configuration.d.ts +16 -16
  22. package/configuration.js +6 -6
  23. package/crawlers/context_pipeline.js +6 -6
  24. package/crawlers/crawler_commons.d.ts +14 -112
  25. package/crawlers/crawler_commons.js +1 -107
  26. package/crawlers/error_snapshotter.d.ts +2 -4
  27. package/crawlers/error_snapshotter.js +5 -5
  28. package/crawlers/index.d.ts +1 -1
  29. package/crawlers/index.js +0 -1
  30. package/crawlers/statistics.d.ts +164 -45
  31. package/crawlers/statistics.js +339 -153
  32. package/debug.js +4 -4
  33. package/enqueue_links/enqueue_links.d.ts +51 -159
  34. package/enqueue_links/enqueue_links.js +38 -231
  35. package/enqueue_links/shared.d.ts +34 -24
  36. package/enqueue_links/shared.js +54 -36
  37. package/errors.d.ts +43 -12
  38. package/errors.js +48 -9
  39. package/events/event_manager.d.ts +2 -2
  40. package/events/event_manager.js +7 -6
  41. package/events/local_event_manager.d.ts +1 -1
  42. package/events/local_event_manager.js +5 -5
  43. package/http.d.ts +9 -0
  44. package/http.js +28 -0
  45. package/index.d.ts +1 -1
  46. package/index.js +1 -1
  47. package/log.js +5 -1
  48. package/memory-storage/memory-storage.d.ts +3 -11
  49. package/memory-storage/memory-storage.js +42 -48
  50. package/memory-storage/resource-clients/dataset.d.ts +1 -1
  51. package/memory-storage/resource-clients/dataset.js +8 -13
  52. package/memory-storage/resource-clients/key-value-store.d.ts +1 -1
  53. package/memory-storage/resource-clients/key-value-store.js +36 -38
  54. package/memory-storage/resource-clients/request-queue.d.ts +4 -23
  55. package/memory-storage/resource-clients/request-queue.js +68 -80
  56. package/owned_or_injected.d.ts +1 -3
  57. package/owned_or_injected.js +17 -17
  58. package/package.json +8 -9
  59. package/proxy_configuration.d.ts +1 -3
  60. package/proxy_configuration.js +18 -14
  61. package/recoverable_state.d.ts +80 -45
  62. package/recoverable_state.js +163 -75
  63. package/request.d.ts +3 -4
  64. package/request.js +57 -44
  65. package/router.d.ts +6 -9
  66. package/router.js +23 -23
  67. package/serialization.js +14 -13
  68. package/service_locator.d.ts +19 -10
  69. package/service_locator.js +57 -48
  70. package/session_pool/session.d.ts +3 -14
  71. package/session_pool/session.js +76 -74
  72. package/session_pool/session_pool.d.ts +6 -12
  73. package/session_pool/session_pool.js +85 -77
  74. package/storages/batched_adds.d.ts +37 -0
  75. package/storages/batched_adds.js +73 -0
  76. package/storages/dataset.d.ts +12 -1
  77. package/storages/dataset.js +133 -32
  78. package/storages/index.d.ts +2 -1
  79. package/storages/index.js +2 -1
  80. package/storages/key_value_store.d.ts +19 -4
  81. package/storages/key_value_store.js +204 -75
  82. package/storages/request_dedup_cache.d.ts +1 -2
  83. package/storages/request_dedup_cache.js +9 -9
  84. package/storages/request_list.d.ts +4 -23
  85. package/storages/request_list.js +103 -96
  86. package/storages/request_loader.d.ts +5 -0
  87. package/storages/request_manager_tandem.d.ts +1 -10
  88. package/storages/request_manager_tandem.js +27 -27
  89. package/storages/request_queue.d.ts +21 -18
  90. package/storages/request_queue.js +318 -177
  91. package/storages/sitemap_request_loader.d.ts +22 -70
  92. package/storages/sitemap_request_loader.js +122 -116
  93. package/storages/storage_instance_manager.d.ts +1 -2
  94. package/storages/storage_instance_manager.js +17 -17
  95. package/storages/storage_stats.d.ts +1 -1
  96. package/storages/storage_stats.js +4 -4
  97. package/storages/throttling_request_manager.d.ts +239 -0
  98. package/storages/throttling_request_manager.js +646 -0
  99. package/storages/transaction.d.ts +252 -0
  100. package/storages/transaction.js +251 -0
  101. package/storages/utils.d.ts +6 -3
  102. package/storages/utils.js +11 -2
  103. package/system-info/runtime.js +7 -7
  104. package/url.d.ts +9 -0
  105. package/url.js +11 -0
  106. package/validators.d.ts +22 -25
  107. package/validators.js +13 -25
  108. package/storages/access_checking.d.ts +0 -12
  109. package/storages/access_checking.js +0 -17
@@ -1,21 +1,34 @@
1
- import ow from 'ow';
1
+ import { z } from 'zod';
2
+ import { convertStateSync, RecoverableState } from '../recoverable_state.js';
2
3
  import { serviceLocator } from '../service_locator.js';
3
4
  import { KeyValueStore } from '../storages/key_value_store.js';
5
+ import { parseArgument, schemas, validators } from '../validators.js';
4
6
  import { ErrorTracker } from './error_tracker.js';
5
7
  /**
6
8
  * @ignore
7
9
  */
8
10
  class Job {
9
- lastRunAt = null;
10
- durationMillis;
11
+ #lastRunAt = null;
12
+ #durationMillis;
11
13
  run() {
12
- this.lastRunAt = Date.now();
14
+ this.#lastRunAt = Date.now();
13
15
  }
14
16
  finish() {
15
- this.durationMillis = Date.now() - this.lastRunAt;
16
- return this.durationMillis;
17
+ this.#durationMillis = Date.now() - this.#lastRunAt;
18
+ return this.#durationMillis;
17
19
  }
18
20
  }
21
+ const statisticsOptionsSchema = z.strictObject({
22
+ logIntervalSecs: schemas.anyNumber.default(60),
23
+ logMessage: z.string().default('Statistics'),
24
+ log: validators.logger.optional(),
25
+ keyValueStore: z.instanceof(KeyValueStore).optional(),
26
+ // `schemas.anyObject` passes values through by reference (object schemas return a pruned plain copy).
27
+ persistenceOptions: schemas.anyObject.default(() => ({ enable: true })),
28
+ saveErrorSnapshots: z.boolean().default(false),
29
+ id: z.union([schemas.anyNumber, z.string()]).optional(),
30
+ stateExtension: schemas.anyObject.default(() => ({})),
31
+ });
19
32
  const errorTrackerConfig = {
20
33
  showErrorCode: true,
21
34
  showErrorName: true,
@@ -24,6 +37,113 @@ const errorTrackerConfig = {
24
37
  showErrorMessage: true,
25
38
  showFullMessage: false,
26
39
  };
40
+ /**
41
+ * The persisted record, in the order it is written - the schema rebuilds the object on the way out, so the field
42
+ * order here *is* the record's field order (guarded by a test).
43
+ *
44
+ * JSON has no infinity, so the three fields that are `Infinity` until the first request settles are written as
45
+ * `null`. Both {@link Statistics.serializeState} and {@link Statistics.deserializeState} run through this,
46
+ * which is what keeps them describing the same record.
47
+ *
48
+ * Nothing is optional on purpose: the record has always carried every field, so one missing a field is not one of
49
+ * ours and is discarded whole rather than partially trusted - a counter restored as a string would poison every
50
+ * later increment.
51
+ *
52
+ * Custom fields are not this schema's business either way: they are added to the record after the encode and
53
+ * validated by their own conversion on the way back, so the keys it does not know about are simply dropped here.
54
+ */
55
+ const persistedStatisticState = z.object({
56
+ requestsFinished: z.number(),
57
+ requestsFailed: z.number(),
58
+ requestsRetries: z.number(),
59
+ requestsFailedPerMinute: z.number().nullable(),
60
+ requestsFinishedPerMinute: z.number().nullable(),
61
+ requestMinDurationMillis: z.number().nullable(),
62
+ requestMaxDurationMillis: z.number(),
63
+ requestTotalFailedDurationMillis: z.number(),
64
+ requestTotalFinishedDurationMillis: z.number(),
65
+ crawlerStartedAt: z.string().nullable(),
66
+ crawlerFinishedAt: z.string().nullable(),
67
+ statsPersistedAt: z.string(),
68
+ crawlerRuntimeMillis: z.number(),
69
+ crawlerLastStartTimestamp: z.number(),
70
+ // A retry count that never occurred leaves a hole in the live histogram, written out as a `null`. We
71
+ // once saw a record whose histogram was not an array at all and crashed the crawler on load.
72
+ requestRetryHistogram: z.array(z.number().nullable()),
73
+ statsId: z.string(),
74
+ requestAvgFailedDurationMillis: z.number().nullable(),
75
+ requestAvgFinishedDurationMillis: z.number().nullable(),
76
+ requestTotalDurationMillis: z.number(),
77
+ requestsTotal: z.number(),
78
+ requestsWithStatusCode: z.record(z.string(), z.number()),
79
+ errors: z.record(z.string(), z.unknown()),
80
+ retryErrors: z.record(z.string(), z.unknown()),
81
+ });
82
+ /** `Infinity` is what the statistics use for "nothing to average yet"; JSON has only `null` for it. */
83
+ function finiteOrNull(value) {
84
+ return Number.isFinite(value) ? value : null;
85
+ }
86
+ /**
87
+ * The conversion between the live state and the record above, in both directions.
88
+ *
89
+ * Built per instance rather than kept as a constant because a record carries three things the state does not: the
90
+ * instance `id`, the derived aggregates of the overridable {@link Statistics.calculate}, and - on the way back -
91
+ * the fields that are rebuilt from the instance's default state rather than restored, the error trackers among them.
92
+ *
93
+ * The model side is deliberately opaque: zod rebuilds what it validates, and `state.errors` has to stay the very
94
+ * object the error trackers write into, not a copy of it.
95
+ */
96
+ function buildStatisticStateCodec(statistics) {
97
+ return z.codec(persistedStatisticState, z.custom(), {
98
+ decode: (record) => ({
99
+ ...statistics.defaultState(),
100
+ requestsFinished: record.requestsFinished,
101
+ requestsFailed: record.requestsFailed,
102
+ requestsRetries: record.requestsRetries,
103
+ requestTotalFailedDurationMillis: record.requestTotalFailedDurationMillis,
104
+ requestTotalFinishedDurationMillis: record.requestTotalFinishedDurationMillis,
105
+ // Restoring the `null` as-is would make every later `duration < min` comparison fail, leaving the
106
+ // minimum `null` for the rest of the run.
107
+ requestMinDurationMillis: record.requestMinDurationMillis ?? Infinity,
108
+ requestMaxDurationMillis: record.requestMaxDurationMillis,
109
+ crawlerRuntimeMillis: record.crawlerRuntimeMillis,
110
+ // A `null` stands for the zero requests that reached that retry count - restore it as such.
111
+ requestRetryHistogram: record.requestRetryHistogram.map((count) => count ?? 0),
112
+ // The record keeps ISO strings, the live state keeps `Date`s.
113
+ crawlerStartedAt: record.crawlerStartedAt === null ? null : new Date(record.crawlerStartedAt),
114
+ crawlerFinishedAt: record.crawlerFinishedAt === null ? null : new Date(record.crawlerFinishedAt),
115
+ statsPersistedAt: new Date(record.statsPersistedAt),
116
+ // Rebased so that the runtime reported by `calculate()` spans the migration instead of restarting.
117
+ instanceStart: Date.now() - (new Date(record.statsPersistedAt).getTime() - record.crawlerLastStartTimestamp),
118
+ }),
119
+ encode: (state) => {
120
+ const { requestsWithStatusCode, errors, retryErrors, requestRetryHistogram, instanceStart, ...counters } = state;
121
+ // Every rate and average `calculate()` derives is `Infinity` until the run is long enough, or until
122
+ // something has finished or failed, to divide by.
123
+ const { requestAvgFailedDurationMillis, requestAvgFinishedDurationMillis, requestsFailedPerMinute, requestsFinishedPerMinute, ...aggregates } = statistics.calculate();
124
+ return {
125
+ ...counters,
126
+ requestMinDurationMillis: finiteOrNull(state.requestMinDurationMillis),
127
+ crawlerStartedAt: state.crawlerStartedAt ? new Date(state.crawlerStartedAt).toISOString() : null,
128
+ crawlerFinishedAt: state.crawlerFinishedAt ? new Date(state.crawlerFinishedAt).toISOString() : null,
129
+ statsPersistedAt: new Date().toISOString(),
130
+ crawlerLastStartTimestamp: instanceStart,
131
+ // `Array.from`, not `map` - a hole left by a retry count no request reached is skipped by `map`
132
+ // and would stay a hole, which is not something the record can carry.
133
+ requestRetryHistogram: Array.from(requestRetryHistogram, (count) => count ?? null),
134
+ statsId: statistics.statsId,
135
+ ...aggregates,
136
+ requestAvgFailedDurationMillis: finiteOrNull(requestAvgFailedDurationMillis),
137
+ requestAvgFinishedDurationMillis: finiteOrNull(requestAvgFinishedDurationMillis),
138
+ requestsFailedPerMinute: finiteOrNull(requestsFailedPerMinute),
139
+ requestsFinishedPerMinute: finiteOrNull(requestsFinishedPerMinute),
140
+ requestsWithStatusCode,
141
+ errors,
142
+ retryErrors,
143
+ };
144
+ },
145
+ });
146
+ }
27
147
  /**
28
148
  * The statistics class provides an interface to collecting and logging run
29
149
  * statistics for requests.
@@ -32,9 +152,13 @@ const errorTrackerConfig = {
32
152
  * under the key `CRAWLEE_CRAWLER_STATISTICS_*`, persists between
33
153
  * migrations and abort/resurrect
34
154
  *
155
+ * Custom fields are tracked by passing {@link StatisticsOptions.stateExtension|`stateExtension`} - the extra fields are then part
156
+ * of {@link Statistics.state|`state`}, persisted and restored along with the built-in ones.
157
+ *
35
158
  * @category Crawlers
36
159
  */
37
160
  export class Statistics {
161
+ // kept as TS-private: statistics tests read the static counter directly
38
162
  static id = 0;
39
163
  /**
40
164
  * An error tracker for final retry errors.
@@ -48,67 +172,119 @@ export class Statistics {
48
172
  * Statistic instance id.
49
173
  */
50
174
  id;
175
+ #persistStateKey;
176
+ #stateCodec;
177
+ #recoverableState;
178
+ #stateExtension;
179
+ #defaultStateExtension;
180
+ #stateExtensionKeys;
181
+ #logIntervalMillis;
182
+ #logMessage;
183
+ #requestsInProgress = new Map();
184
+ log;
185
+ #logInterval;
51
186
  /**
52
187
  * Current statistic state used for doing calculations on {@link Statistics.calculate} calls
53
188
  */
54
- state;
189
+ get state() {
190
+ return this.#recoverableState.currentValue;
191
+ }
55
192
  /**
56
193
  * Contains the current retries histogram. Index 0 means 0 retries, index 2, 2 retries, and so on
57
194
  */
58
- requestRetryHistogram = [];
59
- keyValueStore = undefined;
60
- persistStateKey;
61
- logIntervalMillis;
62
- logMessage;
63
- listener;
64
- requestsInProgress = new Map();
65
- log;
66
- instanceStart;
67
- logInterval;
68
- _events;
69
- persistenceOptions;
70
- get events() {
71
- if (!this._events) {
72
- this._events = serviceLocator.getEventManager();
73
- }
74
- return this._events;
195
+ get requestRetryHistogram() {
196
+ return this.state.requestRetryHistogram;
75
197
  }
76
198
  /**
77
- * @internal
199
+ * Construct a statistics instance to pass to a crawler via its `statistics` option, e.g. to preconfigure
200
+ * persistence or error snapshots, share it across sequential runs, or track extra fields via `state`.
78
201
  */
79
202
  constructor(options = {}) {
80
- ow(options, ow.object.exactShape({
81
- logIntervalSecs: ow.optional.number,
82
- logMessage: ow.optional.string,
83
- log: ow.optional.object,
84
- keyValueStore: ow.optional.object,
85
- persistenceOptions: ow.optional.object,
86
- saveErrorSnapshots: ow.optional.boolean,
87
- id: ow.optional.any(ow.number, ow.string),
88
- }));
89
- const { logIntervalSecs = 60, logMessage = 'Statistics', keyValueStore, persistenceOptions = {
90
- enable: true,
91
- }, saveErrorSnapshots = false, id, } = options;
203
+ const { logIntervalSecs, logMessage, log, keyValueStore, persistenceOptions, saveErrorSnapshots, id, stateExtension, } = parseArgument(options, statisticsOptionsSchema);
92
204
  this.id = id ?? String(Statistics.id++);
93
- this.persistStateKey = `CRAWLEE_CRAWLER_STATISTICS_${this.id}`;
94
- this.log = (options.log ?? serviceLocator.getLogger()).child({ prefix: 'Statistics' });
205
+ this.#persistStateKey = `CRAWLEE_CRAWLER_STATISTICS_${this.id}`;
206
+ this.log = (log ?? serviceLocator.getLogger()).child({ prefix: 'Statistics' });
95
207
  this.errorTracker = new ErrorTracker({ ...errorTrackerConfig, saveErrorSnapshots });
96
208
  this.errorTrackerRetry = new ErrorTracker({ ...errorTrackerConfig, saveErrorSnapshots });
97
- this.logIntervalMillis = logIntervalSecs * 1000;
98
- this.logMessage = logMessage;
99
- this.keyValueStore = keyValueStore;
100
- this.listener = this.persistState.bind(this);
101
- this.persistenceOptions = persistenceOptions;
209
+ this.#logIntervalMillis = logIntervalSecs * 1000;
210
+ this.#logMessage = logMessage;
211
+ this.#stateExtension = stateExtension;
212
+ this.#defaultStateExtension = this.#resolveDefaultStateExtension(this.#stateExtension);
213
+ this.#stateExtensionKeys = Object.keys(this.#defaultStateExtension());
214
+ for (const key of this.#stateExtensionKeys) {
215
+ if (key in this.#builtInDefaultState()) {
216
+ throw new Error(`The custom statistics field \`${String(key)}\` collides with a built-in one - it would shadow ` +
217
+ 'the value the crawler tracks. Rename it in `stateExtension`.');
218
+ }
219
+ }
220
+ // `calculate()` is late-bound on purpose - it is an override point, and a subclass's must be the one that runs.
221
+ this.#stateCodec = buildStatisticStateCodec({
222
+ statsId: this.id,
223
+ defaultState: () => this.#defaultState(),
224
+ calculate: () => this.calculate(),
225
+ });
226
+ this.#recoverableState = new RecoverableState({
227
+ persistStateKey: this.#persistStateKey,
228
+ persistenceEnabled: persistenceOptions.enable,
229
+ keyValueStore,
230
+ logger: this.log,
231
+ defaultState: () => this.#defaultState(),
232
+ serialize: (state) => this.#serializeState(state),
233
+ deserialize: (persistedState) => this.#deserializeState(persistedState),
234
+ });
102
235
  // initialize by "resetting"
103
236
  this.reset();
104
237
  }
105
238
  /**
106
- * Set the current statistic instance to pristine values
239
+ * Set the current statistic instance to pristine values.
240
+ *
241
+ * The persisted record is left alone - use {@link Statistics.resetStore} to clear that as well.
107
242
  */
108
243
  reset() {
109
244
  this.errorTracker.reset();
110
245
  this.errorTrackerRetry.reset();
111
- this.state = {
246
+ this.#recoverableState.reset();
247
+ this.#requestsInProgress.clear();
248
+ }
249
+ /** The pristine state a new instance starts with and {@link Statistics.reset} restores. */
250
+ #defaultState() {
251
+ return {
252
+ ...this.#builtInDefaultState(),
253
+ ...this.#defaultStateExtension(),
254
+ };
255
+ }
256
+ /**
257
+ * The factory behind the custom half of the default state - it has to hand out a fresh object every time, or a
258
+ * `reset()` would write through to the defaults of the next one.
259
+ *
260
+ * With no `defaultState` given, the defaults are what `deserialize` makes of an empty record. That keeps a
261
+ * single declaration of the custom fields - a schema with a `.default()` per field is enough - and the defaults
262
+ * cannot then disagree with the conversion that has to accept them back.
263
+ */
264
+ #resolveDefaultStateExtension(options) {
265
+ const { defaultState, deserialize } = options;
266
+ if (typeof defaultState === 'function') {
267
+ return defaultState;
268
+ }
269
+ if (defaultState !== undefined) {
270
+ return () => structuredClone(defaultState);
271
+ }
272
+ if (deserialize === undefined) {
273
+ return () => ({});
274
+ }
275
+ return () => {
276
+ try {
277
+ return convertStateSync(deserialize, {}, this.#persistStateKey);
278
+ }
279
+ catch (error) {
280
+ throw new Error('Could not derive the default values of the custom statistics fields from `stateExtension.deserialize` - ' +
281
+ 'give every field a default, or declare `stateExtension.defaultState` explicitly.', { cause: error });
282
+ }
283
+ };
284
+ }
285
+ /** The built-in half of {@link Statistics.defaultState}, before any custom fields are merged over it. */
286
+ #builtInDefaultState() {
287
+ return {
112
288
  requestsFinished: 0,
113
289
  requestsFailed: 0,
114
290
  requestsRetries: 0,
@@ -123,25 +299,20 @@ export class Statistics {
123
299
  statsPersistedAt: null,
124
300
  crawlerRuntimeMillis: 0,
125
301
  requestsWithStatusCode: {},
302
+ // Aliases, not copies - the trackers keep writing into these objects.
126
303
  errors: this.errorTracker.result,
127
304
  retryErrors: this.errorTrackerRetry.result,
305
+ requestRetryHistogram: [],
306
+ instanceStart: Date.now(),
128
307
  };
129
- this.requestRetryHistogram.length = 0;
130
- this.requestsInProgress.clear();
131
- this.instanceStart = Date.now();
132
- this.teardown();
133
308
  }
134
309
  /**
135
- * @param options - Override the persistence options provided in the constructor
310
+ * Clear the persisted statistics record, leaving the in-memory state alone.
311
+ *
312
+ * Throws while capturing - the next PERSIST_STATE event would write the record straight back.
136
313
  */
137
- async resetStore(options) {
138
- if (!this.persistenceOptions.enable && !options?.enable) {
139
- return;
140
- }
141
- if (!this.keyValueStore) {
142
- return;
143
- }
144
- await this.keyValueStore.setValue(this.persistStateKey, null);
314
+ async resetStore() {
315
+ await this.#recoverableState.resetStore();
145
316
  }
146
317
  /**
147
318
  * Increments the status code counter.
@@ -158,18 +329,18 @@ export class Statistics {
158
329
  * @ignore
159
330
  */
160
331
  startJob(id) {
161
- let job = this.requestsInProgress.get(id);
332
+ let job = this.#requestsInProgress.get(id);
162
333
  if (!job)
163
334
  job = new Job();
164
335
  job.run();
165
- this.requestsInProgress.set(id, job);
336
+ this.#requestsInProgress.set(id, job);
166
337
  }
167
338
  /**
168
339
  * Mark job as finished and sets the state
169
340
  * @ignore
170
341
  */
171
342
  finishJob(id, retryCount) {
172
- const job = this.requestsInProgress.get(id);
343
+ const job = this.#requestsInProgress.get(id);
173
344
  if (!job)
174
345
  return;
175
346
  const jobDurationMillis = job.finish();
@@ -180,20 +351,20 @@ export class Statistics {
180
351
  this.state.requestMinDurationMillis = jobDurationMillis;
181
352
  if (jobDurationMillis > this.state.requestMaxDurationMillis)
182
353
  this.state.requestMaxDurationMillis = jobDurationMillis;
183
- this.requestsInProgress.delete(id);
354
+ this.#requestsInProgress.delete(id);
184
355
  }
185
356
  /**
186
357
  * Mark job as failed and sets the state
187
358
  * @ignore
188
359
  */
189
360
  failJob(id, retryCount) {
190
- const job = this.requestsInProgress.get(id);
361
+ const job = this.#requestsInProgress.get(id);
191
362
  if (!job)
192
363
  return;
193
364
  this.state.requestTotalFailedDurationMillis += job.finish();
194
365
  this.state.requestsFailed++;
195
366
  this.saveRetryCountForJob(retryCount);
196
- this.requestsInProgress.delete(id);
367
+ this.#requestsInProgress.delete(id);
197
368
  }
198
369
  /**
199
370
  * Discards a started job without affecting the finished/failed counters, e.g. when a request
@@ -201,14 +372,14 @@ export class Statistics {
201
372
  * @ignore
202
373
  */
203
374
  discardJob(id) {
204
- this.requestsInProgress.delete(id);
375
+ this.#requestsInProgress.delete(id);
205
376
  }
206
377
  /**
207
378
  * Calculate the current statistics
208
379
  */
209
380
  calculate() {
210
381
  const { requestsFailed, requestsFinished, requestTotalFailedDurationMillis, requestTotalFinishedDurationMillis, } = this.state;
211
- const totalMillis = Date.now() - this.instanceStart;
382
+ const totalMillis = Date.now() - this.state.instanceStart;
212
383
  const totalMinutes = totalMillis / 1000 / 60;
213
384
  return {
214
385
  requestAvgFailedDurationMillis: Math.round(requestTotalFailedDurationMillis / requestsFailed) || Infinity,
@@ -225,28 +396,30 @@ export class Statistics {
225
396
  * displaying the current state in predefined intervals
226
397
  */
227
398
  async startCapturing() {
228
- this.keyValueStore ??= await KeyValueStore.open(null, { configuration: serviceLocator.getConfiguration() });
399
+ // A single instance drives one logging interval and one PERSIST_STATE listener, so a second concurrent
400
+ // capture (e.g. sharing one instance across crawlers running at once) would orphan the first. Fail loudly.
401
+ if (this.#logInterval) {
402
+ throw new Error('Statistics.startCapturing() was already called - this instance is already capturing.');
403
+ }
404
+ await this.#recoverableState.initialize();
405
+ // After the load, so that a restored record keeps the timestamp of the run it belongs to.
229
406
  if (this.state.crawlerStartedAt === null) {
230
407
  this.state.crawlerStartedAt = new Date();
231
408
  }
232
- if (this.persistenceOptions.enable) {
233
- await this.maybeLoadStatistics();
234
- this.events.on("persistState" /* EventType.PERSIST_STATE */, this.listener);
235
- }
236
- this.logInterval = setInterval(() => {
237
- this.log.info(this.logMessage, {
409
+ this.#logInterval = setInterval(() => {
410
+ this.log.info(this.#logMessage, {
238
411
  ...this.calculate(),
239
412
  retryHistogram: this.requestRetryHistogram,
240
413
  });
241
- }, this.logIntervalMillis);
414
+ }, this.#logIntervalMillis);
242
415
  }
243
416
  /**
244
417
  * Stops logging and remove event listeners, then persist
245
418
  */
246
419
  async stopCapturing() {
247
- this.teardown();
420
+ this.#stopLogging();
248
421
  this.state.crawlerFinishedAt = new Date();
249
- await this.persistState();
422
+ await this.#recoverableState.teardown();
250
423
  }
251
424
  saveRetryCountForJob(retryCount) {
252
425
  if (retryCount > 0)
@@ -255,96 +428,109 @@ export class Statistics {
255
428
  this.requestRetryHistogram[retryCount]++;
256
429
  }
257
430
  /**
258
- * Persist internal state to the key value store
259
- * @param options - Override the persistence options provided in the constructor
431
+ * Persist internal state to the key value store.
432
+ *
433
+ * Statistics are bookkeeping - a store that refuses the write is worth a warning, not a failed crawl. The
434
+ * crawler calls this from its migration handler, where a rejection would go unhandled.
260
435
  */
261
- async persistState(options) {
262
- if (!this.persistenceOptions.enable && !options?.enable) {
263
- return;
264
- }
265
- // this might be called before startCapturing was called without using await, should not crash
266
- if (!this.keyValueStore) {
267
- return;
436
+ async persistState() {
437
+ await this.#recoverableState
438
+ .persistState()
439
+ .catch((error) => this.log.warning(`Failed to persist the statistics to ${this.#persistStateKey}`, { error }));
440
+ }
441
+ /** Rebuilds the state from a persisted record. */
442
+ #deserializeState(persistedState) {
443
+ // The cast covers the custom fields, whose type is open here - and the record is an unvalidated blob off the
444
+ // key-value store either way, which is what the decode is for. Their keys are not in the schema, so the
445
+ // decode drops them; `#restoreStateExtension` is what brings them back.
446
+ const restored = z.safeDecode(this.#stateCodec, persistedState);
447
+ if (!restored.success) {
448
+ // Statistics are bookkeeping - a record that cannot be made sense of is worth a warning and a fresh
449
+ // start, not a failed crawl.
450
+ this.log.warning('Received invalid state from Key-value store, starting the statistics from scratch.', {
451
+ persistStateKey: this.#persistStateKey,
452
+ issues: restored.error.issues,
453
+ });
454
+ return this.#defaultState();
268
455
  }
269
- this.log.debug('Persisting state', { persistStateKey: this.persistStateKey });
270
- await this.keyValueStore
271
- .setValue(this.persistStateKey, this.toJSON())
272
- .catch((error) => this.log.warning(`Failed to persist the statistics to ${this.persistStateKey}`, { error }));
456
+ this.log.debug('Recreating state from KeyValueStore', { persistStateKey: this.#persistStateKey });
457
+ return { ...this.#defaultState(), ...restored.data, ...this.#restoreStateExtension(persistedState) };
273
458
  }
274
459
  /**
275
- * Loads the current statistic from the key value store if any
460
+ * The custom {@link StatisticsOptions.stateExtension|`stateExtension`} fields as they were persisted - the codec only rebuilds
461
+ * the built-in ones.
462
+ *
463
+ * Given a `deserialize`, the whole record goes through it, so a field the record does not carry - one declared
464
+ * after the record was written - comes back as whatever default the conversion gives it. Without one, the
465
+ * declared keys are copied over as they were persisted and a missing one keeps the default it already has.
276
466
  */
277
- async maybeLoadStatistics() {
278
- // this might be called before startCapturing was called without using await, should not crash
279
- if (!this.keyValueStore) {
280
- return;
467
+ #restoreStateExtension(persistedState) {
468
+ const { deserialize } = this.#stateExtension;
469
+ if (deserialize === undefined) {
470
+ const restored = {};
471
+ for (const key of this.#stateExtensionKeys) {
472
+ // The record is an unvalidated blob and there is no conversion to check it with, so this is the
473
+ // caller's word for it - the state ends up holding whatever was written. The two halves only line
474
+ // up at all because the fields are persisted as they are without a `serialize`.
475
+ const persistedValue = persistedState[key];
476
+ if (persistedValue !== undefined) {
477
+ restored[key] = persistedValue;
478
+ }
479
+ }
480
+ return restored;
281
481
  }
282
- const savedState = await this.keyValueStore.getValue(this.persistStateKey);
283
- if (!savedState)
284
- return;
285
- // We saw a run where the requestRetryHistogram was not iterable and crashed
286
- // the crawler. Adding some logging to monitor this problem in the future.
287
- if (!Array.isArray(savedState.requestRetryHistogram)) {
288
- this.log.warning('Received invalid state from Key-value store.', {
289
- persistStateKey: this.persistStateKey,
290
- state: savedState,
291
- });
482
+ try {
483
+ return convertStateSync(deserialize, persistedState, this.#persistStateKey);
484
+ }
485
+ catch (error) {
486
+ // Same policy as a built-in field that cannot be made sense of, but scoped to the custom ones - a
487
+ // corrupt counter of your own is no reason to throw away the crawler's.
488
+ this.log.warning('Received invalid custom statistics fields from Key-value store, starting those from scratch.', { persistStateKey: this.#persistStateKey, error });
489
+ return {};
490
+ }
491
+ }
492
+ #stopLogging() {
493
+ if (this.#logInterval) {
494
+ clearInterval(this.#logInterval);
495
+ this.#logInterval = null;
496
+ }
497
+ }
498
+ /**
499
+ * Builds the record written to the key value store, merging in the derived aggregates so that a consumer
500
+ * reading the record does not have to reconstruct them.
501
+ */
502
+ #serializeState(state) {
503
+ const { builtIn, extension } = this.#splitState(state);
504
+ return {
505
+ ...z.encode(this.#stateCodec, builtIn),
506
+ ...this.#serializeStateExtension(extension),
507
+ };
508
+ }
509
+ /** The custom {@link StatisticsOptions.stateExtension|`stateExtension`} fields as they go into the record. */
510
+ #serializeStateExtension(extension) {
511
+ const { serialize } = this.#stateExtension;
512
+ if (serialize === undefined) {
513
+ return extension;
292
514
  }
293
- this.log.debug('Recreating state from KeyValueStore', { persistStateKey: this.persistStateKey });
294
- // the `requestRetryHistogram` array might be very large, we could end up with
295
- // `RangeError: Maximum call stack size exceeded` if we use `a.push(...b)`
296
- savedState.requestRetryHistogram.forEach((idx) => this.requestRetryHistogram.push(idx));
297
- this.state.requestsFinished = savedState.requestsFinished;
298
- this.state.requestsFailed = savedState.requestsFailed;
299
- this.state.requestsRetries = savedState.requestsRetries;
300
- this.state.requestTotalFailedDurationMillis = savedState.requestTotalFailedDurationMillis;
301
- this.state.requestTotalFinishedDurationMillis = savedState.requestTotalFinishedDurationMillis;
302
- this.state.requestMinDurationMillis = savedState.requestMinDurationMillis;
303
- this.state.requestMaxDurationMillis = savedState.requestMaxDurationMillis;
304
- // persisted state uses ISO date strings
305
- this.state.crawlerFinishedAt = savedState.crawlerFinishedAt ? new Date(savedState.crawlerFinishedAt) : null;
306
- this.state.crawlerStartedAt = savedState.crawlerStartedAt ? new Date(savedState.crawlerStartedAt) : null;
307
- this.state.statsPersistedAt = savedState.statsPersistedAt ? new Date(savedState.statsPersistedAt) : null;
308
- this.state.crawlerRuntimeMillis = savedState.crawlerRuntimeMillis;
309
- this.instanceStart = Date.now() - (+this.state.statsPersistedAt - savedState.crawlerLastStartTimestamp);
310
- this.log.debug('Loaded from KeyValueStore');
515
+ // Unlike the way back, a failure here throws: the value is the caller's own, and a record written from a
516
+ // state that does not match its own declaration is not worth having.
517
+ return convertStateSync(serialize, extension, this.#persistStateKey);
311
518
  }
312
- teardown() {
313
- // this can be called before a call to startCapturing happens (or in a 'finally' block)
314
- // Only unsubscribe if event manager was already resolved — avoid eagerly resolving it
315
- // (e.g. during the constructor's reset() call, which would capture the wrong context)
316
- this._events?.off("persistState" /* EventType.PERSIST_STATE */, this.listener);
317
- if (this.logInterval) {
318
- clearInterval(this.logInterval);
319
- this.logInterval = null;
519
+ /** Separates the declared custom fields from the built-in ones, so that each half goes through its own conversion. */
520
+ #splitState(state) {
521
+ const builtIn = { ...state };
522
+ const extension = {};
523
+ for (const key of this.#stateExtensionKeys) {
524
+ extension[key] = state[key];
525
+ delete builtIn[key];
320
526
  }
527
+ return { builtIn, extension };
321
528
  }
322
529
  /**
323
530
  * Make this class serializable when called with `JSON.stringify(statsInstance)` directly
324
531
  * or through `keyValueStore.setValue('KEY', statsInstance)`
325
532
  */
326
533
  toJSON() {
327
- // merge all the current state information that can be used from the outside
328
- // without the need to reconstruct for the sake of stats.calculate()
329
- // omit duplicated information
330
- const result = {
331
- ...this.state,
332
- crawlerLastStartTimestamp: this.instanceStart,
333
- crawlerFinishedAt: this.state.crawlerFinishedAt
334
- ? new Date(this.state.crawlerFinishedAt).toISOString()
335
- : null,
336
- crawlerStartedAt: this.state.crawlerStartedAt ? new Date(this.state.crawlerStartedAt).toISOString() : null,
337
- requestRetryHistogram: this.requestRetryHistogram,
338
- statsId: this.id,
339
- statsPersistedAt: new Date().toISOString(),
340
- ...this.calculate(),
341
- };
342
- Reflect.deleteProperty(result, 'requestsWithStatusCode');
343
- Reflect.deleteProperty(result, 'errors');
344
- Reflect.deleteProperty(result, 'retryErrors');
345
- result.requestsWithStatusCode = this.state.requestsWithStatusCode;
346
- result.errors = this.state.errors;
347
- result.retryErrors = this.state.retryErrors;
348
- return result;
534
+ return this.#serializeState(this.state);
349
535
  }
350
536
  }