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 }
169 Some(page)
170 }
171
172 pub(crate) async fn subscribe(&self, id: &str) -> Option<tokio::sync::watch::Receiver<()>> {
174 self.sweep().await;
175 self.inner
176 .map
177 .lock()
178 .await
179 .get(id)
180 .map(|j| j.changed.subscribe())
181 }
182
183 pub(crate) async fn errors(&self, id: &str) -> Option<(Vec<JobError>, Vec<String>)> {
185 self.sweep().await;
186 let (mut errors, mut robots) = {
187 let map = self.inner.map.lock().await;
188 let job = map.get(id)?;
189 (job.errors.clone(), job.robots_blocked.clone())
190 };
191 if let Some(store) = &self.inner.store {
192 let (key, notes) = (list_key(id, "errors"), list_key(id, "notes"));
194 let stored = store
195 .blocking(move |s| {
196 let t = s.tasks();
197 let mut v = t.list_items(key, usize::MAX).unwrap_or_default();
198 v.extend(t.list_items(notes, usize::MAX).unwrap_or_default());
199 v
200 })
201 .await;
202 for v in stored {
203 if let Ok(e) = serde_json::from_slice::<JobError>(&v) {
204 if e.code == ErrorCode::RobotsDisallowed.as_str() {
205 robots.push(e.url.clone());
206 }
207 errors.push(e);
208 }
209 }
210 }
211 Some((errors, robots))
212 }
213
214 pub async fn cancel(&self, id: &str) -> bool {
215 let found = {
216 let mut map = self.inner.map.lock().await;
217 match map.get_mut(id) {
218 Some(j) => {
219 if j.state == JobState::Scraping {
220 j.state = JobState::Cancelled;
221 j.touch();
222 }
223 true
224 }
225 None => false,
226 }
227 };
228 if found {
229 self.save_state(id).await;
230 }
231 found
232 }
233
234 pub async fn active(&self) -> Vec<String> {
235 let map = self.inner.map.lock().await;
236 map.iter()
237 .filter(|(_, j)| j.state == JobState::Scraping)
238 .map(|(id, _)| id.clone())
239 .collect()
240 }
241
242 pub(crate) async fn owned_by(&self, id: &str, caller: Option<&Caller>) -> bool {
243 self.sweep().await;
244 let map = self.inner.map.lock().await;
245 map.get(id).is_some_and(|job| match (&job.owner, caller) {
246 (Some(owner), Some(caller)) => owner.account == caller.account,
247 (None, Some(caller)) => caller.account.as_ref() == "anonymous",
248 (None, None) => true,
249 _ => false,
250 })
251 }
252
253 pub(crate) async fn active_for(&self, caller: Option<&Caller>) -> Vec<String> {
254 let map = self.inner.map.lock().await;
255 map.iter()
256 .filter(|(_, job)| {
257 job.state == JobState::Scraping
258 && match (&job.owner, caller) {
259 (Some(owner), Some(caller)) => owner.account == caller.account,
260 (None, Some(caller)) => caller.account.as_ref() == "anonymous",
261 (None, None) => true,
262 _ => false,
263 }
264 })
265 .map(|(id, _)| id.clone())
266 .collect()
267 }
268
269 pub(crate) async fn counts(&self) -> BTreeMap<(&'static str, &'static str), u64> {
271 let mut out = BTreeMap::new();
272 for j in self.inner.map.lock().await.values() {
273 *out.entry((j.kind.as_str(), j.state.as_str())).or_default() += 1;
274 }
275 out
276 }
277
278 async fn register(
280 &self,
281 kind: JobKind,
282 total: usize,
283 start: Start,
284 crawl: Option<CrawlRequest>,
285 batch: Option<BatchSpec>,
286 ) -> Result<(String, Option<Hook>), ApiError> {
287 let id = uuid::Uuid::new_v4().to_string();
288 let job = Job::new(kind, total, start.owner.clone());
289 let meta = Meta {
290 kind,
291 state: JobState::Scraping,
292 created_ms: to_ms(job.created),
293 total,
294 completed: 0,
295 owner: start.owner.as_ref().map(|c| c.account.to_string()),
296 webhook: start.webhook.clone(),
297 crawl,
298 batch,
299 summary: None,
300 };
301 {
302 let mut map = self.inner.map.lock().await;
303 if let Some(c) = &start.owner {
304 if c.limits.max_concurrent_jobs.is_some() {
305 let active = map
306 .values()
307 .filter(|j| {
308 j.state == JobState::Scraping
309 && j.owner.as_ref().is_some_and(|o| o.account == c.account)
310 })
311 .count();
312 self.inner.limiter.check_jobs(c, active)?;
313 }
314 self.inner.limiter.check_quota(c)?;
315 }
316 map.insert(id.clone(), job);
317 }
318 if let Some(store) = &self.inner.store {
319 let (key, value) = (meta_key(&id), serde_json::to_vec(&meta).unwrap_or_default());
320 let m = self.inner.mirror.clone();
321 let put = move |s: &Store| mirror::write(s, m.as_deref(), |w| w.kv_put(&key, &value));
322 if let Err(e) = store.blocking(put).await {
323 tracing::warn!(job = %id, error = %e, "saving job failed; it will not survive a restart");
324 }
325 }
326 self.inner.metrics.job_started(kind.as_str());
327 tracing::info!(
328 job = %id,
329 kind = kind.as_str(),
330 account = start.owner.as_ref().map(|c| &*c.account),
331 "job started"
332 );
333 let hook = match (&self.inner.webhooks, start.webhook) {
334 (Some(w), Some(cfg)) => {
335 let hook = w.start(&id, kind.as_str(), cfg, self.inner.store.clone());
336 hook.send(Event::Started, Vec::new(), None).await;
337 Some(hook)
338 }
339 _ => None,
340 };
341 Ok((id, hook))
342 }
343
344 pub async fn start_batch(
346 &self,
347 engine: Arc<dyn Engine>,
348 urls: Vec<String>,
349 opts: ScrapeOptions,
350 concurrency: usize,
351 ) -> String {
352 self.start_batch_with(engine, urls, opts, concurrency, Start::default())
353 .await
354 .expect("no limits without a caller")
355 }
356
357 pub(crate) async fn start_batch_with(
358 &self,
359 engine: Arc<dyn Engine>,
360 urls: Vec<String>,
361 opts: ScrapeOptions,
362 concurrency: usize,
363 start: Start,
364 ) -> Result<String, ApiError> {
365 let spec = BatchSpec {
366 urls,
367 options: opts,
368 concurrency,
369 };
370 let total = spec.urls.len();
371 let (id, hook) = self
372 .register(
373 JobKind::BatchScrape,
374 total,
375 start,
376 None,
377 self.inner.store.is_some().then(|| spec.clone()),
378 )
379 .await?;
380 let pending = spec.urls.into_iter().enumerate().collect();
381 self.spawn_batch(id.clone(), engine, pending, spec.options, concurrency, hook);
382 Ok(id)
383 }
384
385 fn sink(
386 &self,
387 id: &str,
388 kind: JobKind,
389 hook: Option<Hook>,
390 resume: Option<CrawlResume>,
391 ) -> Arc<JobSink> {
392 Arc::new(JobSink {
393 jobs: self.clone(),
394 id: id.to_string(),
395 kind,
396 hook,
397 resume: StdMutex::new(resume),
398 quota_hit: AtomicBool::new(false),
399 keep_summary: true,
400 store_failed: StdMutex::new(None),
401 })
402 }
403
404 pub(super) fn spawn_batch(
405 &self,
406 id: String,
407 engine: Arc<dyn Engine>,
408 pending: Vec<(usize, String)>,
409 opts: ScrapeOptions,
410 concurrency: usize,
411 hook: Option<Hook>,
412 ) {
413 let sink = self.sink(&id, JobKind::BatchScrape, hook, None);
414 let opts = Arc::new(opts);
415 tokio::spawn(async move {
416 let sem = Arc::new(Semaphore::new(concurrency.max(1)));
417 let mut tasks = tokio::task::JoinSet::new();
418 for (i, url) in pending {
419 let (sink, engine, opts, sem) =
420 (sink.clone(), engine.clone(), opts.clone(), sem.clone());
421 tasks.spawn(async move {
422 let _permit = sem.acquire_owned().await.expect("semaphore open");
423 if sink.stopped().await {
424 return;
425 }
426 let done = i.to_string();
427 match engine.scrape(&url, &opts).await {
428 Ok(doc) => sink.record_page(done, doc).await,
429 Err(e) => sink.record_error(done, url, e).await,
430 }
431 });
432 }
433 while tasks.join_next().await.is_some() {}
434 sink.finish(Ok(())).await;
435 });
436 }
437
438 pub async fn start_crawl(&self, engine: Arc<dyn Engine>, req: CrawlRequest) -> String {
440 self.start_crawl_with(engine, req, Start::default())
441 .await
442 .expect("no limits without a caller")
443 }
444
445 pub(crate) async fn start_crawl_with(
446 &self,
447 engine: Arc<dyn Engine>,
448 req: CrawlRequest,
449 start: Start,
450 ) -> Result<String, ApiError> {
451 let (id, hook) = self
452 .register(
453 JobKind::Crawl,
454 0,
455 start,
456 self.inner.store.is_some().then(|| req.clone()),
457 None,
458 )
459 .await?;
460 self.spawn_crawl(id.clone(), engine, req, hook, None);
461 Ok(id)
462 }
463
464 pub(super) fn spawn_crawl(
465 &self,
466 id: String,
467 engine: Arc<dyn Engine>,
468 req: CrawlRequest,
469 hook: Option<Hook>,
470 resume: Option<CrawlResume>,
471 ) {
472 let mut sink = self.sink(&id, JobKind::Crawl, hook, resume);
473 if let Some(s) = Arc::get_mut(&mut sink) {
474 s.keep_summary = req.query.is_some();
475 }
476 tokio::spawn(async move {
477 let result = engine.crawl(&req, sink.clone()).await;
478 sink.finish(result.map_err(|e| (req.url.clone(), e))).await;
479 });
480 }
481}