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::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 counts(&self) -> BTreeMap<(&'static str, &'static str), u64> {
244 let mut out = BTreeMap::new();
245 for j in self.inner.map.lock().await.values() {
246 *out.entry((j.kind.as_str(), j.state.as_str())).or_default() += 1;
247 }
248 out
249 }
250
251 async fn register(
253 &self,
254 kind: JobKind,
255 total: usize,
256 start: Start,
257 crawl: Option<CrawlRequest>,
258 batch: Option<BatchSpec>,
259 ) -> Result<(String, Option<Hook>), ApiError> {
260 let id = uuid::Uuid::new_v4().to_string();
261 let job = Job::new(kind, total, start.owner.clone());
262 let meta = Meta {
263 kind,
264 state: JobState::Scraping,
265 created_ms: to_ms(job.created),
266 total,
267 completed: 0,
268 owner: start.owner.as_ref().map(|c| c.account.to_string()),
269 webhook: start.webhook.clone(),
270 crawl,
271 batch,
272 summary: None,
273 };
274 {
275 let mut map = self.inner.map.lock().await;
276 if let Some(c) = &start.owner {
277 if c.limits.max_concurrent_jobs.is_some() {
278 let active = map
279 .values()
280 .filter(|j| {
281 j.state == JobState::Scraping
282 && j.owner.as_ref().is_some_and(|o| o.account == c.account)
283 })
284 .count();
285 self.inner.limiter.check_jobs(c, active)?;
286 }
287 self.inner.limiter.check_quota(c)?;
288 }
289 map.insert(id.clone(), job);
290 }
291 if let Some(store) = &self.inner.store {
292 let (key, value) = (meta_key(&id), serde_json::to_vec(&meta).unwrap_or_default());
293 let m = self.inner.mirror.clone();
294 let put = move |s: &Store| mirror::write(s, m.as_deref(), |w| w.kv_put(&key, &value));
295 if let Err(e) = store.blocking(put).await {
296 tracing::warn!(job = %id, error = %e, "saving job failed; it will not survive a restart");
297 }
298 }
299 self.inner.metrics.job_started(kind.as_str());
300 tracing::info!(
301 job = %id,
302 kind = kind.as_str(),
303 account = start.owner.as_ref().map(|c| &*c.account),
304 "job started"
305 );
306 let hook = match (&self.inner.webhooks, start.webhook) {
307 (Some(w), Some(cfg)) => {
308 let hook = w.start(&id, kind.as_str(), cfg, self.inner.store.clone());
309 hook.send(Event::Started, Vec::new(), None).await;
310 Some(hook)
311 }
312 _ => None,
313 };
314 Ok((id, hook))
315 }
316
317 pub async fn start_batch(
319 &self,
320 engine: Arc<dyn Engine>,
321 urls: Vec<String>,
322 opts: ScrapeOptions,
323 concurrency: usize,
324 ) -> String {
325 self.start_batch_with(engine, urls, opts, concurrency, Start::default())
326 .await
327 .expect("no limits without a caller")
328 }
329
330 pub(crate) async fn start_batch_with(
331 &self,
332 engine: Arc<dyn Engine>,
333 urls: Vec<String>,
334 opts: ScrapeOptions,
335 concurrency: usize,
336 start: Start,
337 ) -> Result<String, ApiError> {
338 let spec = BatchSpec {
339 urls,
340 options: opts,
341 concurrency,
342 };
343 let total = spec.urls.len();
344 let (id, hook) = self
345 .register(
346 JobKind::BatchScrape,
347 total,
348 start,
349 None,
350 self.inner.store.is_some().then(|| spec.clone()),
351 )
352 .await?;
353 let pending = spec.urls.into_iter().enumerate().collect();
354 self.spawn_batch(id.clone(), engine, pending, spec.options, concurrency, hook);
355 Ok(id)
356 }
357
358 fn sink(
359 &self,
360 id: &str,
361 kind: JobKind,
362 hook: Option<Hook>,
363 resume: Option<CrawlResume>,
364 ) -> Arc<JobSink> {
365 Arc::new(JobSink {
366 jobs: self.clone(),
367 id: id.to_string(),
368 kind,
369 hook,
370 resume: StdMutex::new(resume),
371 quota_hit: AtomicBool::new(false),
372 keep_summary: true,
373 store_failed: StdMutex::new(None),
374 })
375 }
376
377 pub(super) fn spawn_batch(
378 &self,
379 id: String,
380 engine: Arc<dyn Engine>,
381 pending: Vec<(usize, String)>,
382 opts: ScrapeOptions,
383 concurrency: usize,
384 hook: Option<Hook>,
385 ) {
386 let sink = self.sink(&id, JobKind::BatchScrape, hook, None);
387 let opts = Arc::new(opts);
388 tokio::spawn(async move {
389 let sem = Arc::new(Semaphore::new(concurrency.max(1)));
390 let mut tasks = tokio::task::JoinSet::new();
391 for (i, url) in pending {
392 let (sink, engine, opts, sem) =
393 (sink.clone(), engine.clone(), opts.clone(), sem.clone());
394 tasks.spawn(async move {
395 let _permit = sem.acquire_owned().await.expect("semaphore open");
396 if sink.stopped().await {
397 return;
398 }
399 let done = i.to_string();
400 match engine.scrape(&url, &opts).await {
401 Ok(doc) => sink.record_page(done, doc).await,
402 Err(e) => sink.record_error(done, url, e).await,
403 }
404 });
405 }
406 while tasks.join_next().await.is_some() {}
407 sink.finish(Ok(())).await;
408 });
409 }
410
411 pub async fn start_crawl(&self, engine: Arc<dyn Engine>, req: CrawlRequest) -> String {
413 self.start_crawl_with(engine, req, Start::default())
414 .await
415 .expect("no limits without a caller")
416 }
417
418 pub(crate) async fn start_crawl_with(
419 &self,
420 engine: Arc<dyn Engine>,
421 req: CrawlRequest,
422 start: Start,
423 ) -> Result<String, ApiError> {
424 let (id, hook) = self
425 .register(
426 JobKind::Crawl,
427 0,
428 start,
429 self.inner.store.is_some().then(|| req.clone()),
430 None,
431 )
432 .await?;
433 self.spawn_crawl(id.clone(), engine, req, hook, None);
434 Ok(id)
435 }
436
437 pub(super) fn spawn_crawl(
438 &self,
439 id: String,
440 engine: Arc<dyn Engine>,
441 req: CrawlRequest,
442 hook: Option<Hook>,
443 resume: Option<CrawlResume>,
444 ) {
445 let mut sink = self.sink(&id, JobKind::Crawl, hook, resume);
446 if let Some(s) = Arc::get_mut(&mut sink) {
447 s.keep_summary = req.query.is_some();
448 }
449 tokio::spawn(async move {
450 let result = engine.crawl(&req, sink.clone()).await;
451 sink.finish(result.map_err(|e| (req.url.clone(), e))).await;
452 });
453 }
454}