@crawlee/core 4.0.0-beta.123 → 4.0.0-beta.125
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/crawlers/statistics.d.ts +68 -28
- package/crawlers/statistics.js +141 -41
- package/events/event_manager.js +2 -1
- package/package.json +7 -6
- package/recoverable_state.d.ts +15 -0
- package/recoverable_state.js +19 -0
- package/storages/throttling_request_manager.d.ts +72 -49
- package/storages/throttling_request_manager.js +321 -130
package/crawlers/statistics.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CrawleeLogger } from '../log.js';
|
|
2
|
+
import type { SyncStateConversion } from '../recoverable_state.js';
|
|
2
3
|
import { KeyValueStore } from '../storages/key_value_store.js';
|
|
3
4
|
import { ErrorTracker } from './error_tracker.js';
|
|
4
5
|
/**
|
|
@@ -13,8 +14,11 @@ export interface PersistenceOptions {
|
|
|
13
14
|
}
|
|
14
15
|
/**
|
|
15
16
|
* The statistics surface a crawler depends on: recording per-request outcomes, tracking errors, and driving the
|
|
16
|
-
* capture lifecycle for a run. Injected via the crawler's `statistics` option, so a custom implementation
|
|
17
|
-
*
|
|
17
|
+
* capture lifecycle for a run. Injected via the crawler's `statistics` option, so a custom implementation can be
|
|
18
|
+
* plugged in without subclassing the crawler.
|
|
19
|
+
*
|
|
20
|
+
* `StateExtension` describes the custom fields tracked alongside the built-in {@link StatisticState} ones - see
|
|
21
|
+
* {@link StatisticsOptions.stateExtension}.
|
|
18
22
|
*
|
|
19
23
|
* The owned-only mutators the crawler uses to *own* a default it built - `reset()`/`resetStore()` - are deliberately
|
|
20
24
|
* absent: an injected instance is borrowed, and the crawler never wipes it. Those live on the concrete
|
|
@@ -22,13 +26,13 @@ export interface PersistenceOptions {
|
|
|
22
26
|
*
|
|
23
27
|
* @category Crawlers
|
|
24
28
|
*/
|
|
25
|
-
export interface IStatistics {
|
|
29
|
+
export interface IStatistics<StateExtension extends object = {}> {
|
|
26
30
|
/** Tracker for errors on the final retry of a request. */
|
|
27
31
|
readonly errorTracker: ErrorTracker;
|
|
28
32
|
/** Tracker for errors on retries prior to the final one. */
|
|
29
33
|
readonly errorTrackerRetry: ErrorTracker;
|
|
30
34
|
/** The live statistics state the crawler reads for status messages and the final summary. */
|
|
31
|
-
readonly state: StatisticState;
|
|
35
|
+
readonly state: StatisticState & StateExtension;
|
|
32
36
|
/** Retries histogram - index `i` holds the number of requests that finished after `i` retries. */
|
|
33
37
|
readonly requestRetryHistogram: number[];
|
|
34
38
|
/** Marks a request as started, so its duration can be measured on finish/fail. */
|
|
@@ -78,9 +82,12 @@ export interface CalculatedStatistics {
|
|
|
78
82
|
* under the key `CRAWLEE_CRAWLER_STATISTICS_*`, persists between
|
|
79
83
|
* migrations and abort/resurrect
|
|
80
84
|
*
|
|
85
|
+
* Custom fields are tracked by passing {@link StatisticsOptions.stateExtension|`stateExtension`} - the extra fields are then part
|
|
86
|
+
* of {@link Statistics.state|`state`}, persisted and restored along with the built-in ones.
|
|
87
|
+
*
|
|
81
88
|
* @category Crawlers
|
|
82
89
|
*/
|
|
83
|
-
export declare class Statistics implements IStatistics {
|
|
90
|
+
export declare class Statistics<StateExtension extends object = {}, PersistedStateExtension extends object = StateExtension> implements IStatistics<StateExtension> {
|
|
84
91
|
#private;
|
|
85
92
|
private static id;
|
|
86
93
|
/**
|
|
@@ -95,33 +102,26 @@ export declare class Statistics implements IStatistics {
|
|
|
95
102
|
* Statistic instance id.
|
|
96
103
|
*/
|
|
97
104
|
readonly id: string;
|
|
98
|
-
protected readonly persistStateKey: string;
|
|
99
105
|
private readonly log;
|
|
100
106
|
/**
|
|
101
107
|
* Current statistic state used for doing calculations on {@link Statistics.calculate} calls
|
|
102
108
|
*/
|
|
103
|
-
get state(): StatisticState;
|
|
109
|
+
get state(): StatisticState & StateExtension;
|
|
104
110
|
/**
|
|
105
111
|
* Contains the current retries histogram. Index 0 means 0 retries, index 2, 2 retries, and so on
|
|
106
112
|
*/
|
|
107
113
|
get requestRetryHistogram(): number[];
|
|
108
114
|
/**
|
|
109
115
|
* Construct a statistics instance to pass to a crawler via its `statistics` option, e.g. to preconfigure
|
|
110
|
-
* persistence or error snapshots, share it across sequential runs, or
|
|
116
|
+
* persistence or error snapshots, share it across sequential runs, or track extra fields via `state`.
|
|
111
117
|
*/
|
|
112
|
-
constructor(options?: StatisticsOptions);
|
|
118
|
+
constructor(options?: StatisticsOptions<StateExtension, PersistedStateExtension>);
|
|
113
119
|
/**
|
|
114
120
|
* Set the current statistic instance to pristine values.
|
|
115
121
|
*
|
|
116
122
|
* The persisted record is left alone - use {@link Statistics.resetStore} to clear that as well.
|
|
117
123
|
*/
|
|
118
124
|
reset(): void;
|
|
119
|
-
/**
|
|
120
|
-
* The pristine state a new instance starts with and {@link Statistics.reset} restores.
|
|
121
|
-
*
|
|
122
|
-
* A subclass tracking extra fields declares their initial values here.
|
|
123
|
-
*/
|
|
124
|
-
protected defaultState(): StatisticState;
|
|
125
125
|
/**
|
|
126
126
|
* Clear the persisted statistics record, leaving the in-memory state alone.
|
|
127
127
|
*
|
|
@@ -174,27 +174,16 @@ export declare class Statistics implements IStatistics {
|
|
|
174
174
|
* crawler calls this from its migration handler, where a rejection would go unhandled.
|
|
175
175
|
*/
|
|
176
176
|
persistState(): Promise<void>;
|
|
177
|
-
/**
|
|
178
|
-
* Rebuilds the state from a persisted record.
|
|
179
|
-
*
|
|
180
|
-
* A subclass tracking extra fields restores them here, on top of the result of `super.deserializeState()`.
|
|
181
|
-
*/
|
|
182
|
-
protected deserializeState(persistedState: StatisticPersistedState): StatisticState;
|
|
183
|
-
/**
|
|
184
|
-
* Builds the record written to the key value store, merging in the derived aggregates so that a consumer
|
|
185
|
-
* reading the record does not have to reconstruct them.
|
|
186
|
-
*/
|
|
187
|
-
protected serializeState(state: StatisticState): StatisticPersistedState;
|
|
188
177
|
/**
|
|
189
178
|
* Make this class serializable when called with `JSON.stringify(statsInstance)` directly
|
|
190
179
|
* or through `keyValueStore.setValue('KEY', statsInstance)`
|
|
191
180
|
*/
|
|
192
|
-
toJSON(): StatisticPersistedState;
|
|
181
|
+
toJSON(): StatisticPersistedState & PersistedStateExtension;
|
|
193
182
|
}
|
|
194
183
|
/**
|
|
195
184
|
* Configuration for the {@link Statistics} instance used by the crawler
|
|
196
185
|
*/
|
|
197
|
-
export interface StatisticsOptions {
|
|
186
|
+
export interface StatisticsOptions<StateExtension extends object = {}, PersistedStateExtension extends object = StateExtension> {
|
|
198
187
|
/**
|
|
199
188
|
* Interval in seconds to log the current statistics
|
|
200
189
|
* @default 60
|
|
@@ -233,6 +222,57 @@ export interface StatisticsOptions {
|
|
|
233
222
|
* if crawler creation order changes.
|
|
234
223
|
*/
|
|
235
224
|
id?: string;
|
|
225
|
+
/**
|
|
226
|
+
* Custom fields to track alongside the built-in {@link StatisticState} ones. They become part of
|
|
227
|
+
* {@link Statistics.state|`state`} (typed as such), are persisted with the rest of the state, and are
|
|
228
|
+
* restored on migration or resurrect.
|
|
229
|
+
*
|
|
230
|
+
* ```ts
|
|
231
|
+
* const statistics = new Statistics({ stateExtension: { defaultState: { productsFound: 0 } } });
|
|
232
|
+
* statistics.state.productsFound++;
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
stateExtension?: StatisticStateExtensionOptions<StateExtension, PersistedStateExtension>;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* How the custom fields of {@link StatisticsOptions.stateExtension} are initialized and converted to and from the
|
|
239
|
+
* persisted record - the same three things {@link RecoverableStateOptions} asks for, scoped to the custom half
|
|
240
|
+
* of the statistics state.
|
|
241
|
+
*/
|
|
242
|
+
export interface StatisticStateExtensionOptions<StateExtension extends object, PersistedStateExtension extends object = StateExtension> {
|
|
243
|
+
/**
|
|
244
|
+
* The values the fields start with, and the ones {@link Statistics.reset} restores. A plain value is
|
|
245
|
+
* deep-copied with `structuredClone` each time it is used; pass a factory for a state `structuredClone` cannot
|
|
246
|
+
* rebuild.
|
|
247
|
+
*
|
|
248
|
+
* Can be omitted when `deserialize` supplies its own defaults, which is then the single place the fields are
|
|
249
|
+
* declared - see the example on {@link StatisticStateExtensionOptions.deserialize|`deserialize`}.
|
|
250
|
+
*/
|
|
251
|
+
defaultState?: StateExtension | (() => StateExtension);
|
|
252
|
+
/**
|
|
253
|
+
* Rebuilds the custom fields from the persisted record, and the place to validate them before trusting them.
|
|
254
|
+
* Receives the whole record, so it has to supply a value for every field - `.default()` in a schema, or
|
|
255
|
+
* {@link StatisticStateExtensionOptions.defaultState|`defaultState`} alongside a conversion that copes with
|
|
256
|
+
* a missing field itself.
|
|
257
|
+
*
|
|
258
|
+
* ```ts
|
|
259
|
+
* const statistics = new Statistics({
|
|
260
|
+
* stateExtension: { deserialize: z.object({ productsFound: z.number().default(0) }) },
|
|
261
|
+
* });
|
|
262
|
+
* ```
|
|
263
|
+
*
|
|
264
|
+
* Without it, the declared fields are restored as they were persisted - which is a record off the key-value
|
|
265
|
+
* store taken at its word, `productsFound` included in whatever type it happens to hold.
|
|
266
|
+
*
|
|
267
|
+
* A conversion that rejects the record costs the custom fields their persisted values (they start from the
|
|
268
|
+
* defaults, with a warning) and nothing else.
|
|
269
|
+
*/
|
|
270
|
+
deserialize?: SyncStateConversion<unknown, StateExtension>;
|
|
271
|
+
/**
|
|
272
|
+
* Converts the custom fields to the JSON-serializable form they are persisted in. Not needed for fields that
|
|
273
|
+
* already are one - pair it with `deserialize` for the fields that are not.
|
|
274
|
+
*/
|
|
275
|
+
serialize?: SyncStateConversion<StateExtension, PersistedStateExtension>;
|
|
236
276
|
}
|
|
237
277
|
/**
|
|
238
278
|
* Format of the persisted stats.
|
package/crawlers/statistics.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { RecoverableState } from '../recoverable_state.js';
|
|
2
|
+
import { convertStateSync, RecoverableState } from '../recoverable_state.js';
|
|
3
3
|
import { serviceLocator } from '../service_locator.js';
|
|
4
4
|
import { KeyValueStore } from '../storages/key_value_store.js';
|
|
5
5
|
import { parseArgument, schemas, validators } from '../validators.js';
|
|
@@ -27,6 +27,7 @@ const statisticsOptionsSchema = z.strictObject({
|
|
|
27
27
|
persistenceOptions: schemas.anyObject.default(() => ({ enable: true })),
|
|
28
28
|
saveErrorSnapshots: z.boolean().default(false),
|
|
29
29
|
id: z.union([schemas.anyNumber, z.string()]).optional(),
|
|
30
|
+
stateExtension: schemas.anyObject.default(() => ({})),
|
|
30
31
|
});
|
|
31
32
|
const errorTrackerConfig = {
|
|
32
33
|
showErrorCode: true,
|
|
@@ -44,12 +45,14 @@ const errorTrackerConfig = {
|
|
|
44
45
|
* `null`. Both {@link Statistics.serializeState} and {@link Statistics.deserializeState} run through this,
|
|
45
46
|
* which is what keeps them describing the same record.
|
|
46
47
|
*
|
|
47
|
-
* Nothing is optional on purpose:
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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.
|
|
50
54
|
*/
|
|
51
|
-
const persistedStatisticState = z
|
|
52
|
-
.object({
|
|
55
|
+
const persistedStatisticState = z.object({
|
|
53
56
|
requestsFinished: z.number(),
|
|
54
57
|
requestsFailed: z.number(),
|
|
55
58
|
requestsRetries: z.number(),
|
|
@@ -75,10 +78,7 @@ const persistedStatisticState = z
|
|
|
75
78
|
requestsWithStatusCode: z.record(z.string(), z.number()),
|
|
76
79
|
errors: z.record(z.string(), z.unknown()),
|
|
77
80
|
retryErrors: z.record(z.string(), z.unknown()),
|
|
78
|
-
})
|
|
79
|
-
// A subclass tracking extra fields spreads them into the record; they are none of this schema's business,
|
|
80
|
-
// but they must not be dropped on the way through it.
|
|
81
|
-
.catchall(z.unknown());
|
|
81
|
+
});
|
|
82
82
|
/** `Infinity` is what the statistics use for "nothing to average yet"; JSON has only `null` for it. */
|
|
83
83
|
function finiteOrNull(value) {
|
|
84
84
|
return Number.isFinite(value) ? value : null;
|
|
@@ -88,8 +88,7 @@ function finiteOrNull(value) {
|
|
|
88
88
|
*
|
|
89
89
|
* Built per instance rather than kept as a constant because a record carries three things the state does not: the
|
|
90
90
|
* instance `id`, the derived aggregates of the overridable {@link Statistics.calculate}, and - on the way back -
|
|
91
|
-
* the fields that are rebuilt from
|
|
92
|
-
* among them.
|
|
91
|
+
* the fields that are rebuilt from the instance's default state rather than restored, the error trackers among them.
|
|
93
92
|
*
|
|
94
93
|
* The model side is deliberately opaque: zod rebuilds what it validates, and `state.errors` has to stay the very
|
|
95
94
|
* object the error trackers write into, not a copy of it.
|
|
@@ -153,6 +152,9 @@ function buildStatisticStateCodec(statistics) {
|
|
|
153
152
|
* under the key `CRAWLEE_CRAWLER_STATISTICS_*`, persists between
|
|
154
153
|
* migrations and abort/resurrect
|
|
155
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
|
+
*
|
|
156
158
|
* @category Crawlers
|
|
157
159
|
*/
|
|
158
160
|
export class Statistics {
|
|
@@ -170,9 +172,12 @@ export class Statistics {
|
|
|
170
172
|
* Statistic instance id.
|
|
171
173
|
*/
|
|
172
174
|
id;
|
|
173
|
-
persistStateKey;
|
|
175
|
+
#persistStateKey;
|
|
174
176
|
#stateCodec;
|
|
175
177
|
#recoverableState;
|
|
178
|
+
#stateExtension;
|
|
179
|
+
#defaultStateExtension;
|
|
180
|
+
#stateExtensionKeys;
|
|
176
181
|
#logIntervalMillis;
|
|
177
182
|
#logMessage;
|
|
178
183
|
#requestsInProgress = new Map();
|
|
@@ -192,31 +197,40 @@ export class Statistics {
|
|
|
192
197
|
}
|
|
193
198
|
/**
|
|
194
199
|
* Construct a statistics instance to pass to a crawler via its `statistics` option, e.g. to preconfigure
|
|
195
|
-
* persistence or error snapshots, share it across sequential runs, or
|
|
200
|
+
* persistence or error snapshots, share it across sequential runs, or track extra fields via `state`.
|
|
196
201
|
*/
|
|
197
202
|
constructor(options = {}) {
|
|
198
|
-
const { logIntervalSecs, logMessage, log, keyValueStore, persistenceOptions, saveErrorSnapshots, id } = parseArgument(options, statisticsOptionsSchema);
|
|
203
|
+
const { logIntervalSecs, logMessage, log, keyValueStore, persistenceOptions, saveErrorSnapshots, id, stateExtension, } = parseArgument(options, statisticsOptionsSchema);
|
|
199
204
|
this.id = id ?? String(Statistics.id++);
|
|
200
|
-
this
|
|
205
|
+
this.#persistStateKey = `CRAWLEE_CRAWLER_STATISTICS_${this.id}`;
|
|
201
206
|
this.log = (log ?? serviceLocator.getLogger()).child({ prefix: 'Statistics' });
|
|
202
207
|
this.errorTracker = new ErrorTracker({ ...errorTrackerConfig, saveErrorSnapshots });
|
|
203
208
|
this.errorTrackerRetry = new ErrorTracker({ ...errorTrackerConfig, saveErrorSnapshots });
|
|
204
209
|
this.#logIntervalMillis = logIntervalSecs * 1000;
|
|
205
210
|
this.#logMessage = logMessage;
|
|
206
|
-
|
|
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.
|
|
207
221
|
this.#stateCodec = buildStatisticStateCodec({
|
|
208
222
|
statsId: this.id,
|
|
209
|
-
defaultState: () => this
|
|
223
|
+
defaultState: () => this.#defaultState(),
|
|
210
224
|
calculate: () => this.calculate(),
|
|
211
225
|
});
|
|
212
226
|
this.#recoverableState = new RecoverableState({
|
|
213
|
-
persistStateKey: this
|
|
227
|
+
persistStateKey: this.#persistStateKey,
|
|
214
228
|
persistenceEnabled: persistenceOptions.enable,
|
|
215
229
|
keyValueStore,
|
|
216
230
|
logger: this.log,
|
|
217
|
-
defaultState: () => this
|
|
218
|
-
serialize: (state) => this
|
|
219
|
-
deserialize: (persistedState) => this
|
|
231
|
+
defaultState: () => this.#defaultState(),
|
|
232
|
+
serialize: (state) => this.#serializeState(state),
|
|
233
|
+
deserialize: (persistedState) => this.#deserializeState(persistedState),
|
|
220
234
|
});
|
|
221
235
|
// initialize by "resetting"
|
|
222
236
|
this.reset();
|
|
@@ -232,12 +246,44 @@ export class Statistics {
|
|
|
232
246
|
this.#recoverableState.reset();
|
|
233
247
|
this.#requestsInProgress.clear();
|
|
234
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
|
+
}
|
|
235
256
|
/**
|
|
236
|
-
* The
|
|
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.
|
|
237
259
|
*
|
|
238
|
-
*
|
|
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.
|
|
239
263
|
*/
|
|
240
|
-
|
|
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() {
|
|
241
287
|
return {
|
|
242
288
|
requestsFinished: 0,
|
|
243
289
|
requestsFailed: 0,
|
|
@@ -390,28 +436,58 @@ export class Statistics {
|
|
|
390
436
|
async persistState() {
|
|
391
437
|
await this.#recoverableState
|
|
392
438
|
.persistState()
|
|
393
|
-
.catch((error) => this.log.warning(`Failed to persist the statistics to ${this
|
|
439
|
+
.catch((error) => this.log.warning(`Failed to persist the statistics to ${this.#persistStateKey}`, { error }));
|
|
394
440
|
}
|
|
395
|
-
/**
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
deserializeState(persistedState) {
|
|
401
|
-
// The cast is the index signature the `catchall` puts on the schema and an interface cannot have - the
|
|
402
|
-
// record is an unvalidated blob off the key-value store either way, which is what the decode is for.
|
|
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.
|
|
403
446
|
const restored = z.safeDecode(this.#stateCodec, persistedState);
|
|
404
447
|
if (!restored.success) {
|
|
405
448
|
// Statistics are bookkeeping - a record that cannot be made sense of is worth a warning and a fresh
|
|
406
449
|
// start, not a failed crawl.
|
|
407
450
|
this.log.warning('Received invalid state from Key-value store, starting the statistics from scratch.', {
|
|
408
|
-
persistStateKey: this
|
|
451
|
+
persistStateKey: this.#persistStateKey,
|
|
409
452
|
issues: restored.error.issues,
|
|
410
453
|
});
|
|
411
|
-
return this
|
|
454
|
+
return this.#defaultState();
|
|
455
|
+
}
|
|
456
|
+
this.log.debug('Recreating state from KeyValueStore', { persistStateKey: this.#persistStateKey });
|
|
457
|
+
return { ...this.#defaultState(), ...restored.data, ...this.#restoreStateExtension(persistedState) };
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
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.
|
|
466
|
+
*/
|
|
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;
|
|
481
|
+
}
|
|
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 {};
|
|
412
490
|
}
|
|
413
|
-
this.log.debug('Recreating state from KeyValueStore', { persistStateKey: this.persistStateKey });
|
|
414
|
-
return { ...this.defaultState(), ...restored.data };
|
|
415
491
|
}
|
|
416
492
|
#stopLogging() {
|
|
417
493
|
if (this.#logInterval) {
|
|
@@ -423,14 +499,38 @@ export class Statistics {
|
|
|
423
499
|
* Builds the record written to the key value store, merging in the derived aggregates so that a consumer
|
|
424
500
|
* reading the record does not have to reconstruct them.
|
|
425
501
|
*/
|
|
426
|
-
serializeState(state) {
|
|
427
|
-
|
|
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;
|
|
514
|
+
}
|
|
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);
|
|
518
|
+
}
|
|
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];
|
|
526
|
+
}
|
|
527
|
+
return { builtIn, extension };
|
|
428
528
|
}
|
|
429
529
|
/**
|
|
430
530
|
* Make this class serializable when called with `JSON.stringify(statsInstance)` directly
|
|
431
531
|
* or through `keyValueStore.setValue('KEY', statsInstance)`
|
|
432
532
|
*/
|
|
433
533
|
toJSON() {
|
|
434
|
-
return this
|
|
534
|
+
return this.#serializeState(this.state);
|
|
435
535
|
}
|
|
436
536
|
}
|
package/events/event_manager.js
CHANGED
|
@@ -18,7 +18,8 @@ export class EventManager {
|
|
|
18
18
|
#persistStateIntervalMillis;
|
|
19
19
|
constructor(options) {
|
|
20
20
|
this.#persistStateIntervalMillis = options.persistStateIntervalMillis;
|
|
21
|
-
|
|
21
|
+
// One MIGRATING listener per RequestQueue, and ThrottlingRequestManager opens one per domain.
|
|
22
|
+
this.events.setMaxListeners(150);
|
|
22
23
|
}
|
|
23
24
|
/**
|
|
24
25
|
* Initializes the event manager by starting the `persistState` event interval.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlee/core",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.125",
|
|
4
4
|
"description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0"
|
|
@@ -52,11 +52,12 @@
|
|
|
52
52
|
"@apify/log": "^2.5.18",
|
|
53
53
|
"@apify/timeout": "^0.4.4",
|
|
54
54
|
"@apify/utilities": "^2.15.5",
|
|
55
|
-
"@crawlee/fs-storage": "4.0.0-beta.
|
|
56
|
-
"@crawlee/http-client": "4.0.0-beta.
|
|
57
|
-
"@crawlee/types": "4.0.0-beta.
|
|
58
|
-
"@crawlee/utils": "4.0.0-beta.
|
|
55
|
+
"@crawlee/fs-storage": "4.0.0-beta.125",
|
|
56
|
+
"@crawlee/http-client": "4.0.0-beta.125",
|
|
57
|
+
"@crawlee/types": "4.0.0-beta.125",
|
|
58
|
+
"@crawlee/utils": "4.0.0-beta.125",
|
|
59
59
|
"@sapphire/async-queue": "^1.5.5",
|
|
60
|
+
"@standard-schema/spec": "^1.0.0",
|
|
60
61
|
"@vladfrangu/async_event_emitter": "^2.4.6",
|
|
61
62
|
"content-type": "^1.0.5",
|
|
62
63
|
"csv-stringify": "^6.5.2",
|
|
@@ -77,5 +78,5 @@
|
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
},
|
|
80
|
-
"gitHead": "
|
|
81
|
+
"gitHead": "04a7212dd4abe4a5f94f5fc9632acb6089819c8b"
|
|
81
82
|
}
|
package/recoverable_state.d.ts
CHANGED
|
@@ -11,6 +11,21 @@ import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
|
11
11
|
* other one.
|
|
12
12
|
*/
|
|
13
13
|
export type StateConversion<TFrom, TTo> = ((value: TFrom) => Awaitable<TTo>) | StandardSchemaV1<TFrom, TTo>;
|
|
14
|
+
/**
|
|
15
|
+
* A {@link StateConversion} for a caller that cannot await one - {@link Statistics}, whose `toJSON()` is
|
|
16
|
+
* synchronous, being the reason this exists.
|
|
17
|
+
*
|
|
18
|
+
* Only the function arm can be narrowed here: a Standard Schema is free to validate asynchronously, so a schema
|
|
19
|
+
* that does is rejected when it runs rather than when it is passed.
|
|
20
|
+
*/
|
|
21
|
+
export type SyncStateConversion<TFrom, TTo> = ((value: TFrom) => TTo) | StandardSchemaV1<TFrom, TTo>;
|
|
22
|
+
/**
|
|
23
|
+
* Applies a {@link SyncStateConversion}, throwing a {@link StateValidationError} for a schema that rejects
|
|
24
|
+
* the value.
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export declare function convertStateSync<TFrom, TTo>(conversion: SyncStateConversion<TFrom, TTo>, value: TFrom, persistStateKey: string): TTo;
|
|
14
29
|
export interface RecoverableStatePersistenceOptions {
|
|
15
30
|
/**
|
|
16
31
|
* The key under which the state is stored in the KeyValueStore
|
package/recoverable_state.js
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { addTimeoutToPromise, storage as timeoutStorage } from '@apify/timeout';
|
|
2
2
|
import { EventType, KeyValueStore, serviceLocator, StateValidationError } from '@crawlee/core';
|
|
3
3
|
const DEFAULT_PERSISTENCE_TIMEOUT_MILLIS = 60_000;
|
|
4
|
+
/**
|
|
5
|
+
* Applies a {@link SyncStateConversion}, throwing a {@link StateValidationError} for a schema that rejects
|
|
6
|
+
* the value.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export function convertStateSync(conversion, value, persistStateKey) {
|
|
11
|
+
if (typeof conversion === 'function') {
|
|
12
|
+
return conversion(value);
|
|
13
|
+
}
|
|
14
|
+
const result = conversion['~standard'].validate(value);
|
|
15
|
+
if ('then' in result) {
|
|
16
|
+
throw new Error(`The state conversion for '${persistStateKey}' validated asynchronously, which this caller cannot await.`);
|
|
17
|
+
}
|
|
18
|
+
if (result.issues) {
|
|
19
|
+
throw new StateValidationError(persistStateKey, result.issues);
|
|
20
|
+
}
|
|
21
|
+
return result.value;
|
|
22
|
+
}
|
|
4
23
|
/**
|
|
5
24
|
* A class for managing persistent recoverable state using a plain JavaScript object.
|
|
6
25
|
*
|