@crawlee/core 4.0.0-beta.162 → 4.0.0-beta.163

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/core",
3
- "version": "4.0.0-beta.162",
3
+ "version": "4.0.0-beta.163",
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,10 +52,10 @@
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.162",
56
- "@crawlee/http-client": "4.0.0-beta.162",
57
- "@crawlee/types": "4.0.0-beta.162",
58
- "@crawlee/utils": "4.0.0-beta.162",
55
+ "@crawlee/fs-storage": "4.0.0-beta.163",
56
+ "@crawlee/http-client": "4.0.0-beta.163",
57
+ "@crawlee/types": "4.0.0-beta.163",
58
+ "@crawlee/utils": "4.0.0-beta.163",
59
59
  "@sapphire/async-queue": "^1.5.5",
60
60
  "@standard-schema/spec": "^1.0.0",
61
61
  "@vladfrangu/async_event_emitter": "^2.4.6",
@@ -78,5 +78,5 @@
78
78
  }
79
79
  }
80
80
  },
81
- "gitHead": "0896509b1e3df39639d2224b2d565fc592939fd7"
81
+ "gitHead": "3bcb1a803a1ba3082734c4b1aee72f7b3b51f427"
82
82
  }
@@ -102,7 +102,8 @@ export declare class RecoverableState<TStateModel = Record<string, unknown>, TPe
102
102
  * is no record to restore.
103
103
  *
104
104
  * Calling this again after a {@link RecoverableState.teardown} starts a new persistence window - the
105
- * listener is registered again and the record reloaded.
105
+ * listener is registered again. The record is not reloaded: the in-memory state is what the teardown wrote, and
106
+ * reloading it would only drop whatever the deserialization leaves out.
106
107
  *
107
108
  * @returns The loaded state object
108
109
  */
@@ -139,7 +140,7 @@ export declare class RecoverableState<TStateModel = Record<string, unknown>, TPe
139
140
  * one would write the record straight back. Use {@link RecoverableState.reset} to reset the state itself,
140
141
  * or {@link RecoverableState.teardown} before clearing the record.
141
142
  *
142
- * A no-op if persistence is disabled or no KeyValueStore is available yet.
143
+ * A no-op if persistence is disabled.
143
144
  */
144
145
  resetStore(): Promise<void>;
145
146
  /**
@@ -34,6 +34,7 @@ export class RecoverableState {
34
34
  #defaultState;
35
35
  #state = null;
36
36
  #initialized = false;
37
+ #recordLoaded = false;
37
38
  #listening = false;
38
39
  #persistenceEnabled;
39
40
  #persistStateKey;
@@ -63,6 +64,12 @@ export class RecoverableState {
63
64
  this.#log = options.logger ?? serviceLocator.getLogger().child({ prefix: 'RecoverableState' });
64
65
  this.#serialize = this.#toConversion(options.serialize);
65
66
  this.#deserialize = this.#toConversion(options.deserialize);
67
+ // A pending open() may reject before anything here awaits it. Observe the rejection so it surfaces on
68
+ // first use rather than as an unhandled rejection - most immediately when persistence is disabled and
69
+ // nothing ever awaits the store.
70
+ if (this.#keyValueStore !== null) {
71
+ Promise.resolve(this.#keyValueStore).then(undefined, () => { });
72
+ }
66
73
  // The automatic persists, where a rejection has nowhere useful to go - the event manager does not catch
67
74
  // listener errors, and throwing from teardown would bury the outcome of the work it cleans up after.
68
75
  this.#persistStateQuietly = async (eventData) => this.persistState(eventData).catch((error) => this.#log.warning(`Failed to persist the state under key '${this.#persistStateKey}'.`, { error }));
@@ -91,7 +98,8 @@ export class RecoverableState {
91
98
  * is no record to restore.
92
99
  *
93
100
  * Calling this again after a {@link RecoverableState.teardown} starts a new persistence window - the
94
- * listener is registered again and the record reloaded.
101
+ * listener is registered again. The record is not reloaded: the in-memory state is what the teardown wrote, and
102
+ * reloading it would only drop whatever the deserialization leaves out.
95
103
  *
96
104
  * @returns The loaded state object
97
105
  */
@@ -100,10 +108,7 @@ export class RecoverableState {
100
108
  return this.currentValue;
101
109
  }
102
110
  if (this.#persistenceEnabled) {
103
- this.#keyValueStore ??= KeyValueStore.open(null, {
104
- configuration: this.#configuration ?? serviceLocator.getConfiguration(),
105
- });
106
- await this.#resolveKeyValueStore();
111
+ await this.#openKeyValueStore();
107
112
  serviceLocator.getEventManager().on(EventType.PERSIST_STATE, this.#persistStateQuietly);
108
113
  this.#listening = true;
109
114
  }
@@ -111,7 +116,10 @@ export class RecoverableState {
111
116
  // fully wired object running on the default state rather than a half-initialized one.
112
117
  this.#initialized = true;
113
118
  this.#state ??= this.#defaultState();
114
- await this.#loadSavedState();
119
+ if (!this.#recordLoaded) {
120
+ this.#recordLoaded = true;
121
+ await this.#loadSavedState();
122
+ }
115
123
  return this.currentValue;
116
124
  }
117
125
  /**
@@ -161,7 +169,7 @@ export class RecoverableState {
161
169
  * one would write the record straight back. Use {@link RecoverableState.reset} to reset the state itself,
162
170
  * or {@link RecoverableState.teardown} before clearing the record.
163
171
  *
164
- * A no-op if persistence is disabled or no KeyValueStore is available yet.
172
+ * A no-op if persistence is disabled.
165
173
  */
166
174
  async resetStore() {
167
175
  if (this.#listening) {
@@ -170,10 +178,7 @@ export class RecoverableState {
170
178
  if (!this.#persistenceEnabled) {
171
179
  return;
172
180
  }
173
- const keyValueStore = await this.#resolveKeyValueStore();
174
- if (keyValueStore === null) {
175
- return;
176
- }
181
+ const keyValueStore = await this.#openKeyValueStore();
177
182
  await this.#withTimeout(async () => keyValueStore.setValue(this.#persistStateKey, null), 'Clearing the persisted state');
178
183
  }
179
184
  /**
@@ -197,6 +202,13 @@ export class RecoverableState {
197
202
  const serializedState = await this.#serialize(this.currentValue);
198
203
  await this.#withTimeout(async () => keyValueStore.setValue(this.#persistStateKey, serializedState), 'Persisting the state');
199
204
  }
205
+ /** Falls back to the default store when none was supplied - persistence has to write somewhere. */
206
+ async #openKeyValueStore() {
207
+ this.#keyValueStore ??= KeyValueStore.open(null, {
208
+ configuration: this.#configuration ?? serviceLocator.getConfiguration(),
209
+ });
210
+ return (await this.#resolveKeyValueStore());
211
+ }
200
212
  /** Awaits a store handed over as a pending `open()`, keeping the resolved instance for later calls. */
201
213
  async #resolveKeyValueStore() {
202
214
  if (this.#keyValueStore === null) {
@@ -119,7 +119,7 @@ export declare class Session implements ISession {
119
119
  markGood(): void;
120
120
  /**
121
121
  * Gets session state for persistence in KeyValueStore.
122
- * @returns Represents session internal state.
122
+ * @internal
123
123
  */
124
124
  getState(): SessionState;
125
125
  /**
@@ -148,7 +148,7 @@ export class Session {
148
148
  }
149
149
  /**
150
150
  * Gets session state for persistence in KeyValueStore.
151
- * @returns Represents session internal state.
151
+ * @internal
152
152
  */
153
153
  getState() {
154
154
  return {
@@ -1,10 +1,21 @@
1
- import type { ISessionPool } from '@crawlee/types';
1
+ import type { ISessionPool, SessionState } from '@crawlee/types';
2
+ import { z } from 'zod';
2
3
  import type { PersistenceOptions } from '../crawlers/statistics.js';
3
4
  import type { CrawleeLogger } from '../log.js';
4
5
  import type { SessionOptions } from './session.js';
5
6
  import { Session } from './session.js';
6
7
  declare const SESSION_REUSE_STRATEGIES: readonly ['random', 'round-robin', 'use-until-failure'];
7
8
  export type SessionReuseStrategy = (typeof SESSION_REUSE_STRATEGIES)[number];
9
+ declare const persistedSessionPoolState: z.ZodObject<{
10
+ usableSessionsCount: z.ZodNumber;
11
+ retiredSessionsCount: z.ZodNumber;
12
+ sessions: z.ZodArray<z.ZodCustom<SessionState, SessionState>>;
13
+ }, z.core.$strip>;
14
+ /**
15
+ * The shape of the persisted {@link SessionPool} record, as returned by {@link SessionPool.getState}.
16
+ * @internal
17
+ */
18
+ export type SessionPoolPersistedState = z.infer<typeof persistedSessionPoolState>;
8
19
  /**
9
20
  * Factory user-function which creates customized {@link Session} instances.
10
21
  */
@@ -152,34 +163,36 @@ export declare class SessionPool implements ISessionPool {
152
163
  */
153
164
  getSession(sessionId?: string): Promise<Session | undefined>;
154
165
  /**
155
- * @param options - Override the persistence options provided in the constructor
166
+ * Discards all sessions in the pool, both usable and retired, resetting it to a blank state.
167
+ *
168
+ * The persisted record is left alone - use {@link SessionPool.resetStore} to clear that as well.
169
+ */
170
+ reset(): void;
171
+ /**
172
+ * Clears the persisted pool record, leaving the in-memory sessions alone.
173
+ *
174
+ * Throws while the state is still being persisted periodically - the next PERSIST_STATE event would write
175
+ * the record straight back. Call {@link SessionPool.teardown} first, or use {@link SessionPool.reset}
176
+ * to discard the sessions themselves. A no-op if persistence is disabled.
156
177
  */
157
- resetStore(options?: PersistenceOptions): Promise<void>;
178
+ resetStore(): Promise<void>;
158
179
  /**
159
180
  * Returns an object representing the internal state of the `SessionPool` instance.
160
- * Note that the object's fields can change in future releases.
181
+ * @internal
161
182
  */
162
- getState(): Promise<{
163
- usableSessionsCount: number;
164
- retiredSessionsCount: number;
165
- // @ts-ignore optional peer dependency or compatibility with es2022
166
- sessions: import("@crawlee/types").SessionState[];
167
- }>;
183
+ getState(): Promise<SessionPoolPersistedState>;
168
184
  /**
169
185
  * Persists the current state of the `SessionPool` into the default {@link KeyValueStore}.
170
186
  * The state is persisted automatically in regular intervals.
171
- * @param options - Override the persistence options provided in the constructor
172
187
  */
173
- persistState(options?: PersistenceOptions): Promise<void>;
188
+ persistState(): Promise<void>;
174
189
  [Symbol.asyncDispose](): Promise<void>;
175
190
  /**
176
- * Removes listener from `persistState` event.
191
+ * Stops the periodic state persistence and persists the state one last time.
177
192
  * This function should be called after you are done with using the `SessionPool` instance.
178
- * @param options - Set `persistState` to false when the final state was already persisted by the event manager.
193
+ * Using the pool again afterwards initializes it anew.
179
194
  */
180
- teardown({ persistState }?: {
181
- persistState?: boolean;
182
- }): Promise<void>;
195
+ teardown(): Promise<void>;
183
196
  /**
184
197
  * Removes retired `Session` instances from `SessionPool`.
185
198
  */
@@ -225,10 +238,5 @@ export declare class SessionPool implements ISessionPool {
225
238
  * Returns `undefined` when no session should be reused and a new one should be created instead.
226
239
  */
227
240
  private pickSession;
228
- /**
229
- * Potentially loads `SessionPool`.
230
- * If the state was persisted it loads the `SessionPool` from the persisted state.
231
- */
232
- private maybeLoadSessionPool;
233
241
  }
234
242
  export {};
@@ -1,6 +1,6 @@
1
1
  import { AsyncQueue } from '@sapphire/async-queue';
2
2
  import { z } from 'zod';
3
- import { EventType } from '../events/event_manager.js';
3
+ import { RecoverableState } from '../recoverable_state.js';
4
4
  import { serviceLocator } from '../service_locator.js';
5
5
  import { KeyValueStore } from '../storages/key_value_store.js';
6
6
  import { parseArgument, schemas, validators } from '../validators.js';
@@ -24,6 +24,43 @@ const sessionPoolOptionsSchema = z.strictObject({
24
24
  const createSessionOptionsSchema = z.strictObject({
25
25
  sessionOptions: schemas.anyObject.default(() => ({})),
26
26
  });
27
+ // Only what the recreation reads is pinned down - the rest of a session's state is the session's own business.
28
+ const persistedSessionState = z.custom((value) => typeof value === 'object' &&
29
+ value !== null &&
30
+ typeof value.createdAt === 'string' &&
31
+ typeof value.expiresAt === 'string', 'Expected a persisted session with `createdAt` and `expiresAt` timestamps');
32
+ const persistedSessionPoolState = z.object({
33
+ usableSessionsCount: z.number(),
34
+ retiredSessionsCount: z.number(),
35
+ sessions: z.array(persistedSessionState),
36
+ });
37
+ const sessionPoolInternalState = z.object({
38
+ sessions: z.array(z.instanceof(Session)),
39
+ });
40
+ /**
41
+ * The conversion between the pool's sessions and its persisted record. Decoding recreates the sessions through
42
+ * the pool's factory and keeps only the usable ones, which is why the codec is built per pool.
43
+ */
44
+ function buildSessionPoolStateCodec(pool) {
45
+ return z.codec(persistedSessionPoolState, sessionPoolInternalState, {
46
+ decode: async (record) => {
47
+ const sessions = [];
48
+ for (const sessionState of record.sessions) {
49
+ const session = await pool.recreateSession({
50
+ ...sessionState,
51
+ createdAt: new Date(sessionState.createdAt),
52
+ expiresAt: new Date(sessionState.expiresAt),
53
+ });
54
+ if (session.isUsable()) {
55
+ sessions.push(session);
56
+ }
57
+ }
58
+ pool.log.debug(`${sessions.length} active sessions loaded from KeyValueStore`);
59
+ return { sessions };
60
+ },
61
+ encode: ({ sessions }) => pool.toRecord(sessions),
62
+ });
63
+ }
27
64
  /**
28
65
  * Handles the rotation, creation and persistence of user-like sessions.
29
66
  * Creates a pool of {@link Session} instances, that are randomly rotated.
@@ -81,18 +118,20 @@ export class SessionPool {
81
118
  static #nextId = 0;
82
119
  id;
83
120
  #log;
84
- #sessions = [];
85
121
  #maxPoolSize;
86
122
  #createSessionFunction;
87
- #keyValueStore;
88
123
  #sessionMap = new Map();
89
124
  #sessionOptions;
90
125
  #persistStateKeyValueStoreId;
91
126
  #persistStateKey;
92
- #listener;
93
- #events;
94
127
  #persistenceOptions;
95
128
  #sessionReuseStrategy;
129
+ /**
130
+ * The sessions live in a {@link RecoverableState} that handles the loading, recreation and periodic
131
+ * persistence of the record under `persistStateKey`.
132
+ */
133
+ #state;
134
+ #stateCodec;
96
135
  #initPromise;
97
136
  #queue = new AsyncQueue();
98
137
  #roundRobinIndex = 0;
@@ -100,7 +139,6 @@ export class SessionPool {
100
139
  const { id, maxPoolSize, persistStateKeyValueStoreId, persistStateKey, createSessionFunction, sessionOptions, log, persistenceOptions, sessionReuseStrategy, } = parseArgument(options, sessionPoolOptionsSchema);
101
140
  this.id = id != null ? String(id) : String(SessionPool.#nextId++);
102
141
  this.#sessionReuseStrategy = sessionReuseStrategy;
103
- this.#events = serviceLocator.getEventManager();
104
142
  this.#log = log.child({ prefix: 'SessionPool' });
105
143
  this.#persistenceOptions = persistenceOptions;
106
144
  // Pool Configuration
@@ -116,20 +154,38 @@ export class SessionPool {
116
154
  // Session keyValueStore
117
155
  this.#persistStateKeyValueStoreId = persistStateKeyValueStoreId;
118
156
  this.#persistStateKey = persistStateKey ?? `${PERSIST_STATE_KEY}_${this.id}`;
157
+ this.#stateCodec = buildSessionPoolStateCodec({
158
+ recreateSession: (sessionState) => this.invokeCreateSessionFunction(sessionState),
159
+ toRecord: (sessions) => this.#buildPersistedState(sessions),
160
+ log: this.#log,
161
+ });
162
+ this.#state = new RecoverableState({
163
+ defaultState: () => ({ sessions: [] }),
164
+ persistStateKey: this.#persistStateKey,
165
+ persistenceEnabled: !!this.#persistenceOptions.enable,
166
+ logger: this.#log,
167
+ keyValueStore: this.#persistenceOptions.enable && persistStateKeyValueStoreId
168
+ ? KeyValueStore.open({ id: persistStateKeyValueStoreId }, { configuration: serviceLocator.getConfiguration() })
169
+ : undefined,
170
+ // The codec validates in the decode direction, so it is a Standard Schema as-is; encoding needs a call.
171
+ deserialize: this.#stateCodec,
172
+ serialize: (state) => this.#stateCodec.encode(state),
173
+ });
174
+ this.#state.reset();
119
175
  }
120
176
  /**
121
177
  * Gets count of usable sessions in the pool.
122
178
  */
123
179
  async usableSessionsCount() {
124
180
  await this.ensureInitialized();
125
- return this.#sessions.filter((session) => session.isUsable()).length;
181
+ return this.#state.currentValue.sessions.filter((session) => session.isUsable()).length;
126
182
  }
127
183
  /**
128
184
  * Gets count of retired sessions in the pool.
129
185
  */
130
186
  async retiredSessionsCount() {
131
187
  await this.ensureInitialized();
132
- return this.#sessions.filter((session) => !session.isUsable()).length;
188
+ return this.#state.currentValue.sessions.filter((session) => !session.isUsable()).length;
133
189
  }
134
190
  /**
135
191
  * Starts periodic state persistence and potentially loads SessionPool state from {@link KeyValueStore}.
@@ -142,19 +198,10 @@ export class SessionPool {
142
198
  return this.#initPromise;
143
199
  }
144
200
  async setupPool() {
145
- if (!this.#persistenceOptions.enable) {
146
- return;
147
- }
148
- this.#keyValueStore = await KeyValueStore.open(this.#persistStateKeyValueStoreId ? { id: this.#persistStateKeyValueStoreId } : null, {
149
- configuration: serviceLocator.getConfiguration(),
150
- });
151
- if (!this.#persistStateKeyValueStoreId) {
152
- this.#log.debug(`No 'persistStateKeyValueStoreId' options specified, this session pool's data has been saved in the KeyValueStore with the id: ${this.#keyValueStore.id}`);
201
+ await this.#state.initialize();
202
+ for (const session of this.#state.currentValue.sessions) {
203
+ this.#sessionMap.set(session.id, session);
153
204
  }
154
- // in case of migration happened and SessionPool state should be restored from the keyValueStore.
155
- await this.maybeLoadSessionPool();
156
- this.#listener = this.persistState.bind(this);
157
- this.#events.on(EventType.PERSIST_STATE, this.#listener);
158
205
  }
159
206
  /**
160
207
  * Adds a new session to the session pool. The pool automatically creates sessions up to the maximum size of the pool,
@@ -221,34 +268,39 @@ export class SessionPool {
221
268
  }
222
269
  }
223
270
  /**
224
- * @param options - Override the persistence options provided in the constructor
271
+ * Discards all sessions in the pool, both usable and retired, resetting it to a blank state.
272
+ *
273
+ * The persisted record is left alone - use {@link SessionPool.resetStore} to clear that as well.
225
274
  */
226
- async resetStore(options) {
227
- if (!this.#persistenceOptions.enable && !options?.enable) {
228
- return;
229
- }
230
- await this.ensureInitialized();
231
- await this.#keyValueStore?.setValue(this.#persistStateKey, null);
275
+ reset() {
276
+ this.#state.reset();
277
+ this.#sessionMap.clear();
278
+ this.#roundRobinIndex = 0;
279
+ }
280
+ /**
281
+ * Clears the persisted pool record, leaving the in-memory sessions alone.
282
+ *
283
+ * Throws while the state is still being persisted periodically - the next PERSIST_STATE event would write
284
+ * the record straight back. Call {@link SessionPool.teardown} first, or use {@link SessionPool.reset}
285
+ * to discard the sessions themselves. A no-op if persistence is disabled.
286
+ */
287
+ async resetStore() {
288
+ await this.#state.resetStore();
232
289
  }
233
290
  /**
234
291
  * Returns an object representing the internal state of the `SessionPool` instance.
235
- * Note that the object's fields can change in future releases.
292
+ * @internal
236
293
  */
237
294
  async getState() {
238
295
  await this.ensureInitialized();
239
- return {
240
- usableSessionsCount: await this.usableSessionsCount(),
241
- retiredSessionsCount: await this.retiredSessionsCount(),
242
- sessions: this.#sessions.map((session) => session.getState()),
243
- };
296
+ return this.#buildPersistedState(this.#state.currentValue.sessions);
244
297
  }
245
298
  /**
246
299
  * Persists the current state of the `SessionPool` into the default {@link KeyValueStore}.
247
300
  * The state is persisted automatically in regular intervals.
248
- * @param options - Override the persistence options provided in the constructor
249
301
  */
250
- async persistState(options) {
251
- if (!this.#persistenceOptions.enable && !options?.enable) {
302
+ async persistState() {
303
+ if (!this.#persistenceOptions.enable) {
252
304
  return;
253
305
  }
254
306
  await this.ensureInitialized();
@@ -256,34 +308,32 @@ export class SessionPool {
256
308
  persistStateKeyValueStoreId: this.#persistStateKeyValueStoreId,
257
309
  persistStateKey: this.#persistStateKey,
258
310
  });
259
- await this.#keyValueStore
260
- ?.setValue(this.#persistStateKey, await this.getState())
311
+ await this.#state
312
+ .persistState()
261
313
  .catch((error) => this.#log.warning(`Failed to persist the session pool stats to ${this.#persistStateKey}`, { error }));
262
314
  }
263
315
  async [Symbol.asyncDispose]() {
264
- await this.teardown({ persistState: true });
316
+ await this.teardown();
265
317
  }
266
318
  /**
267
- * Removes listener from `persistState` event.
319
+ * Stops the periodic state persistence and persists the state one last time.
268
320
  * This function should be called after you are done with using the `SessionPool` instance.
269
- * @param options - Set `persistState` to false when the final state was already persisted by the event manager.
321
+ * Using the pool again afterwards initializes it anew.
270
322
  */
271
- async teardown({ persistState = true } = {}) {
323
+ async teardown() {
272
324
  if (!this.#initPromise)
273
325
  return;
274
326
  await this.ensureInitialized();
275
- if (this.#listener) {
276
- this.#events.off(EventType.PERSIST_STATE, this.#listener);
277
- }
278
- if (persistState) {
279
- await this.persistState();
280
- }
327
+ await this.#state.teardown();
328
+ // The next use initializes the pool again, restoring the record and resuming the periodic persistence -
329
+ // a crawler that is run twice gets a persisting pool both times.
330
+ this.#initPromise = undefined;
281
331
  }
282
332
  /**
283
333
  * Removes retired `Session` instances from `SessionPool`.
284
334
  */
285
335
  removeRetiredSessions() {
286
- this.#sessions = this.#sessions.filter((storedSession) => {
336
+ this.#state.currentValue.sessions = this.#state.currentValue.sessions.filter((storedSession) => {
287
337
  if (storedSession.isUsable())
288
338
  return true;
289
339
  this.#sessionMap.delete(storedSession.id);
@@ -296,14 +346,14 @@ export class SessionPool {
296
346
  * @param newSession `Session` instance to be added.
297
347
  */
298
348
  registerSession(newSession) {
299
- this.#sessions.push(newSession);
349
+ this.#state.currentValue.sessions.push(newSession);
300
350
  this.#sessionMap.set(newSession.id, newSession);
301
351
  }
302
352
  /**
303
353
  * Gets random index.
304
354
  */
305
355
  getRandomIndex() {
306
- return Math.floor(Math.random() * this.#sessions.length);
356
+ return Math.floor(Math.random() * this.#state.currentValue.sessions.length);
307
357
  }
308
358
  /**
309
359
  * Creates new session without any extra behavior.
@@ -347,7 +397,7 @@ export class SessionPool {
347
397
  * Decides whether there is enough space for creating new session.
348
398
  */
349
399
  hasSpaceForSession() {
350
- return this.#sessions.length < this.#maxPoolSize;
400
+ return this.#state.currentValue.sessions.length < this.#maxPoolSize;
351
401
  }
352
402
  /**
353
403
  * Picks a session from the `SessionPool` according to the configured `sessionReuseStrategy`.
@@ -357,40 +407,25 @@ export class SessionPool {
357
407
  if (this.#sessionReuseStrategy !== 'use-until-failure' && this.hasSpaceForSession())
358
408
  return undefined;
359
409
  if (this.#sessionReuseStrategy === 'use-until-failure') {
360
- return this.#sessions.find((session) => session.isUsable());
410
+ return this.#state.currentValue.sessions.find((session) => session.isUsable());
361
411
  }
362
412
  let picked;
363
413
  if (this.#sessionReuseStrategy === 'round-robin') {
364
- const index = this.#roundRobinIndex % this.#sessions.length;
414
+ const index = this.#roundRobinIndex % this.#state.currentValue.sessions.length;
365
415
  this.#roundRobinIndex = index + 1;
366
- picked = this.#sessions[index];
416
+ picked = this.#state.currentValue.sessions[index];
367
417
  }
368
418
  else {
369
- picked = this.#sessions[this.getRandomIndex()];
419
+ picked = this.#state.currentValue.sessions[this.getRandomIndex()];
370
420
  }
371
421
  return picked.isUsable() ? picked : undefined;
372
422
  }
373
- /**
374
- * Potentially loads `SessionPool`.
375
- * If the state was persisted it loads the `SessionPool` from the persisted state.
376
- */
377
- async maybeLoadSessionPool() {
378
- const loadedSessionPool = await this.#keyValueStore?.getValue(this.#persistStateKey);
379
- if (!loadedSessionPool)
380
- return;
381
- // Invalidate old sessions and load active sessions only
382
- this.#log.debug('Recreating state from KeyValueStore', {
383
- persistStateKeyValueStoreId: this.#persistStateKeyValueStoreId,
384
- persistStateKey: this.#persistStateKey,
385
- });
386
- for (const sessionObject of loadedSessionPool.sessions) {
387
- sessionObject.createdAt = new Date(sessionObject.createdAt);
388
- sessionObject.expiresAt = new Date(sessionObject.expiresAt);
389
- const recreatedSession = await this.invokeCreateSessionFunction(sessionObject);
390
- if (recreatedSession.isUsable()) {
391
- this.registerSession(recreatedSession);
392
- }
393
- }
394
- this.#log.debug(`${this.#sessions.length} active sessions loaded from KeyValueStore`);
423
+ /** The persisted form of the pool, which is also what {@link SessionPool.getState} returns. */
424
+ #buildPersistedState(sessions) {
425
+ return {
426
+ usableSessionsCount: sessions.filter((session) => session.isUsable()).length,
427
+ retiredSessionsCount: sessions.filter((session) => !session.isUsable()).length,
428
+ sessions: sessions.map((session) => session.getState()),
429
+ };
395
430
  }
396
431
  }
@@ -127,7 +127,8 @@ export interface RequestListOptions {
127
127
  * ```
128
128
  *
129
129
  * Note that the preferred (and simpler) way to persist the state of crawling of the `RequestList`
130
- * is to use the `stateKeyPrefix` parameter instead.
130
+ * is to use the `stateKeyPrefix` parameter instead. When both are set, a record persisted under
131
+ * {@link RequestListOptions.persistStateKey} takes precedence over this option.
131
132
  */
132
133
  state?: RequestListState;
133
134
  /**
@@ -228,12 +229,7 @@ export declare class RequestList implements IRequestLoader {
228
229
  * Set of `uniqueKey`s of requests that were returned by fetchNextRequest().
229
230
  * @internal
230
231
  */
231
- inProgress: Set<string>;
232
- /**
233
- * Starts as true because until we handle the first request, the list is effectively persisted by doing nothing.
234
- * @internal
235
- */
236
- isStatePersisted: boolean;
232
+ get inProgress(): Set<string>;
237
233
  /**
238
234
  * Starts as false because we don't know yet and sources might change in the meantime (eg. download from live list).
239
235
  * @internal
@@ -279,15 +275,6 @@ export declare class RequestList implements IRequestLoader {
279
275
  * but there's no reason to persist it again afterwards, because RequestList is immutable.
280
276
  */
281
277
  private persistRequests;
282
- /**
283
- * Restores RequestList state from a state object.
284
- */
285
- private restoreState;
286
- /**
287
- * Attempts to load state and requests using the `RequestList` configuration
288
- * and returns a tuple of [state, requests] where each may be null if not loaded.
289
- */
290
- private loadStateAndPersistedRequests;
291
278
  /**
292
279
  * Returns an object representing the internal state of the `RequestList` instance.
293
280
  * Note that the object's fields can change in future releases.
@@ -1,6 +1,6 @@
1
1
  import { downloadListOfUrls } from '@crawlee/utils';
2
2
  import { z } from 'zod';
3
- import { EventType } from '../events/event_manager.js';
3
+ import { convertStateSync, RecoverableState } from '../recoverable_state.js';
4
4
  import { Request } from '../request.js';
5
5
  import { createDeserialize, serializeArray } from '../serialization.js';
6
6
  import { serviceLocator } from '../service_locator.js';
@@ -12,24 +12,87 @@ export const STATE_PERSISTENCE_KEY = 'REQUEST_LIST_STATE';
12
12
  /** @internal */
13
13
  export const REQUESTS_PERSISTENCE_KEY = 'REQUEST_LIST_REQUESTS';
14
14
  const CONTENT_TYPE_BINARY = 'application/octet-stream';
15
+ const persistedRequestListState = z.object({
16
+ nextIndex: z.number().int().nonnegative(),
17
+ nextUniqueKey: z.string().nullable(),
18
+ inProgress: z.array(z.string()),
19
+ });
20
+ const requestListInternalState = z.object({
21
+ /** Position of the next request to be processed. */
22
+ nextIndex: z.number().int().nonnegative(),
23
+ /** `uniqueKey`s of the requests being processed at the moment. */
24
+ inProgress: z.set(z.string()),
25
+ });
15
26
  const requestListOptionsSchema = z.strictObject({
16
27
  sources: schemas.anyArray.optional(), // check only for array and not subtypes to avoid iteration over the whole thing
17
28
  sourcesFunction: schemas.anyFunction.optional(),
18
29
  persistStateKey: z.string().optional(),
19
30
  persistRequestsKey: z.string().optional(),
20
- state: z
21
- .strictObject({
22
- nextIndex: schemas.anyNumber,
23
- nextUniqueKey: z.string(),
24
- inProgress: schemas.anyObject, // persisted as an array of unique keys
25
- })
26
- .optional(),
31
+ state: persistedRequestListState.optional(),
27
32
  keepDuplicateUrls: z.boolean().default(false),
28
33
  proxyConfiguration: validators.proxyConfiguration.optional(),
29
34
  httpClient: schemas.httpClient.optional(),
30
35
  });
31
36
  const listNameSchema = z.string().nullish();
32
37
  const openOptionsSchema = z.looseObject({});
38
+ /**
39
+ * The conversion between the crawling position and its persisted record. Decoding also checks the record against
40
+ * the loaded requests, which is why the codec is built per list.
41
+ */
42
+ function buildRequestListStateCodec(list) {
43
+ return z.codec(persistedRequestListState, requestListInternalState, {
44
+ decode: (record, ctx) => {
45
+ const { requests } = list;
46
+ const { nextIndex, nextUniqueKey } = record;
47
+ const inconsistent = (message) => {
48
+ ctx.issues.push({ code: 'custom', message, input: record });
49
+ return z.NEVER;
50
+ };
51
+ if (nextIndex > requests.length) {
52
+ return inconsistent('The state object is not consistent with RequestList, too few requests loaded.');
53
+ }
54
+ if (nextIndex < requests.length && requests[nextIndex].uniqueKey !== nextUniqueKey) {
55
+ return inconsistent('The state object is not consistent with RequestList the order of URLs seems to have changed.');
56
+ }
57
+ const inProgress = new Set(record.inProgress);
58
+ const deleteFromInProgress = [];
59
+ for (const uniqueKey of inProgress) {
60
+ const index = list.indexOf(uniqueKey);
61
+ if (index === undefined) {
62
+ return inconsistent('The state object is not consistent with RequestList. Unknown uniqueKey is present in the state.');
63
+ }
64
+ if (index >= nextIndex) {
65
+ deleteFromInProgress.push(uniqueKey);
66
+ }
67
+ }
68
+ // WORKAROUND:
69
+ // It happened to some users that state object contained something like:
70
+ // {
71
+ // "nextIndex": 11308,
72
+ // "nextUniqueKey": "https://www.anychart.com",
73
+ // "inProgress": {
74
+ // "https://www.ams360.com": true,
75
+ // ...
76
+ // "https://www.anychart.com": true,
77
+ // }
78
+ // Which then caused error "The request is not being processed (uniqueKey: https://www.anychart.com)"
79
+ // As a workaround, we just remove all inProgress requests whose index >= nextIndex,
80
+ // since they will be crawled again.
81
+ if (deleteFromInProgress.length) {
82
+ list.log.warning("RequestList's in-progress field is not consistent, skipping invalid in-progress entries", { deleteFromInProgress });
83
+ for (const uniqueKey of deleteFromInProgress) {
84
+ inProgress.delete(uniqueKey);
85
+ }
86
+ }
87
+ return { nextIndex, inProgress };
88
+ },
89
+ encode: ({ nextIndex, inProgress }) => ({
90
+ nextIndex,
91
+ nextUniqueKey: nextIndex < list.requests.length ? list.requests[nextIndex].uniqueKey : null,
92
+ inProgress: [...inProgress],
93
+ }),
94
+ });
95
+ }
33
96
  /**
34
97
  * Represents a static list of URLs to crawl.
35
98
  * The URLs can be provided either in code or parsed from a text file hosted on the web.
@@ -99,26 +162,27 @@ export class RequestList {
99
162
  * @internal
100
163
  */
101
164
  requests = [];
102
- /** Index to the next item in requests array to fetch. All previous requests are either handled or in progress. */
103
- #nextIndex = 0;
104
165
  /** Dictionary, key is Request.uniqueKey, value is corresponding index in the requests array. */
105
166
  #uniqueKeyToIndex = {};
167
+ /**
168
+ * The crawling position, kept in a {@link RecoverableState} that handles the loading, validation and
169
+ * periodic persistence of the record under `persistStateKey`.
170
+ */
171
+ #state;
172
+ #stateCodec;
106
173
  /**
107
174
  * Set of `uniqueKey`s of requests that were returned by fetchNextRequest().
108
175
  * @internal
109
176
  */
110
- inProgress = new Set();
177
+ get inProgress() {
178
+ return this.#state.currentValue.inProgress;
179
+ }
111
180
  /**
112
181
  * `uniqueKey`s of requests that were in progress when the state was last persisted and thus need to be
113
182
  * re-crawled after a restart. They are served before advancing through the rest of the sources.
114
183
  * @internal
115
184
  */
116
185
  #requestsToRetry = [];
117
- /**
118
- * Starts as true because until we handle the first request, the list is effectively persisted by doing nothing.
119
- * @internal
120
- */
121
- isStatePersisted = true;
122
186
  /**
123
187
  * Starts as false because we don't know yet and sources might change in the meantime (eg. download from live list).
124
188
  * @internal
@@ -128,7 +192,6 @@ export class RequestList {
128
192
  #isInitialized = false;
129
193
  #persistStateKey;
130
194
  #persistRequestsKey;
131
- #initialState;
132
195
  #store;
133
196
  #keepDuplicateUrls;
134
197
  #sources;
@@ -147,8 +210,29 @@ export class RequestList {
147
210
  }
148
211
  this.#persistStateKey = persistStateKey ? `CRAWLEE_${persistStateKey}` : persistStateKey;
149
212
  this.#persistRequestsKey = persistRequestsKey ? `CRAWLEE_${persistRequestsKey}` : persistRequestsKey;
150
- this.#initialState = state;
151
213
  this.#httpClient = httpClient;
214
+ this.#stateCodec = buildRequestListStateCodec({
215
+ requests: this.requests,
216
+ indexOf: (uniqueKey) => {
217
+ const index = this.#uniqueKeyToIndex[uniqueKey];
218
+ return typeof index === 'number' ? index : undefined;
219
+ },
220
+ log: this.#log,
221
+ });
222
+ const stateKey = this.#persistStateKey ?? STATE_PERSISTENCE_KEY;
223
+ this.#state = new RecoverableState({
224
+ // The `state` option is where the list starts when there is no persisted record to restore. The factory
225
+ // runs on `initialize()`, once the requests it is validated against are loaded.
226
+ defaultState: () => state
227
+ ? convertStateSync(this.#stateCodec, state, stateKey)
228
+ : { nextIndex: 0, inProgress: new Set() },
229
+ persistStateKey: stateKey,
230
+ persistenceEnabled: !!this.#persistStateKey,
231
+ logger: this.#log,
232
+ // The codec validates in the decode direction, so it is a Standard Schema as-is; encoding needs a call.
233
+ deserialize: this.#stateCodec,
234
+ serialize: (internalState) => this.#stateCodec.encode(internalState),
235
+ });
152
236
  // If this option is set then all requests will get a pre-generated unique ID and duplicate URLs will be kept in the list.
153
237
  this.#keepDuplicateUrls = keepDuplicateUrls;
154
238
  // Will be empty after initialization to save memory.
@@ -156,7 +240,6 @@ export class RequestList {
156
240
  this.#sourcesFunction = sourcesFunction;
157
241
  // The proxy configuration used for `requestsFromUrl` requests.
158
242
  this.#proxyConfiguration = proxyConfiguration;
159
- this.persistState = this.persistState.bind(this);
160
243
  }
161
244
  /**
162
245
  * Loads all remote sources of URLs and potentially starts periodic state persistence.
@@ -168,7 +251,12 @@ export class RequestList {
168
251
  }
169
252
  this.#isLoading = true;
170
253
  await purgeDefaultStorages({ onlyPurgeOnce: true });
171
- const [state, persistedRequests] = await this.loadStateAndPersistedRequests();
254
+ let persistedRequests;
255
+ if (this.#persistRequestsKey) {
256
+ persistedRequests = await this.getPersistedState(this.#persistRequestsKey);
257
+ if (persistedRequests)
258
+ this.#log.debug('Loaded requests from key value store using the persistRequestsKey.');
259
+ }
172
260
  // Add persisted requests / new sources in a memory efficient way because with very
173
261
  // large lists, we were running out of memory.
174
262
  if (persistedRequests) {
@@ -177,13 +265,14 @@ export class RequestList {
177
265
  else {
178
266
  await this.addRequestsFromSources();
179
267
  }
180
- this.restoreState(state);
268
+ // Restores the crawling position - the requests have to be loaded first, as the record is validated
269
+ // against them. Also starts the periodic state persistence when `persistStateKey` is set.
270
+ await this.#state.initialize();
271
+ // All in-progress requests were interrupted and need to be re-crawled.
272
+ this.#requestsToRetry = [...this.inProgress];
181
273
  this.#isInitialized = true;
182
274
  if (this.#persistRequestsKey && !this.areRequestsPersisted)
183
275
  await this.persistRequests();
184
- if (this.#persistStateKey) {
185
- serviceLocator.getEventManager().on(EventType.PERSIST_STATE, this.persistState);
186
- }
187
276
  return this;
188
277
  }
189
278
  /**
@@ -255,12 +344,8 @@ export class RequestList {
255
344
  if (!this.#persistStateKey) {
256
345
  throw new Error('Cannot persist state. options.persistStateKey is not set.');
257
346
  }
258
- if (this.isStatePersisted)
259
- return;
260
347
  try {
261
- this.#store ??= await KeyValueStore.open();
262
- await this.#store.setValue(this.#persistStateKey, this.getState());
263
- this.isStatePersisted = true;
348
+ await this.#state.persistState();
264
349
  }
265
350
  catch (e) {
266
351
  const err = e;
@@ -273,10 +358,7 @@ export class RequestList {
273
358
  * leaking the listener (and the requests it retains) on the shared event manager.
274
359
  */
275
360
  async teardown() {
276
- serviceLocator.getEventManager().off(EventType.PERSIST_STATE, this.persistState);
277
- if (this.#persistStateKey) {
278
- await this.persistState();
279
- }
361
+ await this.#state.teardown();
280
362
  }
281
363
  /**
282
364
  * Unlike persistState(), this is used only internally, since the sources
@@ -289,101 +371,20 @@ export class RequestList {
289
371
  await this.#store.setValue(this.#persistRequestsKey, serializedRequests, { contentType: CONTENT_TYPE_BINARY });
290
372
  this.areRequestsPersisted = true;
291
373
  }
292
- /**
293
- * Restores RequestList state from a state object.
294
- */
295
- restoreState(state) {
296
- // If there's no state it means we've not persisted any (yet).
297
- if (!state)
298
- return;
299
- // Restore previous state.
300
- if (typeof state.nextIndex !== 'number' || state.nextIndex < 0) {
301
- throw new Error('The state object is invalid: nextIndex must be a non-negative number.');
302
- }
303
- if (state.nextIndex > this.requests.length) {
304
- throw new Error('The state object is not consistent with RequestList, too few requests loaded.');
305
- }
306
- if (state.nextIndex < this.requests.length &&
307
- this.requests[state.nextIndex].uniqueKey !== state.nextUniqueKey) {
308
- throw new Error('The state object is not consistent with RequestList the order of URLs seems to have changed.');
309
- }
310
- const deleteFromInProgress = [];
311
- state.inProgress.forEach((uniqueKey) => {
312
- const index = this.#uniqueKeyToIndex[uniqueKey];
313
- if (typeof index !== 'number') {
314
- throw new Error('The state object is not consistent with RequestList. Unknown uniqueKey is present in the state.');
315
- }
316
- if (index >= state.nextIndex) {
317
- deleteFromInProgress.push(uniqueKey);
318
- }
319
- });
320
- this.#nextIndex = state.nextIndex;
321
- this.inProgress = new Set(state.inProgress);
322
- // WORKAROUND:
323
- // It happened to some users that state object contained something like:
324
- // {
325
- // "nextIndex": 11308,
326
- // "nextUniqueKey": "https://www.anychart.com",
327
- // "inProgress": {
328
- // "https://www.ams360.com": true,
329
- // ...
330
- // "https://www.anychart.com": true,
331
- // }
332
- // Which then caused error "The request is not being processed (uniqueKey: https://www.anychart.com)"
333
- // As a workaround, we just remove all inProgress requests whose index >= nextIndex,
334
- // since they will be crawled again.
335
- if (deleteFromInProgress.length) {
336
- this.#log.warning("RequestList's in-progress field is not consistent, skipping invalid in-progress entries", {
337
- deleteFromInProgress,
338
- });
339
- for (const uniqueKey of deleteFromInProgress) {
340
- this.inProgress.delete(uniqueKey);
341
- }
342
- }
343
- // All in-progress requests were interrupted and need to be re-crawled.
344
- this.#requestsToRetry = [...this.inProgress];
345
- }
346
- /**
347
- * Attempts to load state and requests using the `RequestList` configuration
348
- * and returns a tuple of [state, requests] where each may be null if not loaded.
349
- */
350
- async loadStateAndPersistedRequests() {
351
- let state;
352
- let persistedRequests;
353
- if (this.#initialState) {
354
- state = this.#initialState;
355
- this.#log.debug('Loaded state from options.state argument.');
356
- }
357
- else if (this.#persistStateKey) {
358
- state = await this.getPersistedState(this.#persistStateKey);
359
- if (state)
360
- this.#log.debug('Loaded state from key value store using the persistStateKey.');
361
- }
362
- if (this.#persistRequestsKey) {
363
- persistedRequests = await this.getPersistedState(this.#persistRequestsKey);
364
- if (persistedRequests)
365
- this.#log.debug('Loaded requests from key value store using the persistRequestsKey.');
366
- }
367
- return [state, persistedRequests];
368
- }
369
374
  /**
370
375
  * Returns an object representing the internal state of the `RequestList` instance.
371
376
  * Note that the object's fields can change in future releases.
372
377
  */
373
378
  getState() {
374
379
  this.ensureIsInitialized();
375
- return {
376
- nextIndex: this.#nextIndex,
377
- nextUniqueKey: this.#nextIndex < this.requests.length ? this.requests[this.#nextIndex].uniqueKey : null,
378
- inProgress: [...this.inProgress],
379
- };
380
+ return this.#stateCodec.encode(this.#state.currentValue);
380
381
  }
381
382
  /**
382
383
  * @inheritDoc
383
384
  */
384
385
  async checkReadiness() {
385
386
  this.ensureIsInitialized();
386
- if (this.#requestsToRetry.length > 0 || this.#nextIndex < this.requests.length) {
387
+ if (this.#requestsToRetry.length > 0 || this.#state.currentValue.nextIndex < this.requests.length) {
387
388
  return { status: 'ready' };
388
389
  }
389
390
  // `#requestsToRetry` is a subset of `inProgress`, so nothing in progress means nothing left to re-serve.
@@ -401,12 +402,12 @@ export class RequestList {
401
402
  return this.ensureRequest(this.requests[index], index);
402
403
  }
403
404
  // Otherwise return next request.
404
- if (this.#nextIndex < this.requests.length) {
405
- const index = this.#nextIndex;
405
+ const state = this.#state.currentValue;
406
+ if (state.nextIndex < this.requests.length) {
407
+ const index = state.nextIndex;
406
408
  const request = this.requests[index];
407
- this.inProgress.add(request.uniqueKey);
408
- this.#nextIndex++;
409
- this.isStatePersisted = false;
409
+ state.inProgress.add(request.uniqueKey);
410
+ state.nextIndex++;
410
411
  return this.ensureRequest(request, index);
411
412
  }
412
413
  return null;
@@ -438,7 +439,6 @@ export class RequestList {
438
439
  this.ensureInProgress(uniqueKey);
439
440
  this.ensureIsInitialized();
440
441
  this.inProgress.delete(uniqueKey);
441
- this.isStatePersisted = false;
442
442
  }
443
443
  /**
444
444
  * Adds all fetched requests from a URL from a remote resource.
@@ -561,7 +561,7 @@ export class RequestList {
561
561
  */
562
562
  async getPendingCount() {
563
563
  this.ensureIsInitialized();
564
- return this.requests.length - (this.#nextIndex - this.inProgress.size);
564
+ return this.requests.length - (this.#state.currentValue.nextIndex - this.inProgress.size);
565
565
  }
566
566
  /**
567
567
  * Combines this list with a request manager (a {@link RequestQueue} by default) into a
@@ -579,7 +579,7 @@ export class RequestList {
579
579
  */
580
580
  async getHandledCount() {
581
581
  this.ensureIsInitialized();
582
- return this.#nextIndex - this.inProgress.size;
582
+ return this.#state.currentValue.nextIndex - this.inProgress.size;
583
583
  }
584
584
  /**
585
585
  * Opens a request list and returns a promise resolving to an instance