Skip to main content

pagesift_engine/engine/interact/
registry.rs

1//! 会话表:scrapeId → 抓取的回放信息和保留的页面;空闲回收。
2//!
3//! **留多久**:上限是 Firecrawl 文档的默认值(空闲 5 分钟、总时长 10 分钟),实际空闲上限由池的内存准入推出:
4//! - 内存放得下几个:`(可用 − 余量) / 每标签内存 + 已开的页面数`(已开页面的内存已经算在“用掉”里),
5//!   再和标签名额(池的标签上限 − 1,至少给渲染留一个)取小,见 [`session_cap`];
6//! - 占得越满留得越短:`空闲上限 = 先验 × (1 − (已开页面数 − 1) / 放得下的数)`,见 [`idle_limit`]。
7//!   只开一个时等于先验;刚好放满时还有 `先验 / 放得下的数`;超出(别的进程吃掉了内存)时是 0,
8//!   从最久没用的开始关,关到放得下为止。可用内存、余量、每标签内存都是池实测的同一组数。
9//!
10//! **记录表**:没有保留页面的记录(回放信息、已关闭的说明)按字节数限制,总量不超过多开一个
11//! 标签页的内存,满了淘汰最早的;开着页面的记录不淘汰(它们受上面的上限约束)。
12
13use 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/// 保留会话的时长上限。
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct InteractLimits {
26    /// 内存宽裕时的空闲上限。
27    pub idle: Duration,
28    /// 一个会话从打开起的总时长上限。
29    pub ttl: Duration,
30}
31
32impl Default for InteractLimits {
33    fn default() -> Self {
34        Self {
35            // [协议] Firecrawl 文档 features/interact#session-lifecycle:inactivity timeout 默认 5 分钟
36            idle: Duration::from_secs(5 * 60),
37            // [协议] 同上:TTL 默认 10 分钟
38            ttl: Duration::from_secs(10 * 60),
39        }
40    }
41}
42
43/// 这台机器现在放得下多少个保留页面(含已开的 `open` 个)。可以小于 `open`:内存被别的进程吃掉了。
44pub(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    // 向下取整:差一点放不下就是放不下
52    let fit = spare.div_euclid(tab) + open as i64;
53    (fit.max(0) as usize).min(tabs)
54}
55
56/// 已开 `open` 个、放得下 `cap` 个时,一个页面能空闲多久。
57pub(super) fn idle_limit(prior: Duration, open: usize, cap: usize) -> Duration {
58    if cap == 0 || open > cap {
59        return Duration::ZERO;
60    }
61    // 即 1 − (open − 1) / cap;写成一个分数,免得 1 减小数带来的舍入
62    prior.mul_f64((cap + 1 - open.max(1)) as f64 / cap as f64)
63}
64
65/// `idle` 按空闲时长从长到短排好:前多少个该关。`others` 是这之外还占名额的数(正在用的、要新开的)。
66pub(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    /// 当前页面的网址:下一次动作前查 robots、动作后看有没有跳走。
90    pub url: String,
91    /// 这个会话用过的 `{{secret:NAME}}`:它们的值不进模型,也不进响应。
92    pub secrets: BTreeSet<String>,
93    /// 跑过调用方的脚本:页面上可能已经有监听器,不能再往里填秘密。
94    pub scripted: bool,
95}
96
97pub(super) enum PageState {
98    /// 抓取时没有要求保留:第一次 interact 时重新打开。
99    NotOpened,
100    Open(Box<OpenPage>),
101    /// 已经结束,附原因。
102    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    /// 记录占的字节数(估计)。
112    bytes: usize,
113    created: Instant,
114}
115
116#[derive(Default)]
117struct Table {
118    map: HashMap<String, Arc<Record>>,
119    /// 可以淘汰的记录,最早的在前;可能有已经开了页面或已被淘汰的旧项,取出时再看。
120    order: VecDeque<String>,
121    open: HashSet<String>,
122    /// 已经准入、页面还在打开的名额(打开要好几秒,同时来的请求不能都算还有空位)。
123    pending: usize,
124    bytes: usize,
125}
126
127impl Table {
128    /// 去掉超过 `ttl` 还没打开过的记录:它们带着调用方的请求头,过了会话时限也用不上了。
129    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            // 开着页面的不去掉:关闭时重新排队
138            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            // 开着页面的不淘汰:关闭时重新排队
152            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
162/// 占着一个保留页面的名额;页面登记之后放开。
163pub(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    /// 新记录,返回 scrapeId。`state` 是 [`PageState::Open`] 时同时登记成开着的。
195    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        // 选项里可能带调用方给的 schema,按序列化后的长度算;再算上键和记录本身
207        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    /// 查记录。查不到时分清“上一个进程发的”和“没有这个 id”。
235    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    /// 记录从“没开页面”变成开着。打开页面的那几秒里它可能已被淘汰:放回去,不然 `open` 里的 id
258    /// 再也找不到记录,名额永远还不回来。
259    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    /// 占一个名额;放不下时返回已占的数量。
269    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    /// 页面关了:记录重新变成可淘汰的。
281    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    /// 关一个页面,记下原因。`expect_used`:决定回收之后这个会话又被用过,就不关了。
298    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    /// 回收:死掉的、超过总时长的、按内存该关的。`extra` 是马上要新开的页面数。
323    /// 返回算出来放得下多少个(`room` 为 `None` 且没有开着的页面时不用算,返回 0)。
324    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        // 关几个按空闲时间算;关谁先看账户:占得最多的账户先让出,一个账户开再多也挤不掉别人的
367        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    /// 后台回收任务:第一个页面开起来时启动,间隔是空闲上限先验的 δ 倍(和池的健康检查的下限同一个式子)。
383    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    /// 停掉回收任务,关掉全部页面(服务退出时)。
403    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    /// `Record` 带着页面状态,没有 `Debug`:只看错误码。
427    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        // 可用 3024 − 余量 1024 = 2000 MB,再放 10 个;已开 2 个时一共放得下 12 个
443        assert_eq!(session_cap(&room(Some(3024), 64), 2), 12);
444        // 标签名额更紧:留一个给渲染
445        assert_eq!(session_cap(&room(Some(3024), 4), 2), 3);
446        // 读不到内存:只看标签名额
447        assert_eq!(session_cap(&room(None, 8), 0), 7);
448        // 可用内存比余量少 500 MB:要关掉 3 个才够(向下取整),3 个里一个都放不下
449        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        // 放得下:都没到上限,不关
467        assert_eq!(reclaim_count(&[s(10), s(5)], 0, PRIOR, 10), 0);
468        // 只开一个、空闲超过先验:关
469        assert_eq!(reclaim_count(&[s(301)], 0, PRIOR, 10), 1);
470        // 刚好放满:上限是先验的 1/3,最久的 200 s 已超过,关掉后上限变长,下一个留下
471        assert_eq!(reclaim_count(&[s(200), s(60), s(1)], 0, PRIOR, 3), 1);
472        // 要新开一个、已经放满:只关腾出一个位置需要的数量(刚用过的也算)
473        assert_eq!(reclaim_count(&[s(2), s(1)], 1, PRIOR, 2), 1);
474        // 正在用的不在列表里,但占名额:放不下时关空闲的,关完还放不下就没有可关的了
475        assert_eq!(reclaim_count(&[s(1)], 3, PRIOR, 2), 1);
476    }
477
478    /// 打开页面的几秒里记录被淘汰了:名额要能还回来,不然最终所有保留页面都报 429。
479    #[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    /// 同时来的请求不能都算还有空位。
511    #[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        // 最早的被淘汰:这个进程发的 id,查不到就是过期了
552        assert_eq!(code(s.get(&first, None)), Some(ErrorCode::NotFound));
553    }
554}