1use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
14use std::sync::{Arc, Mutex, Weak};
15use std::time::{Duration, Instant};
16
17use pagesift_api::types::{ScrapeOptions, instance_tag, issued_elsewhere, scrape_id, session_gone};
18use pagesift_api::{ApiError, ErrorCode};
19use pagesift_cdp::{BrowserPool, KeptPage, SessionRoom};
20use pagesift_policy::RobotsMode;
21use tokio::task::JoinHandle;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct InteractLimits {
26 pub idle: Duration,
28 pub ttl: Duration,
30}
31
32impl Default for InteractLimits {
33 fn default() -> Self {
34 Self {
35 idle: Duration::from_secs(5 * 60),
37 ttl: Duration::from_secs(10 * 60),
39 }
40 }
41}
42
43pub(super) fn session_cap(room: &SessionRoom, open: usize) -> usize {
45 let tabs = room.max_tabs.saturating_sub(1);
46 let Some(available) = room.available_mb else {
47 return tabs;
48 };
49 let spare = available as i64 - room.reserve_mb as i64;
50 let tab = room.tab_mb.max(1) as i64;
51 let fit = spare.div_euclid(tab) + open as i64;
53 (fit.max(0) as usize).min(tabs)
54}
55
56pub(super) fn idle_limit(prior: Duration, open: usize, cap: usize) -> Duration {
58 if cap == 0 || open > cap {
59 return Duration::ZERO;
60 }
61 prior.mul_f64((cap + 1 - open.max(1)) as f64 / cap as f64)
63}
64
65pub(super) fn reclaim_count(
67 idle: &[Duration],
68 others: usize,
69 prior: Duration,
70 cap: usize,
71) -> usize {
72 let mut open = idle.len() + others;
73 let mut n = 0;
74 for d in idle {
75 if *d < idle_limit(prior, open, cap) {
76 break;
77 }
78 n += 1;
79 open -= 1;
80 }
81 n
82}
83
84pub(super) struct OpenPage {
85 pub page: KeptPage,
86 pub pool: Arc<BrowserPool>,
87 pub opened: Instant,
88 pub last_used: Instant,
89 pub url: String,
91 pub secrets: BTreeSet<String>,
93 pub scripted: bool,
95}
96
97pub(super) enum PageState {
98 NotOpened,
100 Open(Box<OpenPage>),
101 Gone(String),
103}
104
105pub(super) struct Record {
106 pub owner: Option<String>,
107 pub url: String,
108 pub opts: ScrapeOptions,
109 pub mode: RobotsMode,
110 pub state: tokio::sync::Mutex<PageState>,
111 bytes: usize,
113 created: Instant,
114}
115
116#[derive(Default)]
117struct Table {
118 map: HashMap<String, Arc<Record>>,
119 order: VecDeque<String>,
121 open: HashSet<String>,
122 pending: usize,
124 bytes: usize,
125}
126
127impl Table {
128 fn expire(&mut self, ttl: Duration) {
130 while let Some(id) = self.order.front() {
131 if self.map.get(id).is_some_and(|r| r.created.elapsed() < ttl) {
132 return;
133 }
134 let Some(id) = self.order.pop_front() else {
135 return;
136 };
137 if !self.open.contains(&id)
139 && let Some(r) = self.map.remove(&id)
140 {
141 self.bytes -= r.bytes;
142 }
143 }
144 }
145
146 fn evict(&mut self, max_bytes: usize) {
147 while self.bytes > max_bytes {
148 let Some(id) = self.order.pop_front() else {
149 return;
150 };
151 if self.open.contains(&id) {
153 continue;
154 }
155 if let Some(r) = self.map.remove(&id) {
156 self.bytes -= r.bytes;
157 }
158 }
159 }
160}
161
162pub(super) struct Reserved(Arc<Sessions>);
164
165impl Drop for Reserved {
166 fn drop(&mut self) {
167 self.0.table().pending -= 1;
168 }
169}
170
171pub(crate) struct Sessions {
172 tag: String,
173 pub limits: InteractLimits,
174 max_record_bytes: usize,
175 table: Mutex<Table>,
176 reaper: Mutex<Option<JoinHandle<()>>>,
177}
178
179impl Sessions {
180 pub(crate) fn new(limits: InteractLimits, max_record_bytes: usize) -> Self {
181 Self {
182 tag: instance_tag(),
183 limits,
184 max_record_bytes,
185 table: Mutex::default(),
186 reaper: Mutex::new(None),
187 }
188 }
189
190 fn table(&self) -> std::sync::MutexGuard<'_, Table> {
191 self.table.lock().unwrap_or_else(|e| e.into_inner())
192 }
193
194 pub(super) fn insert(
196 &self,
197 id: Option<&str>,
198 owner: Option<&str>,
199 url: &str,
200 opts: &ScrapeOptions,
201 mode: RobotsMode,
202 state: PageState,
203 ) -> String {
204 let id = id.map_or_else(|| scrape_id(&self.tag), str::to_string);
205 let opened = matches!(state, PageState::Open(_));
206 let bytes = serde_json::to_string(opts).map_or(0, |s| s.len())
208 + url.len()
209 + id.len()
210 + std::mem::size_of::<Record>();
211 let record = Arc::new(Record {
212 owner: owner.map(str::to_string),
213 url: url.to_string(),
214 opts: opts.clone(),
215 mode,
216 state: tokio::sync::Mutex::new(state),
217 bytes,
218 created: Instant::now(),
219 });
220 let mut t = self.table();
221 if let Some(old) = t.map.insert(id.clone(), record) {
222 t.bytes -= old.bytes;
223 }
224 t.bytes += bytes;
225 t.order.push_back(id.clone());
226 if opened {
227 t.open.insert(id.clone());
228 }
229 t.expire(self.limits.ttl);
230 t.evict(self.max_record_bytes);
231 id
232 }
233
234 pub(super) fn get(&self, id: &str, owner: Option<&str>) -> Result<Arc<Record>, ApiError> {
236 let found = self.table().map.get(id).cloned();
237 let Some(r) = found else {
238 if issued_elsewhere(id, &self.tag) {
239 return Err(session_gone(format!(
240 "scrape {id} was not issued by this server process: the server restarted or the id belongs to another machine, so its browser session is gone; run the scrape again"
241 )));
242 }
243 return Err(ApiError::new(
244 ErrorCode::NotFound,
245 format!("no scrape {id}: it never existed or its record has expired"),
246 ));
247 };
248 if r.owner.as_deref() != owner {
249 return Err(ApiError::new(
250 ErrorCode::Forbidden,
251 format!("scrape {id} belongs to a different account"),
252 ));
253 }
254 Ok(r)
255 }
256
257 pub(super) fn mark_open(&self, id: &str, record: &Arc<Record>) {
260 let mut t = self.table();
261 if !t.map.contains_key(id) {
262 t.map.insert(id.to_string(), record.clone());
263 t.bytes += record.bytes;
264 }
265 t.open.insert(id.to_string());
266 }
267
268 pub(super) fn reserve(self: &Arc<Self>, cap: usize) -> Result<Reserved, usize> {
270 let mut t = self.table();
271 let taken = t.open.len() + t.pending;
272 if taken >= cap {
273 return Err(taken);
274 }
275 t.pending += 1;
276 drop(t);
277 Ok(Reserved(self.clone()))
278 }
279
280 pub(super) fn mark_closed(&self, id: &str) {
282 let mut t = self.table();
283 if t.open.remove(id) {
284 t.order.push_back(id.to_string());
285 }
286 t.evict(self.max_record_bytes);
287 }
288
289 fn open_records(&self) -> Vec<(String, Arc<Record>)> {
290 let t = self.table();
291 t.open
292 .iter()
293 .filter_map(|id| t.map.get(id).map(|r| (id.clone(), r.clone())))
294 .collect()
295 }
296
297 async fn close_record(
299 &self,
300 id: &str,
301 record: &Record,
302 why: String,
303 expect_used: Option<Instant>,
304 ) {
305 let Ok(mut state) = record.state.try_lock() else {
306 return;
307 };
308 if let (Some(used), PageState::Open(o)) = (expect_used, &*state)
309 && o.last_used != used
310 {
311 return;
312 }
313 let old = std::mem::replace(&mut *state, PageState::Gone(why));
314 drop(state);
315 if let PageState::Open(o) = old {
316 tracing::debug!(id, "closing interact session");
317 o.page.close().await;
318 self.mark_closed(id);
319 }
320 }
321
322 pub(super) async fn sweep(&self, room: Option<SessionRoom>, extra: usize) -> usize {
325 let mut idle: Vec<(String, Arc<Record>, Instant)> = Vec::new();
326 let mut busy = 0;
327 let mut gone = Vec::new();
328 let mut room = room;
329 for (id, r) in self.open_records() {
330 let Ok(state) = r.state.try_lock() else {
331 busy += 1;
332 continue;
333 };
334 let PageState::Open(o) = &*state else {
335 continue;
336 };
337 room.get_or_insert_with(|| o.pool.session_room());
338 if !o.page.is_alive() {
339 gone.push((id.clone(), r.clone(), "the browser exited".to_string()));
340 } else if o.opened.elapsed() >= self.limits.ttl {
341 gone.push((
342 id.clone(),
343 r.clone(),
344 format!(
345 "it reached the session limit of {} s",
346 self.limits.ttl.as_secs()
347 ),
348 ));
349 } else {
350 idle.push((id.clone(), r.clone(), o.last_used));
351 }
352 }
353 for (id, r, why) in gone {
354 self.close_record(&id, &r, why, None).await;
355 }
356 let Some(room) = room else {
357 return 0;
358 };
359 idle.sort_by_key(|(_, _, used)| *used);
360 let now = Instant::now();
361 let waits: Vec<Duration> = idle.iter().map(|(_, _, u)| now - *u).collect();
362 let open = idle.len() + busy;
363 let cap = session_cap(&room, open);
364 let extra = extra + self.table().pending;
365 let n = reclaim_count(&waits, busy + extra, self.limits.idle, cap);
366 let mut held: HashMap<Option<String>, usize> = HashMap::new();
368 for (_, r, _) in &idle {
369 *held.entry(r.owner.clone()).or_default() += 1;
370 }
371 idle.sort_by_key(|(_, r, used)| (std::cmp::Reverse(held[&r.owner]), *used));
372 for (id, r, used) in idle.into_iter().take(n) {
373 let why = format!(
374 "it was idle for {} s; {open} sessions were open and memory admits {cap}",
375 (now - used).as_secs()
376 );
377 self.close_record(&id, &r, why, Some(used)).await;
378 }
379 cap
380 }
381
382 pub(super) fn start_reaper(self: &Arc<Self>) {
384 let mut slot = self.reaper.lock().unwrap_or_else(|e| e.into_inner());
385 if slot.is_some() {
386 return;
387 }
388 let weak: Weak<Self> = Arc::downgrade(self);
389 let every = self
390 .limits
391 .idle
392 .mul_f64(pagesift_stat::Delta::DEFAULT.get());
393 *slot = Some(tokio::spawn(async move {
394 loop {
395 tokio::time::sleep(every).await;
396 let Some(s) = weak.upgrade() else { return };
397 s.sweep(None, 0).await;
398 }
399 }));
400 }
401
402 pub(crate) async fn shutdown(&self) {
404 if let Some(h) = self.reaper.lock().unwrap_or_else(|e| e.into_inner()).take() {
405 h.abort();
406 }
407 for (id, r) in self.open_records() {
408 let old = std::mem::replace(
409 &mut *r.state.lock().await,
410 PageState::Gone("the server shut down".into()),
411 );
412 if let PageState::Open(o) = old {
413 o.page.close().await;
414 }
415 self.mark_closed(&id);
416 }
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 const PRIOR: Duration = Duration::from_secs(300);
425
426 fn code(r: Result<Arc<Record>, ApiError>) -> Option<ErrorCode> {
428 r.err().map(|e| e.code)
429 }
430
431 fn room(available: Option<u64>, max_tabs: usize) -> SessionRoom {
432 SessionRoom {
433 available_mb: available,
434 reserve_mb: 1024,
435 tab_mb: 200,
436 max_tabs,
437 }
438 }
439
440 #[test]
441 fn capacity_comes_from_memory_and_keeps_a_tab_for_rendering() {
442 assert_eq!(session_cap(&room(Some(3024), 64), 2), 12);
444 assert_eq!(session_cap(&room(Some(3024), 4), 2), 3);
446 assert_eq!(session_cap(&room(None, 8), 0), 7);
448 assert_eq!(session_cap(&room(Some(524), 64), 3), 0);
450 assert_eq!(session_cap(&room(Some(524), 64), 5), 2);
451 }
452
453 #[test]
454 fn the_fuller_the_shorter_the_idle_limit() {
455 assert_eq!(idle_limit(PRIOR, 1, 10), PRIOR);
456 assert_eq!(idle_limit(PRIOR, 1, 1), PRIOR);
457 assert_eq!(idle_limit(PRIOR, 6, 10), PRIOR / 2);
458 assert_eq!(idle_limit(PRIOR, 10, 10), PRIOR / 10);
459 assert_eq!(idle_limit(PRIOR, 11, 10), Duration::ZERO);
460 assert_eq!(idle_limit(PRIOR, 1, 0), Duration::ZERO);
461 }
462
463 #[test]
464 fn reclaims_the_oldest_until_it_fits() {
465 let s = |n| Duration::from_secs(n);
466 assert_eq!(reclaim_count(&[s(10), s(5)], 0, PRIOR, 10), 0);
468 assert_eq!(reclaim_count(&[s(301)], 0, PRIOR, 10), 1);
470 assert_eq!(reclaim_count(&[s(200), s(60), s(1)], 0, PRIOR, 3), 1);
472 assert_eq!(reclaim_count(&[s(2), s(1)], 1, PRIOR, 2), 1);
474 assert_eq!(reclaim_count(&[s(1)], 3, PRIOR, 2), 1);
476 }
477
478 #[test]
480 fn a_record_evicted_while_opening_can_still_be_closed() {
481 let s = Arc::new(Sessions::new(InteractLimits::default(), 4096));
482 let opts = ScrapeOptions::default();
483 let id = s.insert(
484 None,
485 None,
486 "https://a.test/",
487 &opts,
488 RobotsMode::Respect,
489 PageState::NotOpened,
490 );
491 let record = s.get(&id, None).unwrap();
492 for _ in 0..64 {
493 s.insert(
494 None,
495 None,
496 "https://a.test/",
497 &opts,
498 RobotsMode::Respect,
499 PageState::NotOpened,
500 );
501 }
502 assert!(s.get(&id, None).is_err(), "应该已被淘汰");
503 s.mark_open(&id, &record);
504 assert!(s.get(&id, None).is_ok());
505 assert!(s.reserve(1).is_err(), "开着的页面占名额");
506 s.mark_closed(&id);
507 assert!(s.table().open.is_empty());
508 }
509
510 #[test]
512 fn reservations_count_until_released() {
513 let s = Arc::new(Sessions::new(InteractLimits::default(), 4096));
514 let first = s.reserve(1).unwrap();
515 assert_eq!(s.reserve(1).err(), Some(1));
516 drop(first);
517 assert!(s.reserve(1).is_ok());
518 }
519
520 #[test]
521 fn records_are_bounded_by_bytes_and_tell_ids_apart() {
522 let s = Sessions::new(InteractLimits::default(), 4096);
523 let opts = ScrapeOptions::default();
524 let first = s.insert(
525 None,
526 None,
527 "https://a.test/",
528 &opts,
529 RobotsMode::Respect,
530 PageState::NotOpened,
531 );
532 assert!(s.get(&first, None).is_ok());
533 assert_eq!(
534 code(s.get(&first, Some("other"))),
535 Some(ErrorCode::Forbidden)
536 );
537 assert_eq!(code(s.get("nope", None)), Some(ErrorCode::NotFound));
538 let elsewhere = scrape_id(&instance_tag());
539 assert_eq!(code(s.get(&elsewhere, None)), Some(ErrorCode::SessionGone));
540 for _ in 0..64 {
541 s.insert(
542 None,
543 None,
544 "https://a.test/",
545 &opts,
546 RobotsMode::Respect,
547 PageState::NotOpened,
548 );
549 }
550 assert!(s.table().bytes <= 4096);
551 assert_eq!(code(s.get(&first, None)), Some(ErrorCode::NotFound));
553 }
554}