Skip to main content

millipede_core/
session.rs

1//! Sessions and the session pool.
2//!
3//! Expiry uses a persisted wall-clock timestamp instead of an `Instant`, so restarting a crawler
4//! cannot silently renew sessions. Persistence is explicit rather than `AutoSaved`: the live cookie
5//! jar remains authoritative instead of being duplicated behind another lock. Pools are created
6//! before crawler storage is available, so
7//! [`SessionPool::attach_persistence`](crate::session::SessionPool::attach_persistence) bridges that
8//! lifecycle gap instead of requiring storage in
9//! [`SessionPool::new`](crate::session::SessionPool::new).
10
11use std::{
12    fmt,
13    sync::{Arc, Mutex},
14    time::Duration,
15};
16
17use serde::{Deserialize, Serialize};
18use time::OffsetDateTime;
19
20use crate::{
21    cookies::CookieJar,
22    errors::CrawlError,
23    request::UserData,
24    storage::{KeyValueStore, KeyValueStoreExt},
25};
26
27/// Stable identifier for a crawler session.
28///
29/// ```
30/// use millipede_core::session::SessionId;
31/// let id = SessionId::generate();
32/// assert!(id.as_str().starts_with("session-"));
33/// ```
34#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
35pub struct SessionId(String);
36
37impl SessionId {
38    /// Generates a process-local random identifier.
39    pub fn generate() -> Self {
40        Self(format!("session-{:016x}", crate::util::rand_u64()))
41    }
42    /// Returns the identifier as text.
43    pub fn as_str(&self) -> &str {
44        &self.0
45    }
46}
47
48impl fmt::Display for SessionId {
49    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50        formatter.write_str(&self.0)
51    }
52}
53
54impl From<String> for SessionId {
55    fn from(value: String) -> Self {
56        Self(value)
57    }
58}
59
60/// Stable token used to keep fingerprint generation consistent within a session.
61#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
62pub struct SessionToken(String);
63
64impl SessionToken {
65    /// Creates a token from its textual representation.
66    pub fn new(value: impl Into<String>) -> Self {
67        Self(value.into())
68    }
69
70    /// Returns the token as text.
71    pub fn as_str(&self) -> &str {
72        &self.0
73    }
74}
75
76impl fmt::Display for SessionToken {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        formatter.write_str(&self.0)
79    }
80}
81
82impl From<String> for SessionToken {
83    fn from(value: String) -> Self {
84        Self(value)
85    }
86}
87
88impl From<&str> for SessionToken {
89    fn from(value: &str) -> Self {
90        Self(value.to_owned())
91    }
92}
93
94impl From<&SessionId> for SessionToken {
95    fn from(id: &SessionId) -> Self {
96        Self(id.as_str().to_owned())
97    }
98}
99
100impl From<SessionId> for SessionToken {
101    fn from(id: SessionId) -> Self {
102        Self(id.as_str().to_owned())
103    }
104}
105
106#[cfg(test)]
107mod session_token_tests {
108    use super::{SessionId, SessionToken};
109
110    #[test]
111    fn session_token_from_session_id_preserves_text() {
112        let id = SessionId::from("session-stable".to_owned());
113        assert_eq!(SessionToken::from(&id).as_str(), id.as_str());
114        assert_eq!(SessionToken::from(id).as_str(), "session-stable");
115    }
116}
117
118/// Limits and scoring behavior for one session.
119///
120/// Scores use fixed-point thousandths. `max_age` extends the three-field interface configuration
121/// with Crawlee's 3,000-second session lifetime.
122///
123/// ```
124/// use millipede_core::session::SessionConfig;
125/// let config = SessionConfig::default().with_max_usage_count(10);
126/// assert_eq!(config.max_usage_count, 10);
127/// ```
128#[derive(Debug, Clone)]
129#[non_exhaustive]
130#[must_use = "session configuration does nothing unless used to create a session"]
131pub struct SessionConfig {
132    /// Blocking threshold, scaled by 1,000.
133    pub max_error_score_scaled: u32,
134    /// Score removed after a successful use, scaled by 1,000.
135    pub error_score_decrement_scaled: u32,
136    /// Maximum attempts served before retirement.
137    pub max_usage_count: u32,
138    /// Maximum wall-clock lifetime.
139    pub max_age: Duration,
140}
141
142impl Default for SessionConfig {
143    fn default() -> Self {
144        Self {
145            max_error_score_scaled: 3_000,
146            error_score_decrement_scaled: 500,
147            max_usage_count: 50,
148            max_age: Duration::from_secs(3_000),
149        }
150    }
151}
152
153impl SessionConfig {
154    /// Sets the scaled blocking threshold.
155    pub fn with_max_error_score_scaled(mut self, value: u32) -> Self {
156        self.max_error_score_scaled = value;
157        self
158    }
159    /// Sets the scaled successful-use decrement.
160    pub fn with_error_score_decrement_scaled(mut self, value: u32) -> Self {
161        self.error_score_decrement_scaled = value;
162        self
163    }
164    /// Sets the maximum usage count.
165    pub fn with_max_usage_count(mut self, value: u32) -> Self {
166        self.max_usage_count = value;
167        self
168    }
169    /// Sets the maximum session age.
170    pub fn with_max_age(mut self, value: Duration) -> Self {
171        self.max_age = value;
172        self
173    }
174}
175
176struct SessionState {
177    user_data: UserData,
178    error_score_scaled: u32,
179    usage_count: u32,
180    retired: bool,
181}
182
183/// Cookie, score, and user-data state associated with a crawling identity.
184///
185/// ```
186/// use millipede_core::session::{Session, SessionConfig};
187/// let session = Session::new(SessionConfig::default());
188/// assert!(session.id().as_str().starts_with("session-"));
189/// ```
190pub struct Session {
191    id: SessionId,
192    cookies: Arc<CookieJar>,
193    state: tokio::sync::Mutex<SessionState>,
194    expires_at: OffsetDateTime,
195    config: SessionConfig,
196}
197
198impl Session {
199    /// Creates an empty session with a generated identifier.
200    pub fn new(config: SessionConfig) -> Self {
201        let expires_at = OffsetDateTime::now_utc() + config.max_age;
202        Self {
203            id: SessionId::generate(),
204            cookies: Arc::new(CookieJar::new()),
205            state: tokio::sync::Mutex::new(SessionState {
206                user_data: UserData::default(),
207                error_score_scaled: 0,
208                usage_count: 0,
209                retired: false,
210            }),
211            expires_at,
212            config,
213        }
214    }
215
216    fn restored(value: PersistedSession, config: SessionConfig) -> Result<Self, CrawlError> {
217        let cookies = CookieJar::from_json(&value.cookies).map_err(CrawlError::non_retryable)?;
218        Ok(Self {
219            id: value.id.into(),
220            cookies: Arc::new(cookies),
221            state: tokio::sync::Mutex::new(SessionState {
222                user_data: UserData::default(),
223                error_score_scaled: value.error_score_scaled,
224                usage_count: value.usage_count,
225                retired: value.retired,
226            }),
227            expires_at: value.expires_at,
228            config,
229        })
230    }
231
232    /// Returns this session's identifier.
233    pub fn id(&self) -> &SessionId {
234        &self.id
235    }
236    /// Returns the authoritative shared cookie jar.
237    pub fn cookie_jar(&self) -> &Arc<CookieJar> {
238        &self.cookies
239    }
240    /// Reads user data through a synchronous closure, releasing the lock on return.
241    pub async fn with_user_data<R>(&self, f: impl FnOnce(&UserData) -> R) -> R {
242        f(&self.state.lock().await.user_data)
243    }
244    /// Mutates user data through a synchronous closure, releasing the lock on return.
245    pub async fn update_user_data(&self, f: impl FnOnce(&mut UserData)) {
246        f(&mut self.state.lock().await.user_data);
247    }
248    /// Returns the error score in unscaled units.
249    pub async fn error_score(&self) -> f32 {
250        self.state.lock().await.error_score_scaled as f32 / 1_000.0
251    }
252    /// Returns the number of attempts served.
253    pub async fn usage_count(&self) -> u32 {
254        self.state.lock().await.usage_count
255    }
256    /// Records one checkout attempt. Session pools call this when serving a session.
257    pub async fn record_usage(&self) {
258        let mut state = self.state.lock().await;
259        state.usage_count = state.usage_count.saturating_add(1);
260    }
261    /// Returns whether the error threshold has been reached.
262    pub async fn is_blocked(&self) -> bool {
263        self.state.lock().await.error_score_scaled >= self.config.max_error_score_scaled
264    }
265    /// Returns whether the fixed creation-time expiry has passed.
266    pub fn is_expired(&self) -> bool {
267        OffsetDateTime::now_utc() >= self.expires_at
268    }
269    /// Returns whether the session was explicitly retired.
270    pub async fn is_retired(&self) -> bool {
271        self.state.lock().await.retired
272    }
273    /// Returns whether the session can serve another attempt.
274    pub async fn is_usable(&self) -> bool {
275        let state = self.state.lock().await;
276        !state.retired
277            && !self.is_expired()
278            && state.error_score_scaled < self.config.max_error_score_scaled
279            && state.usage_count < self.config.max_usage_count
280    }
281    /// Decreases the error score, flooring it at zero.
282    pub async fn mark_good(&self) {
283        let mut state = self.state.lock().await;
284        state.error_score_scaled = state
285            .error_score_scaled
286            .saturating_sub(self.config.error_score_decrement_scaled);
287    }
288    /// Adds one scaled error point, saturating at `u32::MAX`.
289    pub async fn mark_bad(&self) {
290        let mut state = self.state.lock().await;
291        state.error_score_scaled = state.error_score_scaled.saturating_add(1_000);
292    }
293    /// Permanently retires this session.
294    pub async fn retire(&self) {
295        self.state.lock().await.retired = true;
296    }
297    /// Stores `Set-Cookie` headers synchronously and infallibly.
298    pub fn set_cookies_from_response(&self, response: &crate::http_client::HttpResponse) {
299        self.cookies
300            .store_response_cookies(&response.url, &response.headers);
301    }
302}
303
304impl fmt::Debug for Session {
305    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
306        formatter
307            .debug_struct("Session")
308            .field("id", &self.id)
309            .field("expires_at", &self.expires_at)
310            .finish()
311    }
312}
313
314/// Default key used to persist the session pool.
315pub const SESSION_POOL_PERSIST_KEY: &str = "SDK_SESSION_POOL_STATE";
316
317/// Session pool capacity, creation, and persistence settings.
318///
319/// ```
320/// use millipede_core::session::SessionPoolOptions;
321/// assert_eq!(SessionPoolOptions::default().max_pool_size, 1000);
322/// ```
323#[derive(Debug, Clone)]
324#[non_exhaustive]
325#[must_use = "pool options do nothing unless passed to SessionPool::new"]
326pub struct SessionPoolOptions {
327    /// Maximum number of retained sessions.
328    pub max_pool_size: usize,
329    /// Configuration cloned into new and restored sessions.
330    pub session_config: SessionConfig,
331    /// KVS key used by explicit persistence.
332    pub persist_state_key: String,
333}
334
335impl Default for SessionPoolOptions {
336    fn default() -> Self {
337        Self {
338            max_pool_size: 1_000,
339            session_config: SessionConfig::default(),
340            persist_state_key: SESSION_POOL_PERSIST_KEY.into(),
341        }
342    }
343}
344
345impl SessionPoolOptions {
346    /// Sets the maximum retained session count.
347    pub fn with_max_pool_size(mut self, value: usize) -> Self {
348        self.max_pool_size = value;
349        self
350    }
351    /// Sets configuration for pool sessions.
352    pub fn with_session_config(mut self, value: SessionConfig) -> Self {
353        self.session_config = value;
354        self
355    }
356    /// Sets the persistence key.
357    pub fn with_persist_state_key(mut self, value: impl Into<String>) -> Self {
358        self.persist_state_key = value.into();
359        self
360    }
361}
362
363/// A bounded collection of reusable crawler sessions.
364///
365/// ```
366/// use millipede_core::session::{SessionPool, SessionPoolOptions};
367/// let pool = SessionPool::new(SessionPoolOptions::default());
368/// # let _ = pool;
369/// ```
370pub struct SessionPool {
371    sessions: tokio::sync::Mutex<Vec<Arc<Session>>>,
372    options: SessionPoolOptions,
373    kvs: Mutex<Option<Arc<dyn KeyValueStore>>>,
374}
375
376impl SessionPool {
377    /// Creates an empty pool. Persistence can be attached once storage opens.
378    pub fn new(options: SessionPoolOptions) -> Self {
379        Self {
380            sessions: tokio::sync::Mutex::new(Vec::new()),
381            options,
382            kvs: Mutex::new(None),
383        }
384    }
385    /// Attaches the key-value store used by [`Self::persist`] and [`Self::restore`].
386    pub fn attach_persistence(&self, kvs: Arc<dyn KeyValueStore>) {
387        *self.kvs.lock().unwrap_or_else(|e| e.into_inner()) = Some(kvs);
388    }
389    /// Checks out a sticky usable session, creates one while below capacity, or rotates at random.
390    pub async fn session(&self, sticky: Option<&SessionId>) -> Arc<Session> {
391        let mut sessions = self.sessions.lock().await;
392        if let Some(session) = sticky
393            .and_then(|id| sessions.iter().find(|session| session.id() == id))
394            .cloned()
395        {
396            if session.is_usable().await {
397                session.record_usage().await;
398                return session;
399            }
400        }
401        let mut usable = Vec::with_capacity(sessions.len());
402        for session in sessions.iter() {
403            usable.push(session.is_usable().await);
404        }
405        let mut index = 0;
406        sessions.retain(|_| {
407            let keep = usable[index];
408            index += 1;
409            keep
410        });
411        if sessions.len() < self.options.max_pool_size {
412            let session = Arc::new(Session::new(self.options.session_config.clone()));
413            session.record_usage().await;
414            sessions.push(Arc::clone(&session));
415            return session;
416        }
417        if sessions.is_empty() {
418            let session = Arc::new(Session::new(self.options.session_config.clone()));
419            session.record_usage().await;
420            return session;
421        }
422        let index = crate::util::rand_u64() as usize % sessions.len();
423        let session = Arc::clone(&sessions[index]);
424        session.record_usage().await;
425        session
426    }
427    /// Retires the session with `id` when it exists.
428    pub async fn retire_session(&self, id: &SessionId) {
429        if let Some(session) = self
430            .sessions
431            .lock()
432            .await
433            .iter()
434            .find(|s| s.id() == id)
435            .cloned()
436        {
437            session.retire().await;
438        }
439    }
440    /// Returns the number of currently retained entries.
441    pub async fn session_count(&self) -> usize {
442        self.sessions.lock().await.len()
443    }
444    /// Persists IDs, cookies, scores, usage, retirement, and original expiry.
445    pub async fn persist(&self) -> Result<(), CrawlError> {
446        let kvs = self.kvs.lock().unwrap_or_else(|e| e.into_inner()).clone();
447        let Some(kvs) = kvs else {
448            return Ok(());
449        };
450        let sessions = self.sessions.lock().await;
451        let mut persisted = Vec::with_capacity(sessions.len());
452        for session in sessions.iter() {
453            let state = session.state.lock().await;
454            persisted.push(PersistedSession {
455                id: session.id.to_string(),
456                cookies: session
457                    .cookies
458                    .to_json()
459                    .map_err(CrawlError::non_retryable)?,
460                error_score_scaled: state.error_score_scaled,
461                usage_count: state.usage_count,
462                retired: state.retired,
463                expires_at: session.expires_at,
464            });
465        }
466        drop(sessions);
467        kvs.set(
468            &self.options.persist_state_key,
469            &SessionPoolState {
470                sessions: persisted,
471            },
472        )
473        .await
474        .map_err(CrawlError::retry)
475    }
476    /// Replaces pool contents from persisted state, skipping entries with corrupt cookie JSON.
477    pub async fn restore(&self) -> Result<(), CrawlError> {
478        let kvs = self.kvs.lock().unwrap_or_else(|e| e.into_inner()).clone();
479        let Some(kvs) = kvs else {
480            return Ok(());
481        };
482        let Some(state) = kvs
483            .get::<SessionPoolState>(&self.options.persist_state_key)
484            .await
485            .map_err(CrawlError::retry)?
486        else {
487            return Ok(());
488        };
489        let mut restored = Vec::with_capacity(state.sessions.len());
490        for persisted in state.sessions {
491            match Session::restored(persisted, self.options.session_config.clone()) {
492                Ok(session) => restored.push(Arc::new(session)),
493                Err(error) => tracing::warn!(%error, "skipping corrupt persisted session"),
494            }
495        }
496        *self.sessions.lock().await = restored;
497        Ok(())
498    }
499}
500
501#[derive(Serialize, Deserialize)]
502struct SessionPoolState {
503    sessions: Vec<PersistedSession>,
504}
505
506#[derive(Serialize, Deserialize)]
507struct PersistedSession {
508    id: String,
509    cookies: String,
510    error_score_scaled: u32,
511    usage_count: u32,
512    retired: bool,
513    #[serde(with = "time::serde::rfc3339")]
514    expires_at: OffsetDateTime,
515}