1use std::collections::{BTreeMap, HashMap};
16use std::sync::atomic::AtomicBool;
17use std::sync::{Arc, Mutex as StdMutex};
18use std::time::SystemTime;
19
20use pagesift_store::Store;
21use tokio::sync::{Mutex, Semaphore};
22
23use crate::engine::{CrawlResume, Engine};
24use crate::error::{ApiError, ErrorCode};
25use crate::limits::{Caller, Limiter};
26use crate::metrics::Metrics;
27use crate::types::{CrawlRequest, JobError, ScrapeOptions};
28use crate::webhook::{Event, Hook, Webhooks};
29
30pub mod mirror;
31mod sink;
32pub mod state;
33pub(crate) mod store;
34pub mod time;
35
36pub use state::{JOB_TTL, Job, JobKind, JobState, Start, StatusPage};
37pub use time::rfc3339;
38
39use sink::JobSink;
40use store::{BatchSpec, Meta, delete_job, list_key, meta_key, to_ms};
41
42struct Inner {
43 map: Mutex<HashMap<String, Job>>,
44 store: Option<Store>,
45 limiter: Arc<Limiter>,
46 webhooks: Option<Webhooks>,
47 metrics: Arc<Metrics>,
48 mirror: Option<Arc<mirror::JobMirror>>,
50}
51
52#[derive(Clone)]
53pub struct Jobs {
54 inner: Arc<Inner>,
55}
56
57impl Default for Jobs {
58 fn default() -> Self {
60 Self::new(None, Arc::default(), None, Arc::default(), None)
61 }
62}
63
64impl Jobs {
65 pub(crate) fn new(
66 store: Option<Store>,
67 limiter: Arc<Limiter>,
68 webhooks: Option<Webhooks>,
69 metrics: Arc<Metrics>,
70 mirror: Option<Arc<mirror::JobMirror>>,
71 ) -> Self {
72 Self {
73 inner: Arc::new(Inner {
74 map: Mutex::new(HashMap::new()),
75 store,
76 limiter,
77 webhooks,
78 metrics,
79 mirror,
80 }),
81 }
82 }
83
84 async fn sweep(&self) {
86 let now = SystemTime::now();
87 let expired: Vec<String> = {
88 let mut map = self.inner.map.lock().await;
89 let ids: Vec<String> = map
90 .iter()
91 .filter(|(_, j)| j.expires_at() <= now)
92 .map(|(id, _)| id.clone())
93 .collect();
94 for id in &ids {
95 map.remove(id);
96 }
97 ids
98 };
99 if let (Some(store), false) = (&self.inner.store, expired.is_empty()) {
100 let m = self.inner.mirror.clone();
101 store
102 .blocking(move |s| {
103 for id in expired {
104 delete_job(s, m.as_deref(), &id);
105 }
106 })
107 .await;
108 }
109 }
110
111 pub(crate) fn store(&self) -> Option<&Store> {
113 self.inner.store.as_ref()
114 }
115
116 pub(crate) fn disk(&self) -> Option<crate::attachments::Disk> {
118 self.inner
119 .store
120 .clone()
121 .map(|store| crate::attachments::Disk {
122 store,
123 mirror: self.inner.mirror.clone(),
124 })
125 }
126
127 pub async fn get<R>(&self, id: &str, f: impl FnOnce(&Job) -> R) -> Option<R> {
128 self.sweep().await;
129 self.inner.map.lock().await.get(id).map(f)
130 }
131
132 pub(crate) async fn status(&self, id: &str, skip: usize, limit: usize) -> Option<StatusPage> {
134 self.sweep().await;
135 let mut page = {
136 let map = self.inner.map.lock().await;
137 let job = map.get(id)?;
138 let end = skip.saturating_add(limit).min(job.data.len());
139 StatusPage {
140 state: job.state,
141 total: job.total,
142 completed: job.completed,
143 expires_at: job.expires_at(),
144 docs: job.docs(),
145 data: job
146 .data
147 .get(skip.min(end)..end)
148 .unwrap_or_default()
149 .to_vec(),
150 summary: job.summary.clone(),
151 }
152 };
153 if let Some(store) = &self.inner.store
154 && page.data.is_empty()
155 && skip < page.docs
156 {
157 let key = list_key(id, "pages");
158 page.data = store
159 .blocking(move |s| s.tasks().list_range(key, skip as u64, limit))
160 .await
161 .unwrap_or_else(|e| {
162 tracing::warn!(error = %e, "reading job pages failed");
163 Vec::new()
164 })
165 .iter()
166 .filter_map(|v| serde_json::from_slice(v).ok())
167 .collect();
168 for doc in &mut page.data {
169 for a in &mut doc.attachments {
170 if a.inline_data && a.data.is_none() {
171 a.data = crate::attachments::load(self.disk(), a.id.clone())
172 .await
173 .map(|(data, _)| data);
174 }
175 }
176 }
177 }
178 Some(page)
179 }
180
181 pub(crate) async fn subscribe(&self, id: &str) -> Option<tokio::sync::watch::Receiver<()>> {
183 self.sweep().await;
184 self.inner
185 .map
186 .lock()
187 .await
188 .get(id)
189 .map(|j| j.changed.subscribe())
190 }
191
192 pub(crate) async fn subscribe_previews(
193 &self,
194 id: &str,
195 ) -> Option<tokio::sync::broadcast::Receiver<crate::types::MarkdownPreview>> {
196 self.inner
197 .map
198 .lock()
199 .await
200 .get(id)
201 .map(|job| job.previews.subscribe())
202 }
203
204 pub(crate) async fn errors(&self, id: &str) -> Option<(Vec<JobError>, Vec<String>)> {
206 self.sweep().await;
207 let (mut errors, mut robots) = {
208 let map = self.inner.map.lock().await;
209 let job = map.get(id)?;
210 (job.errors.clone(), job.robots_blocked.clone())
211 };
212 if let Some(store) = &self.inner.store {
213 let (key, notes) = (list_key(id, "errors"), list_key(id, "notes"));
215 let stored = store
216 .blocking(move |s| {
217 let t = s.tasks();
218 let mut v = t.list_items(key, usize::MAX).unwrap_or_default();
219 v.extend(t.list_items(notes, usize::MAX).unwrap_or_default());
220 v
221 })
222 .await;
223 for v in stored {
224 if let Ok(e) = serde_json::from_slice::<JobError>(&v) {
225 if e.code == ErrorCode::RobotsDisallowed.as_str() {
226 robots.push(e.url.clone());
227 }
228 errors.push(e);
229 }
230 }
231 }
232 Some((errors, robots))
233 }
234
235 pub async fn cancel(&self, id: &str) -> bool {
236 let found = {
237 let mut map = self.inner.map.lock().await;
238 match map.get_mut(id) {
239 Some(j) => {
240 if j.state == JobState::Scraping {
241 j.state = JobState::Cancelled;
242 j.touch();
243 }
244 true
245 }
246 None => false,
247 }
248 };
249 if found {
250 self.save_state(id).await;
251 }
252 found
253 }
254
255 pub async fn active(&self) -> Vec<String> {
256 let map = self.inner.map.lock().await;
257 map.iter()
258 .filter(|(_, j)| j.state == JobState::Scraping)
259 .map(|(id, _)| id.clone())
260 .collect()
261 }
262
263 pub(crate) async fn owned_by(&self, id: &str, caller: Option<&Caller>) -> bool {
264 self.sweep().await;
265 let map = self.inner.map.lock().await;
266 map.get(id).is_some_and(|job| match (&job.owner, caller) {
267 (Some(owner), Some(caller)) => owner.account == caller.account,
268 (None, Some(caller)) => caller.account.as_ref() == "anonymous",
269 (None, None) => true,
270 _ => false,
271 })
272 }
273
274 pub(crate) async fn active_for(&self, caller: Option<&Caller>) -> Vec<String> {
275 let map = self.inner.map.lock().await;
276 map.iter()
277 .filter(|(_, job)| {
278 job.state == JobState::Scraping
279 && match (&job.owner, caller) {
280 (Some(owner), Some(caller)) => owner.account == caller.account,
281 (None, Some(caller)) => caller.account.as_ref() == "anonymous",
282 (None, None) => true,
283 _ => false,
284 }
285 })
286 .map(|(id, _)| id.clone())
287 .collect()
288 }
289
290 pub(crate) async fn counts(&self) -> BTreeMap<(&'static str, &'static str), u64> {
292 let mut out = BTreeMap::new();
293 for j in self.inner.map.lock().await.values() {
294 *out.entry((j.kind.as_str(), j.state.as_str())).or_default() += 1;
295 }
296 out
297 }
298
299 async fn register(
301 &self,
302 kind: JobKind,
303 total: usize,
304 start: Start,
305 crawl: Option<CrawlRequest>,
306 batch: Option<BatchSpec>,
307 ) -> Result<(String, Option<Hook>), ApiError> {
308 let id = uuid::Uuid::new_v4().to_string();
309 let job = Job::new(kind, total, start.owner.clone());
310 let meta = Meta {
311 kind,
312 state: JobState::Scraping,
313 created_ms: to_ms(job.created),
314 total,
315 completed: 0,
316 owner: start.owner.as_ref().map(|c| c.account.to_string()),
317 webhook: start.webhook.clone(),
318 crawl,
319 batch,
320 summary: None,
321 };
322 {
323 let mut map = self.inner.map.lock().await;
324 if let Some(c) = &start.owner {
325 if c.limits.max_concurrent_jobs.is_some() {
326 let active = map
327 .values()
328 .filter(|j| {
329 j.state == JobState::Scraping
330 && j.owner.as_ref().is_some_and(|o| o.account == c.account)
331 })
332 .count();
333 self.inner.limiter.check_jobs(c, active)?;
334 }
335 self.inner.limiter.check_quota(c)?;
336 }
337 map.insert(id.clone(), job);
338 }
339 if let Some(store) = &self.inner.store {
340 let (key, value) = (meta_key(&id), serde_json::to_vec(&meta).unwrap_or_default());
341 let m = self.inner.mirror.clone();
342 let put = move |s: &Store| mirror::write(s, m.as_deref(), |w| w.kv_put(&key, &value));
343 if let Err(e) = store.blocking(put).await {
344 tracing::warn!(job = %id, error = %e, "saving job failed; it will not survive a restart");
345 }
346 }
347 self.inner.metrics.job_started(kind.as_str());
348 tracing::info!(
349 job = %id,
350 kind = kind.as_str(),
351 account = start.owner.as_ref().map(|c| &*c.account),
352 "job started"
353 );
354 let hook = match (&self.inner.webhooks, start.webhook) {
355 (Some(w), Some(cfg)) => {
356 let hook = w.start(&id, kind.as_str(), cfg, self.inner.store.clone());
357 hook.send(Event::Started, Vec::new(), None).await;
358 Some(hook)
359 }
360 _ => None,
361 };
362 Ok((id, hook))
363 }
364
365 pub async fn start_batch(
367 &self,
368 engine: Arc<dyn Engine>,
369 urls: Vec<String>,
370 opts: ScrapeOptions,
371 concurrency: usize,
372 ) -> String {
373 self.start_batch_with(engine, urls, opts, concurrency, Start::default())
374 .await
375 .expect("no limits without a caller")
376 }
377
378 pub(crate) async fn start_batch_with(
379 &self,
380 engine: Arc<dyn Engine>,
381 urls: Vec<String>,
382 opts: ScrapeOptions,
383 concurrency: usize,
384 start: Start,
385 ) -> Result<String, ApiError> {
386 let spec = BatchSpec {
387 urls,
388 options: opts,
389 concurrency,
390 };
391 let total = spec.urls.len();
392 let (id, hook) = self
393 .register(
394 JobKind::BatchScrape,
395 total,
396 start,
397 None,
398 self.inner.store.is_some().then(|| spec.clone()),
399 )
400 .await?;
401 let pending = spec.urls.into_iter().enumerate().collect();
402 self.spawn_batch(id.clone(), engine, pending, spec.options, concurrency, hook);
403 Ok(id)
404 }
405
406 fn sink(
407 &self,
408 id: &str,
409 kind: JobKind,
410 hook: Option<Hook>,
411 resume: Option<CrawlResume>,
412 ) -> Arc<JobSink> {
413 Arc::new(JobSink {
414 jobs: self.clone(),
415 id: id.to_string(),
416 kind,
417 hook,
418 resume: StdMutex::new(resume),
419 quota_hit: AtomicBool::new(false),
420 keep_summary: true,
421 store_failed: StdMutex::new(None),
422 asset_hashes: tokio::sync::Mutex::new(None),
423 })
424 }
425
426 pub(super) fn spawn_batch(
427 &self,
428 id: String,
429 engine: Arc<dyn Engine>,
430 pending: Vec<(usize, String)>,
431 mut opts: ScrapeOptions,
432 concurrency: usize,
433 hook: Option<Hook>,
434 ) {
435 let sink = self.sink(&id, JobKind::BatchScrape, hook, None);
436 if opts.image_assets.is_some() {
437 opts.asset_fetch_cache = Some(Default::default());
438 }
439 tokio::spawn(async move {
440 if opts.image_assets.is_some() {
441 opts.markdown_progress = sink
442 .jobs
443 .inner
444 .map
445 .lock()
446 .await
447 .get(&id)
448 .map(|job| crate::types::MarkdownProgress(job.previews.clone()));
449 }
450 if let Some(cache) = &opts.asset_fetch_cache {
451 sink.jobs.seed_asset_cache(&id, cache).await;
452 }
453 let opts = Arc::new(opts);
454 let sem = Arc::new(Semaphore::new(concurrency.max(1)));
455 let mut tasks = tokio::task::JoinSet::new();
456 for (i, url) in pending {
457 let (sink, engine, opts, sem) =
458 (sink.clone(), engine.clone(), opts.clone(), sem.clone());
459 tasks.spawn(async move {
460 let _permit = sem.acquire_owned().await.expect("semaphore open");
461 if sink.stopped().await {
462 return;
463 }
464 let done = i.to_string();
465 match engine.scrape(&url, &opts).await {
466 Ok(doc) => sink.record_page(done, doc).await,
467 Err(e) => sink.record_error(done, url, e).await,
468 }
469 });
470 }
471 while tasks.join_next().await.is_some() {}
472 sink.finish(Ok(())).await;
473 });
474 }
475
476 pub async fn start_crawl(&self, engine: Arc<dyn Engine>, req: CrawlRequest) -> String {
478 self.start_crawl_with(engine, req, Start::default())
479 .await
480 .expect("no limits without a caller")
481 }
482
483 pub(crate) async fn start_crawl_with(
484 &self,
485 engine: Arc<dyn Engine>,
486 req: CrawlRequest,
487 start: Start,
488 ) -> Result<String, ApiError> {
489 let (id, hook) = self
490 .register(
491 JobKind::Crawl,
492 0,
493 start,
494 self.inner.store.is_some().then(|| req.clone()),
495 None,
496 )
497 .await?;
498 self.spawn_crawl(id.clone(), engine, req, hook, None);
499 Ok(id)
500 }
501
502 pub(super) fn spawn_crawl(
503 &self,
504 id: String,
505 engine: Arc<dyn Engine>,
506 mut req: CrawlRequest,
507 hook: Option<Hook>,
508 resume: Option<CrawlResume>,
509 ) {
510 if req.scrape_options.image_assets.is_some() {
511 req.scrape_options.asset_fetch_cache = Some(Default::default());
512 }
513 let mut sink = self.sink(&id, JobKind::Crawl, hook, resume);
514 if let Some(s) = Arc::get_mut(&mut sink) {
515 s.keep_summary = req.query.is_some();
516 }
517 tokio::spawn(async move {
518 if req.scrape_options.image_assets.is_some() {
519 req.scrape_options.markdown_progress = sink
520 .jobs
521 .inner
522 .map
523 .lock()
524 .await
525 .get(&id)
526 .map(|job| crate::types::MarkdownProgress(job.previews.clone()));
527 }
528 if let Some(cache) = &req.scrape_options.asset_fetch_cache {
529 sink.jobs.seed_asset_cache(&id, cache).await;
530 }
531 let result = engine.crawl(&req, sink.clone()).await;
532 sink.finish(result.map_err(|e| (req.url.clone(), e))).await;
533 });
534 }
535
536 async fn seed_asset_cache(&self, id: &str, cache: &crate::types::AssetFetchCache) {
538 let Some(store) = &self.inner.store else {
539 return;
540 };
541 let key = list_key(id, "pages");
542 let pairs = store
543 .blocking(move |s| -> pagesift_store::Result<Vec<(String, String)>> {
544 let tasks = s.tasks();
545 let mut pairs = std::collections::BTreeMap::new();
546 for start in (0..tasks.list_len(&key)).step_by(256) {
547 for raw in tasks.list_range(&key, start, 256)? {
548 let doc: crate::types::Document =
549 serde_json::from_slice(&raw).map_err(std::io::Error::other)?;
550 for asset in doc.image_assets {
551 if asset.status == crate::types::ImageAssetStatus::Ok
552 && (asset.source.starts_with("http://")
553 || asset.source.starts_with("https://"))
554 && let Some(id) = asset.id
555 {
556 pairs.entry(asset.source).or_insert(id);
557 }
558 if pairs.len() >= 32 {
559 return Ok(pairs.into_iter().collect());
560 }
561 }
562 }
563 }
564 Ok(pairs.into_iter().collect())
565 })
566 .await;
567 let Ok(pairs) = pairs else { return };
568 for (url, id) in pairs {
569 let Some((bytes, _)) = crate::attachments::load(self.disk(), id.clone()).await else {
570 continue;
571 };
572 if bytes.len() > 512 * 1024 {
573 continue;
574 }
575 let Some(format) = crate::types::attachment::sniff_image_format(&bytes) else {
576 continue;
577 };
578 let attachment =
579 crate::types::Attachment::new(bytes.to_vec(), format, Some(url.clone()));
580 if attachment.id == id {
581 cache.seed(&url, attachment);
582 }
583 }
584 }
585}