1use 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
31pub 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 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 pub data: Vec<Document>,
86 pub errors: Vec<JobError>,
88 pub robots_blocked: Vec<String>,
89 pub created: SystemTime,
90 pub summary: Option<CrawlSummary>,
92 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
123pub(crate) struct StatusPage {
125 pub state: JobState,
126 pub total: usize,
127 pub completed: usize,
128 pub expires_at: SystemTime,
129 pub docs: usize,
131 pub data: Vec<Document>,
132 pub summary: Option<CrawlSummary>,
133}
134
135#[derive(Default)]
137pub(crate) struct Start {
138 pub owner: Option<Caller>,
139 pub webhook: Option<WebhookConfig>,
140}
141
142#[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 #[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#[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 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 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 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 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 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 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 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 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 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 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 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 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
768fn 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
794struct JobSink {
796 jobs: Jobs,
797 id: String,
798 kind: JobKind,
799 hook: Option<Hook>,
800 resume: StdMutex<Option<CrawlResume>>,
801 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 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 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 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(), "a_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 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
1063pub 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 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); assert_eq!(rfc3339(leap), "2000-02-29T00:00:00.000Z");
1098 }
1099}