use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
use std::sync::{Arc, Mutex, Weak};
use std::time::{Duration, Instant};
use pagesift_api::types::{ScrapeOptions, instance_tag, issued_elsewhere, scrape_id, session_gone};
use pagesift_api::{ApiError, ErrorCode};
use pagesift_cdp::{BrowserPool, KeptPage, SessionRoom};
use pagesift_policy::RobotsMode;
use tokio::task::JoinHandle;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InteractLimits {
pub idle: Duration,
pub ttl: Duration,
}
impl Default for InteractLimits {
fn default() -> Self {
Self {
idle: Duration::from_secs(5 * 60),
ttl: Duration::from_secs(10 * 60),
}
}
}
pub(super) fn session_cap(room: &SessionRoom, open: usize) -> usize {
let tabs = room.max_tabs.saturating_sub(1);
let Some(available) = room.available_mb else {
return tabs;
};
let spare = available as i64 - room.reserve_mb as i64;
let tab = room.tab_mb.max(1) as i64;
let fit = spare.div_euclid(tab) + open as i64;
(fit.max(0) as usize).min(tabs)
}
pub(super) fn idle_limit(prior: Duration, open: usize, cap: usize) -> Duration {
if cap == 0 || open > cap {
return Duration::ZERO;
}
prior.mul_f64((cap + 1 - open.max(1)) as f64 / cap as f64)
}
pub(super) fn reclaim_count(
idle: &[Duration],
others: usize,
prior: Duration,
cap: usize,
) -> usize {
let mut open = idle.len() + others;
let mut n = 0;
for d in idle {
if *d < idle_limit(prior, open, cap) {
break;
}
n += 1;
open -= 1;
}
n
}
pub(super) struct OpenPage {
pub page: KeptPage,
pub pool: Arc<BrowserPool>,
pub opened: Instant,
pub last_used: Instant,
pub url: String,
pub secrets: BTreeSet<String>,
pub scripted: bool,
}
pub(super) enum PageState {
NotOpened,
Open(Box<OpenPage>),
Gone(String),
}
pub(super) struct Record {
pub owner: Option<String>,
pub url: String,
pub opts: ScrapeOptions,
pub mode: RobotsMode,
pub state: tokio::sync::Mutex<PageState>,
bytes: usize,
created: Instant,
}
#[derive(Default)]
struct Table {
map: HashMap<String, Arc<Record>>,
order: VecDeque<String>,
open: HashSet<String>,
pending: usize,
bytes: usize,
}
impl Table {
fn expire(&mut self, ttl: Duration) {
while let Some(id) = self.order.front() {
if self.map.get(id).is_some_and(|r| r.created.elapsed() < ttl) {
return;
}
let Some(id) = self.order.pop_front() else {
return;
};
if !self.open.contains(&id)
&& let Some(r) = self.map.remove(&id)
{
self.bytes -= r.bytes;
}
}
}
fn evict(&mut self, max_bytes: usize) {
while self.bytes > max_bytes {
let Some(id) = self.order.pop_front() else {
return;
};
if self.open.contains(&id) {
continue;
}
if let Some(r) = self.map.remove(&id) {
self.bytes -= r.bytes;
}
}
}
}
pub(super) struct Reserved(Arc<Sessions>);
impl Drop for Reserved {
fn drop(&mut self) {
self.0.table().pending -= 1;
}
}
pub(crate) struct Sessions {
tag: String,
pub limits: InteractLimits,
max_record_bytes: usize,
table: Mutex<Table>,
reaper: Mutex<Option<JoinHandle<()>>>,
}
impl Sessions {
pub(crate) fn new(limits: InteractLimits, max_record_bytes: usize) -> Self {
Self {
tag: instance_tag(),
limits,
max_record_bytes,
table: Mutex::default(),
reaper: Mutex::new(None),
}
}
fn table(&self) -> std::sync::MutexGuard<'_, Table> {
self.table.lock().unwrap_or_else(|e| e.into_inner())
}
pub(super) fn insert(
&self,
id: Option<&str>,
owner: Option<&str>,
url: &str,
opts: &ScrapeOptions,
mode: RobotsMode,
state: PageState,
) -> String {
let id = id.map_or_else(|| scrape_id(&self.tag), str::to_string);
let opened = matches!(state, PageState::Open(_));
let bytes = serde_json::to_string(opts).map_or(0, |s| s.len())
+ url.len()
+ id.len()
+ std::mem::size_of::<Record>();
let record = Arc::new(Record {
owner: owner.map(str::to_string),
url: url.to_string(),
opts: opts.clone(),
mode,
state: tokio::sync::Mutex::new(state),
bytes,
created: Instant::now(),
});
let mut t = self.table();
if let Some(old) = t.map.insert(id.clone(), record) {
t.bytes -= old.bytes;
}
t.bytes += bytes;
t.order.push_back(id.clone());
if opened {
t.open.insert(id.clone());
}
t.expire(self.limits.ttl);
t.evict(self.max_record_bytes);
id
}
pub(super) fn get(&self, id: &str, owner: Option<&str>) -> Result<Arc<Record>, ApiError> {
let found = self.table().map.get(id).cloned();
let Some(r) = found else {
if issued_elsewhere(id, &self.tag) {
return Err(session_gone(format!(
"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"
)));
}
return Err(ApiError::new(
ErrorCode::NotFound,
format!("no scrape {id}: it never existed or its record has expired"),
));
};
if r.owner.as_deref() != owner {
return Err(ApiError::new(
ErrorCode::Forbidden,
format!("scrape {id} belongs to a different account"),
));
}
Ok(r)
}
pub(super) fn mark_open(&self, id: &str, record: &Arc<Record>) {
let mut t = self.table();
if !t.map.contains_key(id) {
t.map.insert(id.to_string(), record.clone());
t.bytes += record.bytes;
}
t.open.insert(id.to_string());
}
pub(super) fn reserve(self: &Arc<Self>, cap: usize) -> Result<Reserved, usize> {
let mut t = self.table();
let taken = t.open.len() + t.pending;
if taken >= cap {
return Err(taken);
}
t.pending += 1;
drop(t);
Ok(Reserved(self.clone()))
}
pub(super) fn mark_closed(&self, id: &str) {
let mut t = self.table();
if t.open.remove(id) {
t.order.push_back(id.to_string());
}
t.evict(self.max_record_bytes);
}
fn open_records(&self) -> Vec<(String, Arc<Record>)> {
let t = self.table();
t.open
.iter()
.filter_map(|id| t.map.get(id).map(|r| (id.clone(), r.clone())))
.collect()
}
async fn close_record(
&self,
id: &str,
record: &Record,
why: String,
expect_used: Option<Instant>,
) {
let Ok(mut state) = record.state.try_lock() else {
return;
};
if let (Some(used), PageState::Open(o)) = (expect_used, &*state)
&& o.last_used != used
{
return;
}
let old = std::mem::replace(&mut *state, PageState::Gone(why));
drop(state);
if let PageState::Open(o) = old {
tracing::debug!(id, "closing interact session");
o.page.close().await;
self.mark_closed(id);
}
}
pub(super) async fn sweep(&self, room: Option<SessionRoom>, extra: usize) -> usize {
let mut idle: Vec<(String, Arc<Record>, Instant)> = Vec::new();
let mut busy = 0;
let mut gone = Vec::new();
let mut room = room;
for (id, r) in self.open_records() {
let Ok(state) = r.state.try_lock() else {
busy += 1;
continue;
};
let PageState::Open(o) = &*state else {
continue;
};
room.get_or_insert_with(|| o.pool.session_room());
if !o.page.is_alive() {
gone.push((id.clone(), r.clone(), "the browser exited".to_string()));
} else if o.opened.elapsed() >= self.limits.ttl {
gone.push((
id.clone(),
r.clone(),
format!(
"it reached the session limit of {} s",
self.limits.ttl.as_secs()
),
));
} else {
idle.push((id.clone(), r.clone(), o.last_used));
}
}
for (id, r, why) in gone {
self.close_record(&id, &r, why, None).await;
}
let Some(room) = room else {
return 0;
};
idle.sort_by_key(|(_, _, used)| *used);
let now = Instant::now();
let waits: Vec<Duration> = idle.iter().map(|(_, _, u)| now - *u).collect();
let open = idle.len() + busy;
let cap = session_cap(&room, open);
let extra = extra + self.table().pending;
let n = reclaim_count(&waits, busy + extra, self.limits.idle, cap);
let mut held: HashMap<Option<String>, usize> = HashMap::new();
for (_, r, _) in &idle {
*held.entry(r.owner.clone()).or_default() += 1;
}
idle.sort_by_key(|(_, r, used)| (std::cmp::Reverse(held[&r.owner]), *used));
for (id, r, used) in idle.into_iter().take(n) {
let why = format!(
"it was idle for {} s; {open} sessions were open and memory admits {cap}",
(now - used).as_secs()
);
self.close_record(&id, &r, why, Some(used)).await;
}
cap
}
pub(super) fn start_reaper(self: &Arc<Self>) {
let mut slot = self.reaper.lock().unwrap_or_else(|e| e.into_inner());
if slot.is_some() {
return;
}
let weak: Weak<Self> = Arc::downgrade(self);
let every = self
.limits
.idle
.mul_f64(pagesift_stat::Delta::DEFAULT.get());
*slot = Some(tokio::spawn(async move {
loop {
tokio::time::sleep(every).await;
let Some(s) = weak.upgrade() else { return };
s.sweep(None, 0).await;
}
}));
}
pub(crate) async fn shutdown(&self) {
if let Some(h) = self.reaper.lock().unwrap_or_else(|e| e.into_inner()).take() {
h.abort();
}
for (id, r) in self.open_records() {
let old = std::mem::replace(
&mut *r.state.lock().await,
PageState::Gone("the server shut down".into()),
);
if let PageState::Open(o) = old {
o.page.close().await;
}
self.mark_closed(&id);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const PRIOR: Duration = Duration::from_secs(300);
fn code(r: Result<Arc<Record>, ApiError>) -> Option<ErrorCode> {
r.err().map(|e| e.code)
}
fn room(available: Option<u64>, max_tabs: usize) -> SessionRoom {
SessionRoom {
available_mb: available,
reserve_mb: 1024,
tab_mb: 200,
max_tabs,
}
}
#[test]
fn capacity_comes_from_memory_and_keeps_a_tab_for_rendering() {
assert_eq!(session_cap(&room(Some(3024), 64), 2), 12);
assert_eq!(session_cap(&room(Some(3024), 4), 2), 3);
assert_eq!(session_cap(&room(None, 8), 0), 7);
assert_eq!(session_cap(&room(Some(524), 64), 3), 0);
assert_eq!(session_cap(&room(Some(524), 64), 5), 2);
}
#[test]
fn the_fuller_the_shorter_the_idle_limit() {
assert_eq!(idle_limit(PRIOR, 1, 10), PRIOR);
assert_eq!(idle_limit(PRIOR, 1, 1), PRIOR);
assert_eq!(idle_limit(PRIOR, 6, 10), PRIOR / 2);
assert_eq!(idle_limit(PRIOR, 10, 10), PRIOR / 10);
assert_eq!(idle_limit(PRIOR, 11, 10), Duration::ZERO);
assert_eq!(idle_limit(PRIOR, 1, 0), Duration::ZERO);
}
#[test]
fn reclaims_the_oldest_until_it_fits() {
let s = |n| Duration::from_secs(n);
assert_eq!(reclaim_count(&[s(10), s(5)], 0, PRIOR, 10), 0);
assert_eq!(reclaim_count(&[s(301)], 0, PRIOR, 10), 1);
assert_eq!(reclaim_count(&[s(200), s(60), s(1)], 0, PRIOR, 3), 1);
assert_eq!(reclaim_count(&[s(2), s(1)], 1, PRIOR, 2), 1);
assert_eq!(reclaim_count(&[s(1)], 3, PRIOR, 2), 1);
}
#[test]
fn a_record_evicted_while_opening_can_still_be_closed() {
let s = Arc::new(Sessions::new(InteractLimits::default(), 4096));
let opts = ScrapeOptions::default();
let id = s.insert(
None,
None,
"https://a.test/",
&opts,
RobotsMode::Respect,
PageState::NotOpened,
);
let record = s.get(&id, None).unwrap();
for _ in 0..64 {
s.insert(
None,
None,
"https://a.test/",
&opts,
RobotsMode::Respect,
PageState::NotOpened,
);
}
assert!(s.get(&id, None).is_err(), "应该已被淘汰");
s.mark_open(&id, &record);
assert!(s.get(&id, None).is_ok());
assert!(s.reserve(1).is_err(), "开着的页面占名额");
s.mark_closed(&id);
assert!(s.table().open.is_empty());
}
#[test]
fn reservations_count_until_released() {
let s = Arc::new(Sessions::new(InteractLimits::default(), 4096));
let first = s.reserve(1).unwrap();
assert_eq!(s.reserve(1).err(), Some(1));
drop(first);
assert!(s.reserve(1).is_ok());
}
#[test]
fn records_are_bounded_by_bytes_and_tell_ids_apart() {
let s = Sessions::new(InteractLimits::default(), 4096);
let opts = ScrapeOptions::default();
let first = s.insert(
None,
None,
"https://a.test/",
&opts,
RobotsMode::Respect,
PageState::NotOpened,
);
assert!(s.get(&first, None).is_ok());
assert_eq!(
code(s.get(&first, Some("other"))),
Some(ErrorCode::Forbidden)
);
assert_eq!(code(s.get("nope", None)), Some(ErrorCode::NotFound));
let elsewhere = scrape_id(&instance_tag());
assert_eq!(code(s.get(&elsewhere, None)), Some(ErrorCode::SessionGone));
for _ in 0..64 {
s.insert(
None,
None,
"https://a.test/",
&opts,
RobotsMode::Respect,
PageState::NotOpened,
);
}
assert!(s.table().bytes <= 4096);
assert_eq!(code(s.get(&first, None)), Some(ErrorCode::NotFound));
}
}