Skip to main content

pagesift_api/
jobs.rs

1//! 异步任务(批量抓取和整站爬取):进度、结果、错误,过期后清理。
2//!
3//! 给了存储(F-6.4)时任务落盘,服务重启后:已结束的任务照样能查(直到过期),
4//! 进行中的任务从断点继续。存储布局(都在 [`pagesift_store::Tasks`] 里):
5//!
6//! - KV `job/{id}`:任务元数据(类型、状态、创建时间、原始请求、webhook、调用方);
7//! - 列表 `job/{id}/pages`:完成的文档,按完成顺序,序号就是分页下标;
8//! - 列表 `job/{id}/errors`:页面错误;
9//! - 列表 `job/{id}/frontier`:爬取入过队的 URL 和深度,按入队顺序;
10//! - 集合 `job/{id}/done`:已完成的 URL(爬取)或下标(批量抓取)。
11//!
12//! 每个页面先写文档再记完成:崩溃时最多重复抓一个页面,不会丢页面。
13//! 不给存储时一切留在内存里,行为和以前一样。
14
15use std::collections::{BTreeMap, HashMap};
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::{Arc, Mutex as StdMutex};
18use std::time::{Duration, SystemTime, UNIX_EPOCH};
19
20use pagesift_store::Store;
21use serde::{Deserialize, Serialize};
22use tokio::sync::{Mutex, Semaphore};
23
24use crate::engine::{BoxFuture, CrawlResume, CrawlSink, Engine};
25use crate::error::{ApiError, ErrorCode};
26use crate::limits::{Caller, KeyLimits, Limiter, quota_error};
27use crate::metrics::{Metrics, PageSource};
28use crate::types::{CrawlRequest, CrawlSummary, Document, JobError, ScrapeOptions};
29use crate::webhook::{Event, Hook, WebhookConfig, Webhooks};
30
31/// Firecrawl 的任务结果保留 24 小时。
32pub const JOB_TTL: Duration = Duration::from_secs(24 * 3600);
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum JobState {
37    Scraping,
38    Completed,
39    Cancelled,
40    Failed,
41}
42
43impl JobState {
44    pub fn as_str(self) -> &'static str {
45        match self {
46            JobState::Scraping => "scraping",
47            JobState::Completed => "completed",
48            JobState::Cancelled => "cancelled",
49            JobState::Failed => "failed",
50        }
51    }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum JobKind {
57    Crawl,
58    BatchScrape,
59}
60
61impl JobKind {
62    /// 也是 webhook 事件类型的前缀(`crawl.page`、`batch_scrape.completed`)。
63    pub fn as_str(self) -> &'static str {
64        match self {
65            JobKind::Crawl => "crawl",
66            JobKind::BatchScrape => "batch_scrape",
67        }
68    }
69
70    fn pages(self) -> PageSource {
71        match self {
72            JobKind::Crawl => PageSource::Crawl,
73            JobKind::BatchScrape => PageSource::Batch,
74        }
75    }
76}
77
78#[derive(Debug)]
79pub struct Job {
80    pub kind: JobKind,
81    pub state: JobState,
82    pub total: usize,
83    pub completed: usize,
84    /// 按完成顺序追加,分页用下标。任务落盘时文档在存储里,这里为空。
85    pub data: Vec<Document>,
86    /// 任务落盘时错误在存储里,这里为空。
87    pub errors: Vec<JobError>,
88    pub robots_blocked: Vec<String>,
89    pub created: SystemTime,
90    /// 带查询的爬取结束后的说明。
91    pub summary: Option<CrawlSummary>,
92    /// 存储里已有的文档数。
93    stored_docs: usize,
94    owner: Option<Caller>,
95}
96
97impl Job {
98    fn new(kind: JobKind, total: usize, owner: Option<Caller>) -> Self {
99        Self {
100            kind,
101            state: JobState::Scraping,
102            total,
103            completed: 0,
104            data: Vec::new(),
105            errors: Vec::new(),
106            robots_blocked: Vec::new(),
107            created: SystemTime::now(),
108            summary: None,
109            stored_docs: 0,
110            owner,
111        }
112    }
113
114    pub fn expires_at(&self) -> SystemTime {
115        self.created + JOB_TTL
116    }
117
118    fn docs(&self) -> usize {
119        self.data.len() + self.stored_docs
120    }
121}
122
123/// 任务状态的一页。
124pub(crate) struct StatusPage {
125    pub state: JobState,
126    pub total: usize,
127    pub completed: usize,
128    pub expires_at: SystemTime,
129    /// 文档总数。
130    pub docs: usize,
131    pub data: Vec<Document>,
132    pub summary: Option<CrawlSummary>,
133}
134
135/// 启动任务时的调用方信息。
136#[derive(Default)]
137pub(crate) struct Start {
138    pub owner: Option<Caller>,
139    pub webhook: Option<WebhookConfig>,
140}
141
142/// 落盘的任务元数据。
143#[derive(Serialize, Deserialize)]
144struct Meta {
145    kind: JobKind,
146    state: JobState,
147    created_ms: u64,
148    total: usize,
149    completed: usize,
150    owner: Option<String>,
151    webhook: Option<WebhookConfig>,
152    crawl: Option<CrawlRequest>,
153    batch: Option<BatchSpec>,
154    /// 带查询的爬取结束后的说明;旧版本存的任务没有这个字段。
155    #[serde(default)]
156    summary: Option<CrawlSummary>,
157}
158
159#[derive(Clone, Serialize, Deserialize)]
160struct BatchSpec {
161    urls: Vec<String>,
162    options: ScrapeOptions,
163    concurrency: usize,
164}
165
166fn meta_key(id: &str) -> String {
167    format!("job/{id}")
168}
169
170fn list_key(id: &str, what: &str) -> String {
171    format!("job/{id}/{what}")
172}
173
174// Store identity with the result in ONE CRC-protected log record. The done
175// set is a derived index: startup repairs it before selecting pending work.
176// Public Document/JobError decoding deliberately omits this private field.
177#[derive(Serialize)]
178struct Completion<'a, T> {
179    #[serde(flatten)]
180    value: &'a T,
181    #[serde(rename = "_pagesiftDone")]
182    done: &'a str,
183}
184
185#[derive(Deserialize)]
186struct Receipt {
187    #[serde(default, rename = "_pagesiftDone")]
188    done: Option<String>,
189}
190
191fn repair_done(store: &Store, id: &str) -> pagesift_store::Result<()> {
192    let t = store.tasks();
193    let done = list_key(id, "done");
194    for kind in ["pages", "errors"] {
195        let key = list_key(id, kind);
196        for start in (0..t.list_len(&key)).step_by(256) {
197            for raw in t.list_range(&key, start, 256)? {
198                if let Ok(Receipt { done: Some(member) }) = serde_json::from_slice(&raw) {
199                    t.set_insert(&done, member)?;
200                }
201            }
202        }
203    }
204    Ok(())
205}
206
207fn to_ms(t: SystemTime) -> u64 {
208    t.duration_since(UNIX_EPOCH)
209        .map_or(0, |d| d.as_millis() as u64)
210}
211
212struct Inner {
213    map: Mutex<HashMap<String, Job>>,
214    store: Option<Store>,
215    limiter: Arc<Limiter>,
216    webhooks: Option<Webhooks>,
217    metrics: Arc<Metrics>,
218}
219
220#[derive(Clone)]
221pub struct Jobs {
222    inner: Arc<Inner>,
223}
224
225impl Default for Jobs {
226    /// 只在内存里的任务表:不落盘、不限额、不发 webhook。
227    fn default() -> Self {
228        Self::new(None, Arc::default(), None, Arc::default())
229    }
230}
231
232impl Jobs {
233    pub(crate) fn new(
234        store: Option<Store>,
235        limiter: Arc<Limiter>,
236        webhooks: Option<Webhooks>,
237        metrics: Arc<Metrics>,
238    ) -> Self {
239        Self {
240            inner: Arc::new(Inner {
241                map: Mutex::new(HashMap::new()),
242                store,
243                limiter,
244                webhooks,
245                metrics,
246            }),
247        }
248    }
249
250    /// 清掉过期任务(连同存储里的数据)。
251    async fn sweep(&self) {
252        let now = SystemTime::now();
253        let expired: Vec<String> = {
254            let mut map = self.inner.map.lock().await;
255            let ids: Vec<String> = map
256                .iter()
257                .filter(|(_, j)| j.expires_at() <= now)
258                .map(|(id, _)| id.clone())
259                .collect();
260            for id in &ids {
261                map.remove(id);
262            }
263            ids
264        };
265        if let (Some(store), false) = (&self.inner.store, expired.is_empty()) {
266            store
267                .blocking(move |s| {
268                    for id in expired {
269                        delete_job(s, &id);
270                    }
271                })
272                .await;
273        }
274    }
275
276    pub async fn get<R>(&self, id: &str, f: impl FnOnce(&Job) -> R) -> Option<R> {
277        self.sweep().await;
278        self.inner.map.lock().await.get(id).map(f)
279    }
280
281    /// 状态和从 `skip` 开始最多 `limit` 个文档。
282    pub(crate) async fn status(&self, id: &str, skip: usize, limit: usize) -> Option<StatusPage> {
283        self.sweep().await;
284        let mut page = {
285            let map = self.inner.map.lock().await;
286            let job = map.get(id)?;
287            let end = (skip + limit).min(job.data.len());
288            StatusPage {
289                state: job.state,
290                total: job.total,
291                completed: job.completed,
292                expires_at: job.expires_at(),
293                docs: job.docs(),
294                data: job
295                    .data
296                    .get(skip.min(end)..end)
297                    .unwrap_or_default()
298                    .to_vec(),
299                summary: job.summary.clone(),
300            }
301        };
302        if let Some(store) = &self.inner.store
303            && page.data.is_empty()
304            && skip < page.docs
305        {
306            let key = list_key(id, "pages");
307            page.data = store
308                .blocking(move |s| s.tasks().list_range(key, skip as u64, limit))
309                .await
310                .unwrap_or_else(|e| {
311                    tracing::warn!(error = %e, "reading job pages failed");
312                    Vec::new()
313                })
314                .iter()
315                .filter_map(|v| serde_json::from_slice(v).ok())
316                .collect();
317        }
318        Some(page)
319    }
320
321    /// 任务的页面错误和被 robots.txt 拦下的 URL。
322    pub(crate) async fn errors(&self, id: &str) -> Option<(Vec<JobError>, Vec<String>)> {
323        self.sweep().await;
324        let (mut errors, mut robots) = {
325            let map = self.inner.map.lock().await;
326            let job = map.get(id)?;
327            (job.errors.clone(), job.robots_blocked.clone())
328        };
329        if let Some(store) = &self.inner.store {
330            let key = list_key(id, "errors");
331            let stored = store
332                .blocking(move |s| s.tasks().list_items(key, usize::MAX))
333                .await
334                .unwrap_or_default();
335            for v in stored {
336                if let Ok(e) = serde_json::from_slice::<JobError>(&v) {
337                    if e.code == ErrorCode::RobotsDisallowed.as_str() {
338                        robots.push(e.url.clone());
339                    }
340                    errors.push(e);
341                }
342            }
343        }
344        Some((errors, robots))
345    }
346
347    pub async fn cancel(&self, id: &str) -> bool {
348        let found = {
349            let mut map = self.inner.map.lock().await;
350            match map.get_mut(id) {
351                Some(j) => {
352                    if j.state == JobState::Scraping {
353                        j.state = JobState::Cancelled;
354                    }
355                    true
356                }
357                None => false,
358            }
359        };
360        if found {
361            self.save_state(id).await;
362        }
363        found
364    }
365
366    pub async fn active(&self) -> Vec<String> {
367        let map = self.inner.map.lock().await;
368        map.iter()
369            .filter(|(_, j)| j.state == JobState::Scraping)
370            .map(|(id, _)| id.clone())
371            .collect()
372    }
373
374    /// 按 (类型, 状态) 统计的任务数,给 `/metrics`。
375    pub(crate) async fn counts(&self) -> BTreeMap<(&'static str, &'static str), u64> {
376        let mut out = BTreeMap::new();
377        for j in self.inner.map.lock().await.values() {
378            *out.entry((j.kind.as_str(), j.state.as_str())).or_default() += 1;
379        }
380        out
381    }
382
383    /// 登记新任务:检查调用方的并发任务数,落盘元数据,发 `started` 事件。
384    async fn register(
385        &self,
386        kind: JobKind,
387        total: usize,
388        start: Start,
389        crawl: Option<CrawlRequest>,
390        batch: Option<BatchSpec>,
391    ) -> Result<(String, Option<Hook>), ApiError> {
392        let id = uuid::Uuid::new_v4().to_string();
393        let job = Job::new(kind, total, start.owner.clone());
394        let meta = Meta {
395            kind,
396            state: JobState::Scraping,
397            created_ms: to_ms(job.created),
398            total,
399            completed: 0,
400            owner: start.owner.as_ref().map(|c| c.account.to_string()),
401            webhook: start.webhook.clone(),
402            crawl,
403            batch,
404            summary: None,
405        };
406        {
407            let mut map = self.inner.map.lock().await;
408            if let Some(c) = &start.owner {
409                if c.limits.max_concurrent_jobs.is_some() {
410                    let active = map
411                        .values()
412                        .filter(|j| {
413                            j.state == JobState::Scraping
414                                && j.owner.as_ref().is_some_and(|o| o.account == c.account)
415                        })
416                        .count();
417                    self.inner.limiter.check_jobs(c, active)?;
418                }
419                self.inner.limiter.check_quota(c)?;
420            }
421            map.insert(id.clone(), job);
422        }
423        if let Some(store) = &self.inner.store {
424            let (key, value) = (meta_key(&id), serde_json::to_vec(&meta).unwrap_or_default());
425            if let Err(e) = store.blocking(move |s| s.tasks().kv_put(key, &value)).await {
426                tracing::warn!(job = %id, error = %e, "saving job failed; it will not survive a restart");
427            }
428        }
429        self.inner.metrics.job_started(kind.as_str());
430        tracing::info!(
431            job = %id,
432            kind = kind.as_str(),
433            account = start.owner.as_ref().map(|c| &*c.account),
434            "job started"
435        );
436        let hook = match (&self.inner.webhooks, start.webhook) {
437            (Some(w), Some(cfg)) => {
438                let hook = w.start(&id, kind.as_str(), cfg);
439                hook.send(Event::Started, Vec::new(), None);
440                Some(hook)
441            }
442            _ => None,
443        };
444        Ok((id, hook))
445    }
446
447    /// 启动批量抓取,立即返回任务 id;每个 URL 的结果和错误陆续写入任务。
448    pub async fn start_batch(
449        &self,
450        engine: Arc<dyn Engine>,
451        urls: Vec<String>,
452        opts: ScrapeOptions,
453        concurrency: usize,
454    ) -> String {
455        self.start_batch_with(engine, urls, opts, concurrency, Start::default())
456            .await
457            .expect("no limits without a caller")
458    }
459
460    pub(crate) async fn start_batch_with(
461        &self,
462        engine: Arc<dyn Engine>,
463        urls: Vec<String>,
464        opts: ScrapeOptions,
465        concurrency: usize,
466        start: Start,
467    ) -> Result<String, ApiError> {
468        let spec = BatchSpec {
469            urls,
470            options: opts,
471            concurrency,
472        };
473        let total = spec.urls.len();
474        let (id, hook) = self
475            .register(
476                JobKind::BatchScrape,
477                total,
478                start,
479                None,
480                self.inner.store.is_some().then(|| spec.clone()),
481            )
482            .await?;
483        let pending = spec.urls.into_iter().enumerate().collect();
484        self.spawn_batch(id.clone(), engine, pending, spec.options, concurrency, hook);
485        Ok(id)
486    }
487
488    fn sink(
489        &self,
490        id: &str,
491        kind: JobKind,
492        hook: Option<Hook>,
493        resume: Option<CrawlResume>,
494    ) -> Arc<JobSink> {
495        Arc::new(JobSink {
496            jobs: self.clone(),
497            id: id.to_string(),
498            kind,
499            hook,
500            resume: StdMutex::new(resume),
501            quota_hit: AtomicBool::new(false),
502        })
503    }
504
505    fn spawn_batch(
506        &self,
507        id: String,
508        engine: Arc<dyn Engine>,
509        pending: Vec<(usize, String)>,
510        opts: ScrapeOptions,
511        concurrency: usize,
512        hook: Option<Hook>,
513    ) {
514        let sink = self.sink(&id, JobKind::BatchScrape, hook, None);
515        let opts = Arc::new(opts);
516        tokio::spawn(async move {
517            let sem = Arc::new(Semaphore::new(concurrency.max(1)));
518            let mut tasks = tokio::task::JoinSet::new();
519            for (i, url) in pending {
520                let (sink, engine, opts, sem) =
521                    (sink.clone(), engine.clone(), opts.clone(), sem.clone());
522                tasks.spawn(async move {
523                    let _permit = sem.acquire_owned().await.expect("semaphore open");
524                    if sink.stopped().await {
525                        return;
526                    }
527                    let done = i.to_string();
528                    match engine.scrape(&url, &opts).await {
529                        Ok(doc) => sink.record_page(done, doc).await,
530                        Err(e) => sink.record_error(done, url, e).await,
531                    }
532                });
533            }
534            while tasks.join_next().await.is_some() {}
535            sink.finish(Ok(())).await;
536        });
537    }
538
539    /// 启动整站爬取,立即返回任务 id。
540    pub async fn start_crawl(&self, engine: Arc<dyn Engine>, req: CrawlRequest) -> String {
541        self.start_crawl_with(engine, req, Start::default())
542            .await
543            .expect("no limits without a caller")
544    }
545
546    pub(crate) async fn start_crawl_with(
547        &self,
548        engine: Arc<dyn Engine>,
549        req: CrawlRequest,
550        start: Start,
551    ) -> Result<String, ApiError> {
552        let (id, hook) = self
553            .register(
554                JobKind::Crawl,
555                0,
556                start,
557                self.inner.store.is_some().then(|| req.clone()),
558                None,
559            )
560            .await?;
561        self.spawn_crawl(id.clone(), engine, req, hook, None);
562        Ok(id)
563    }
564
565    fn spawn_crawl(
566        &self,
567        id: String,
568        engine: Arc<dyn Engine>,
569        req: CrawlRequest,
570        hook: Option<Hook>,
571        resume: Option<CrawlResume>,
572    ) {
573        let sink = self.sink(&id, JobKind::Crawl, hook, resume);
574        tokio::spawn(async move {
575            let result = engine.crawl(&req, sink.clone()).await;
576            sink.finish(result.map_err(|e| (req.url.clone(), e))).await;
577        });
578    }
579
580    /// 把任务的当前状态和计数写回存储。
581    async fn save_state(&self, id: &str) {
582        let Some(store) = &self.inner.store else {
583            return;
584        };
585        let snapshot = {
586            let map = self.inner.map.lock().await;
587            map.get(id)
588                .map(|j| (j.state, j.total, j.completed, j.summary.clone()))
589        };
590        let Some((state, total, completed, summary)) = snapshot else {
591            return;
592        };
593        let key = meta_key(id);
594        let r = store
595            .blocking(move |s| -> pagesift_store::Result<()> {
596                let t = s.tasks();
597                let Some(raw) = t.kv_get(&key)? else {
598                    return Ok(());
599                };
600                let Ok(mut meta) = serde_json::from_slice::<Meta>(&raw) else {
601                    return Ok(());
602                };
603                meta.state = state;
604                meta.total = total;
605                meta.completed = completed;
606                meta.summary = summary;
607                t.kv_put(&key, &serde_json::to_vec(&meta).unwrap_or_default())
608            })
609            .await;
610        if let Err(e) = r {
611            tracing::warn!(job = %id, error = %e, "saving job state failed");
612        }
613    }
614
615    /// 服务启动时从存储恢复任务:结束的任务照样能查,进行中的从断点继续,过期的删掉。
616    /// `limits` 按调用方名字找回当前配置的限额。需要在 tokio 运行时里调用。
617    pub(crate) fn restore(
618        &self,
619        engine: &Arc<dyn Engine>,
620        limits: &dyn Fn(&str) -> Arc<KeyLimits>,
621    ) {
622        let Some(store) = &self.inner.store else {
623            return;
624        };
625        let t = store.tasks();
626        let keys = match t.kv_keys("job/") {
627            Ok(k) => k,
628            Err(e) => {
629                tracing::error!(error = %e, "listing saved jobs failed");
630                return;
631            }
632        };
633        let now = SystemTime::now();
634        let (mut finished, mut resumed, mut expired) = (0, 0, 0);
635        for key in keys {
636            let Some(id) = std::str::from_utf8(&key)
637                .ok()
638                .and_then(|k| k.strip_prefix("job/"))
639                .filter(|id| !id.contains('/'))
640                .map(String::from)
641            else {
642                continue;
643            };
644            let Some(meta) = t
645                .kv_get(&key)
646                .ok()
647                .flatten()
648                .and_then(|v| serde_json::from_slice::<Meta>(&v).ok())
649            else {
650                tracing::warn!(job = %id, "dropping an unreadable saved job");
651                delete_job(store, &id);
652                continue;
653            };
654            let created = UNIX_EPOCH + Duration::from_millis(meta.created_ms);
655            if created + JOB_TTL <= now {
656                delete_job(store, &id);
657                expired += 1;
658                continue;
659            }
660            let pages = t.list_len(list_key(&id, "pages")) as usize;
661            let errors = t.list_len(list_key(&id, "errors")) as usize;
662            let owner = meta.owner.as_deref().map(|a| Caller::named(a, limits(a)));
663            let mut job = Job::new(meta.kind, meta.total, owner);
664            job.created = created;
665            job.state = meta.state;
666            job.summary = meta.summary;
667            job.stored_docs = pages;
668            job.completed = pages + errors;
669            job.total = job.total.max(job.completed);
670            if job.state != JobState::Scraping {
671                finished += 1;
672                self.inner
673                    .map
674                    .try_lock()
675                    .expect("not shared yet")
676                    .insert(id, job);
677                continue;
678            }
679            // Repair the append/done crash window before any worker resumes.
680            if let Err(error) = repair_done(store, &id) {
681                tracing::error!(job = %id, %error, "could not rebuild completion index; not resuming job");
682                continue;
683            }
684            // 进行中的任务:算出还没做完的部分,接着跑
685            let done = list_key(&id, "done");
686            let hook = match (&self.inner.webhooks, meta.webhook) {
687                (Some(w), Some(cfg)) => Some(w.start(&id, meta.kind.as_str(), cfg)),
688                _ => None,
689            };
690            match (meta.kind, meta.crawl, meta.batch) {
691                (JobKind::BatchScrape, _, Some(spec)) => {
692                    let pending: Vec<(usize, String)> = spec
693                        .urls
694                        .into_iter()
695                        .enumerate()
696                        .filter(|(i, _)| !t.set_contains(&done, i.to_string()).unwrap_or(false))
697                        .collect();
698                    job.total = job.completed + pending.len();
699                    self.inner
700                        .map
701                        .try_lock()
702                        .expect("not shared yet")
703                        .insert(id.clone(), job);
704                    tracing::info!(job = %id, kind = "batch_scrape", pending = pending.len(), "resuming job");
705                    self.spawn_batch(
706                        id,
707                        engine.clone(),
708                        pending,
709                        spec.options,
710                        spec.concurrency,
711                        hook,
712                    );
713                }
714                (JobKind::Crawl, Some(req), _) => {
715                    let frontier: Vec<(String, usize)> = t
716                        .list_items(list_key(&id, "frontier"), usize::MAX)
717                        .unwrap_or_default()
718                        .iter()
719                        .filter_map(|v| serde_json::from_slice(v).ok())
720                        .collect();
721                    let resume = (!frontier.is_empty()).then(|| {
722                        let pending: Vec<(String, usize)> = frontier
723                            .iter()
724                            .filter(|(u, _)| !t.set_contains(&done, u).unwrap_or(false))
725                            .cloned()
726                            .collect();
727                        CrawlResume {
728                            enqueued: frontier.into_iter().map(|(u, _)| u).collect(),
729                            pending,
730                            done: job.completed,
731                        }
732                    });
733                    if let Some(r) = &resume {
734                        job.total =
735                            (job.completed + r.pending.len()).min(req.limit.max(job.completed));
736                    }
737                    self.inner
738                        .map
739                        .try_lock()
740                        .expect("not shared yet")
741                        .insert(id.clone(), job);
742                    tracing::info!(
743                        job = %id,
744                        kind = "crawl",
745                        pending = resume.as_ref().map_or(0, |r| r.pending.len()),
746                        "resuming job"
747                    );
748                    self.spawn_crawl(id, engine.clone(), req, hook, resume);
749                }
750                _ => {
751                    tracing::warn!(job = %id, "saved job has no request to resume from");
752                    job.state = JobState::Failed;
753                    self.inner
754                        .map
755                        .try_lock()
756                        .expect("not shared yet")
757                        .insert(id, job);
758                }
759            }
760            resumed += 1;
761        }
762        if finished + resumed + expired > 0 {
763            tracing::info!(finished, resumed, expired, "restored saved jobs");
764        }
765    }
766}
767
768/// 删除一个任务在存储里的全部数据。
769fn delete_job(store: &Store, id: &str) {
770    let t = store.tasks();
771    let r = (|| -> pagesift_store::Result<()> {
772        for what in ["pages", "errors", "frontier"] {
773            t.list_clear(list_key(id, what))?;
774        }
775        t.set_clear(list_key(id, "done"))?;
776        t.kv_delete(meta_key(id))?;
777        Ok(())
778    })();
779    if let Err(e) = r {
780        tracing::warn!(job = %id, error = %e, "deleting an expired job failed");
781    }
782}
783
784fn job_error(url: String, e: &ApiError) -> JobError {
785    JobError {
786        id: uuid::Uuid::new_v4().to_string(),
787        timestamp: rfc3339(SystemTime::now()),
788        url,
789        code: e.code.as_str().to_string(),
790        error: e.message.clone(),
791    }
792}
793
794/// 一个任务的进度回报:批量抓取和整站爬取共用。
795struct JobSink {
796    jobs: Jobs,
797    id: String,
798    kind: JobKind,
799    hook: Option<Hook>,
800    resume: StdMutex<Option<CrawlResume>>,
801    /// 调用方当天的页面配额用完了,任务提前结束。
802    quota_hit: AtomicBool,
803}
804
805impl JobSink {
806    fn store(&self) -> Option<&Store> {
807        self.jobs.inner.store.as_ref()
808    }
809
810    async fn owner(&self) -> Option<Caller> {
811        let map = self.jobs.inner.map.lock().await;
812        map.get(&self.id).and_then(|j| j.owner.clone())
813    }
814
815    async fn running(&self) -> bool {
816        let map = self.jobs.inner.map.lock().await;
817        map.get(&self.id)
818            .is_some_and(|j| j.state == JobState::Scraping)
819    }
820
821    /// 任务被取消,或者调用方的配额用完了。
822    async fn stopped(&self) -> bool {
823        let owner = {
824            let map = self.jobs.inner.map.lock().await;
825            match map.get(&self.id) {
826                Some(j) if j.state == JobState::Scraping => j.owner.clone(),
827                _ => return true,
828            }
829        };
830        if let Some(c) = owner
831            && self.jobs.inner.limiter.remaining_pages(&c) == Some(0)
832        {
833            self.quota_hit.store(true, Ordering::Relaxed);
834            return true;
835        }
836        false
837    }
838
839    /// 记一个完成的页面;`done` 是恢复时用来认出它的键(URL 或批量里的下标)。
840    async fn record_page(&self, done: String, doc: Document) {
841        let inner = &self.jobs.inner;
842        if !self.running().await {
843            return;
844        }
845        let page_doc = self.hook.as_ref().map(|_| doc.clone());
846        let mut keep = Some(doc);
847        if let Some(store) = self.store() {
848            let doc = keep.take().expect("set above");
849            let (pages, done_set) = (list_key(&self.id, "pages"), list_key(&self.id, "done"));
850            let r = store
851                .blocking(move |s| -> pagesift_store::Result<()> {
852                    let t = s.tasks();
853                    let value = serde_json::to_vec(&Completion {
854                        value: &doc,
855                        done: &done,
856                    })
857                    .map_err(std::io::Error::other)?;
858                    t.list_push(pages, &value)?;
859                    t.set_insert(done_set, done)?;
860                    Ok(())
861                })
862                .await;
863            if let Err(e) = r {
864                tracing::warn!(job = %self.id, error = %e, "saving a job page failed");
865            }
866        }
867        let owner = {
868            let mut map = inner.map.lock().await;
869            let Some(job) = map.get_mut(&self.id) else {
870                return;
871            };
872            if job.state == JobState::Cancelled {
873                return;
874            }
875            job.completed += 1;
876            job.total = job.total.max(job.completed);
877            match keep {
878                Some(doc) => job.data.push(doc),
879                None => job.stored_docs += 1,
880            }
881            job.owner.clone()
882        };
883        inner.metrics.page(self.kind.pages());
884        if let Some(c) = owner {
885            inner.limiter.consume_pages(&c, 1).await;
886        }
887        if let (Some(hook), Some(doc)) = (&self.hook, page_doc) {
888            hook.send(Event::Page, vec![doc], None);
889        }
890    }
891
892    async fn record_error(&self, done: String, url: String, err: ApiError) {
893        let inner = &self.jobs.inner;
894        if !self.running().await {
895            return;
896        }
897        inner.metrics.error(err.code.as_str(), "job");
898        let e = job_error(url, &err);
899        let mut keep = Some(e);
900        if let Some(store) = self.store() {
901            let e = keep.take().expect("set above");
902            let (errors, done_set) = (list_key(&self.id, "errors"), list_key(&self.id, "done"));
903            let r = store
904                .blocking(move |s| -> pagesift_store::Result<()> {
905                    let t = s.tasks();
906                    let value = serde_json::to_vec(&Completion {
907                        value: &e,
908                        done: &done,
909                    })
910                    .map_err(std::io::Error::other)?;
911                    t.list_push(errors, &value)?;
912                    t.set_insert(done_set, done)?;
913                    Ok(())
914                })
915                .await;
916            if let Err(e) = r {
917                tracing::warn!(job = %self.id, error = %e, "saving a job error failed");
918            }
919        }
920        let mut map = inner.map.lock().await;
921        let Some(job) = map.get_mut(&self.id) else {
922            return;
923        };
924        if job.state == JobState::Cancelled {
925            return;
926        }
927        job.completed += 1;
928        job.total = job.total.max(job.completed);
929        if let Some(e) = keep {
930            if err.code == ErrorCode::RobotsDisallowed {
931                job.robots_blocked.push(e.url.clone());
932            }
933            job.errors.push(e);
934        }
935    }
936
937    /// 任务跑完:定下最终状态、落盘、发结束事件。
938    async fn finish(&self, result: Result<(), (String, ApiError)>) {
939        let inner = &self.jobs.inner;
940        let quota = self.quota_hit.load(Ordering::Relaxed);
941        let mut extra = Vec::new();
942        if quota && let Some(c) = self.owner().await {
943            extra.push(job_error(String::new(), &quota_error(&c)));
944        }
945        if let Err((url, e)) = &result {
946            extra.push(job_error(url.clone(), e));
947        }
948        if let (Some(store), false) = (self.store(), extra.is_empty()) {
949            let (key, items) = (list_key(&self.id, "errors"), extra.clone());
950            store
951                .blocking(move |s| {
952                    for e in items {
953                        let _ = s
954                            .tasks()
955                            .list_push(&key, &serde_json::to_vec(&e).unwrap_or_default());
956                    }
957                })
958                .await;
959        }
960        let final_state = {
961            let mut map = inner.map.lock().await;
962            let Some(job) = map.get_mut(&self.id) else {
963                return;
964            };
965            if job.state == JobState::Scraping {
966                job.state = if result.is_ok() {
967                    JobState::Completed
968                } else {
969                    JobState::Failed
970                };
971                if self.store().is_none() {
972                    job.errors.extend(extra);
973                }
974            }
975            (job.state, job.completed)
976        };
977        self.jobs.save_state(&self.id).await;
978        let (state, completed) = final_state;
979        tracing::info!(
980            job = %self.id,
981            kind = self.kind.as_str(),
982            status = state.as_str(),
983            pages = completed,
984            quota_exceeded = quota,
985            "job finished"
986        );
987        if let Some(hook) = &self.hook {
988            match (state, &result) {
989                (JobState::Failed, Err((_, e))) => {
990                    hook.send(Event::Failed, Vec::new(), Some(e.to_string()))
991                }
992                (JobState::Completed, _) => hook.send(Event::Completed, Vec::new(), None),
993                _ => {}
994            }
995        }
996    }
997}
998
999impl CrawlSink for JobSink {
1000    fn page(&self, doc: Document) -> BoxFuture<'_, ()> {
1001        Box::pin(async move {
1002            let done = doc.metadata.source_url.clone().unwrap_or_default();
1003            self.record_page(done, doc).await
1004        })
1005    }
1006
1007    fn error<'a>(&'a self, url: &'a str, err: ApiError) -> BoxFuture<'a, ()> {
1008        Box::pin(self.record_error(url.to_string(), url.to_string(), err))
1009    }
1010
1011    fn total(&self, total: usize) -> BoxFuture<'_, ()> {
1012        Box::pin(async move {
1013            if let Some(job) = self.jobs.inner.map.lock().await.get_mut(&self.id) {
1014                job.total = total.max(job.completed);
1015            }
1016        })
1017    }
1018
1019    fn cancelled(&self) -> BoxFuture<'_, bool> {
1020        Box::pin(self.stopped())
1021    }
1022
1023    fn resume(&self) -> Option<CrawlResume> {
1024        self.resume.lock().unwrap_or_else(|e| e.into_inner()).take()
1025    }
1026
1027    fn tracks_frontier(&self) -> bool {
1028        self.store().is_some()
1029    }
1030
1031    fn summary(&self, summary: CrawlSummary) -> BoxFuture<'_, ()> {
1032        Box::pin(async move {
1033            if let Some(job) = self.jobs.inner.map.lock().await.get_mut(&self.id) {
1034                job.summary = Some(summary);
1035            }
1036            // 结束时 `finish` 还会再存一次;这里先存,任务结束前进程退出也不丢
1037            self.jobs.save_state(&self.id).await;
1038        })
1039    }
1040
1041    fn enqueued(&self, urls: Vec<(String, usize)>) -> BoxFuture<'_, ()> {
1042        Box::pin(async move {
1043            let (Some(store), false) = (self.store(), urls.is_empty()) else {
1044                return;
1045            };
1046            let key = list_key(&self.id, "frontier");
1047            let r = store
1048                .blocking(move |s| -> pagesift_store::Result<()> {
1049                    let t = s.tasks();
1050                    for u in urls {
1051                        t.list_push(&key, &serde_json::to_vec(&u).unwrap_or_default())?;
1052                    }
1053                    Ok(())
1054                })
1055                .await;
1056            if let Err(e) = r {
1057                tracing::warn!(job = %self.id, error = %e, "saving the crawl queue failed");
1058            }
1059        })
1060    }
1061}
1062
1063/// UTC 时间格式化成 RFC 3339(秒级精度后带毫秒),不引入日期库。
1064pub fn rfc3339(t: SystemTime) -> String {
1065    let d = t.duration_since(UNIX_EPOCH).unwrap_or_default();
1066    let secs = d.as_secs() as i64;
1067    let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
1068    // Howard Hinnant 的 civil_from_days
1069    let z = days + 719_468;
1070    let era = z.div_euclid(146_097);
1071    let doe = z - era * 146_097;
1072    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1073    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1074    let mp = (5 * doy + 2) / 153;
1075    let day = doy - (153 * mp + 2) / 5 + 1;
1076    let month = if mp < 10 { mp + 3 } else { mp - 9 };
1077    let year = yoe + era * 400 + i64::from(month <= 2);
1078    format!(
1079        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z",
1080        rem / 3600,
1081        rem % 3600 / 60,
1082        rem % 60,
1083        d.subsec_millis()
1084    )
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089    use super::*;
1090
1091    #[test]
1092    fn formats_utc_dates() {
1093        assert_eq!(rfc3339(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
1094        let t = UNIX_EPOCH + Duration::from_millis(1_792_540_800_123);
1095        assert_eq!(rfc3339(t), "2026-10-21T00:00:00.123Z");
1096        let leap = UNIX_EPOCH + Duration::from_secs(951_782_400); // 2000-02-29
1097        assert_eq!(rfc3339(leap), "2000-02-29T00:00:00.000Z");
1098    }
1099}