1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
35pub struct SessionId(String);
36
37impl SessionId {
38 pub fn generate() -> Self {
40 Self(format!("session-{:016x}", crate::util::rand_u64()))
41 }
42 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
62pub struct SessionToken(String);
63
64impl SessionToken {
65 pub fn new(value: impl Into<String>) -> Self {
67 Self(value.into())
68 }
69
70 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#[derive(Debug, Clone)]
129#[non_exhaustive]
130#[must_use = "session configuration does nothing unless used to create a session"]
131pub struct SessionConfig {
132 pub max_error_score_scaled: u32,
134 pub error_score_decrement_scaled: u32,
136 pub max_usage_count: u32,
138 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 pub fn with_max_error_score_scaled(mut self, value: u32) -> Self {
156 self.max_error_score_scaled = value;
157 self
158 }
159 pub fn with_error_score_decrement_scaled(mut self, value: u32) -> Self {
161 self.error_score_decrement_scaled = value;
162 self
163 }
164 pub fn with_max_usage_count(mut self, value: u32) -> Self {
166 self.max_usage_count = value;
167 self
168 }
169 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
183pub 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 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 pub fn id(&self) -> &SessionId {
234 &self.id
235 }
236 pub fn cookie_jar(&self) -> &Arc<CookieJar> {
238 &self.cookies
239 }
240 pub async fn with_user_data<R>(&self, f: impl FnOnce(&UserData) -> R) -> R {
242 f(&self.state.lock().await.user_data)
243 }
244 pub async fn update_user_data(&self, f: impl FnOnce(&mut UserData)) {
246 f(&mut self.state.lock().await.user_data);
247 }
248 pub async fn error_score(&self) -> f32 {
250 self.state.lock().await.error_score_scaled as f32 / 1_000.0
251 }
252 pub async fn usage_count(&self) -> u32 {
254 self.state.lock().await.usage_count
255 }
256 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 pub async fn is_blocked(&self) -> bool {
263 self.state.lock().await.error_score_scaled >= self.config.max_error_score_scaled
264 }
265 pub fn is_expired(&self) -> bool {
267 OffsetDateTime::now_utc() >= self.expires_at
268 }
269 pub async fn is_retired(&self) -> bool {
271 self.state.lock().await.retired
272 }
273 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 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 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 pub async fn retire(&self) {
295 self.state.lock().await.retired = true;
296 }
297 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
314pub const SESSION_POOL_PERSIST_KEY: &str = "SDK_SESSION_POOL_STATE";
316
317#[derive(Debug, Clone)]
324#[non_exhaustive]
325#[must_use = "pool options do nothing unless passed to SessionPool::new"]
326pub struct SessionPoolOptions {
327 pub max_pool_size: usize,
329 pub session_config: SessionConfig,
331 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 pub fn with_max_pool_size(mut self, value: usize) -> Self {
348 self.max_pool_size = value;
349 self
350 }
351 pub fn with_session_config(mut self, value: SessionConfig) -> Self {
353 self.session_config = value;
354 self
355 }
356 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
363pub 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 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 pub fn attach_persistence(&self, kvs: Arc<dyn KeyValueStore>) {
387 *self.kvs.lock().unwrap_or_else(|e| e.into_inner()) = Some(kvs);
388 }
389 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 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 pub async fn session_count(&self) -> usize {
442 self.sessions.lock().await.len()
443 }
444 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 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}