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
30mod sink;
31pub mod state;
32mod store;
33pub mod time;
34
35pub use state::{JOB_TTL, Job, JobKind, JobState, Start, StatusPage};
36pub use time::rfc3339;
37
38use sink::JobSink;
39use store::{BatchSpec, Meta, delete_job, list_key, meta_key, to_ms};
40
41struct Inner {
42 map: Mutex<HashMap<String, Job>>,
43 store: Option<Store>,
44 limiter: Arc<Limiter>,
45 webhooks: Option<Webhooks>,
46 metrics: Arc<Metrics>,
47}
48
49#[derive(Clone)]
50pub struct Jobs {
51 inner: Arc<Inner>,
52}
53
54impl Default for Jobs {
55 fn default() -> Self {
57 Self::new(None, Arc::default(), None, Arc::default())
58 }
59}
60
61impl Jobs {
62 pub(crate) fn new(
63 store: Option<Store>,
64 limiter: Arc<Limiter>,
65 webhooks: Option<Webhooks>,
66 metrics: Arc<Metrics>,
67 ) -> Self {
68 Self {
69 inner: Arc::new(Inner {
70 map: Mutex::new(HashMap::new()),
71 store,
72 limiter,
73 webhooks,
74 metrics,
75 }),
76 }
77 }
78
79 async fn sweep(&self) {
81 let now = SystemTime::now();
82 let expired: Vec<String> = {
83 let mut map = self.inner.map.lock().await;
84 let ids: Vec<String> = map
85 .iter()
86 .filter(|(_, j)| j.expires_at() <= now)
87 .map(|(id, _)| id.clone())
88 .collect();
89 for id in &ids {
90 map.remove(id);
91 }
92 ids
93 };
94 if let (Some(store), false) = (&self.inner.store, expired.is_empty()) {
95 store
96 .blocking(move |s| {
97 for id in expired {
98 delete_job(s, &id);
99 }
100 })
101 .await;
102 }
103 }
104
105 pub async fn get<R>(&self, id: &str, f: impl FnOnce(&Job) -> R) -> Option<R> {
106 self.sweep().await;
107 self.inner.map.lock().await.get(id).map(f)
108 }
109
110 pub(crate) async fn status(&self, id: &str, skip: usize, limit: usize) -> Option<StatusPage> {
112 self.sweep().await;
113 let mut page = {
114 let map = self.inner.map.lock().await;
115 let job = map.get(id)?;
116 let end = (skip + limit).min(job.data.len());
117 StatusPage {
118 state: job.state,
119 total: job.total,
120 completed: job.completed,
121 expires_at: job.expires_at(),
122 docs: job.docs(),
123 data: job
124 .data
125 .get(skip.min(end)..end)
126 .unwrap_or_default()
127 .to_vec(),
128 summary: job.summary.clone(),
129 }
130 };
131 if let Some(store) = &self.inner.store
132 && page.data.is_empty()
133 && skip < page.docs
134 {
135 let key = list_key(id, "pages");
136 page.data = store
137 .blocking(move |s| s.tasks().list_range(key, skip as u64, limit))
138 .await
139 .unwrap_or_else(|e| {
140 tracing::warn!(error = %e, "reading job pages failed");
141 Vec::new()
142 })
143 .iter()
144 .filter_map(|v| serde_json::from_slice(v).ok())
145 .collect();
146 }
147 Some(page)
148 }
149
150 pub(crate) async fn errors(&self, id: &str) -> Option<(Vec<JobError>, Vec<String>)> {
152 self.sweep().await;
153 let (mut errors, mut robots) = {
154 let map = self.inner.map.lock().await;
155 let job = map.get(id)?;
156 (job.errors.clone(), job.robots_blocked.clone())
157 };
158 if let Some(store) = &self.inner.store {
159 let key = list_key(id, "errors");
160 let stored = store
161 .blocking(move |s| s.tasks().list_items(key, usize::MAX))
162 .await
163 .unwrap_or_default();
164 for v in stored {
165 if let Ok(e) = serde_json::from_slice::<JobError>(&v) {
166 if e.code == ErrorCode::RobotsDisallowed.as_str() {
167 robots.push(e.url.clone());
168 }
169 errors.push(e);
170 }
171 }
172 }
173 Some((errors, robots))
174 }
175
176 pub async fn cancel(&self, id: &str) -> bool {
177 let found = {
178 let mut map = self.inner.map.lock().await;
179 match map.get_mut(id) {
180 Some(j) => {
181 if j.state == JobState::Scraping {
182 j.state = JobState::Cancelled;
183 }
184 true
185 }
186 None => false,
187 }
188 };
189 if found {
190 self.save_state(id).await;
191 }
192 found
193 }
194
195 pub async fn active(&self) -> Vec<String> {
196 let map = self.inner.map.lock().await;
197 map.iter()
198 .filter(|(_, j)| j.state == JobState::Scraping)
199 .map(|(id, _)| id.clone())
200 .collect()
201 }
202
203 pub(crate) async fn counts(&self) -> BTreeMap<(&'static str, &'static str), u64> {
205 let mut out = BTreeMap::new();
206 for j in self.inner.map.lock().await.values() {
207 *out.entry((j.kind.as_str(), j.state.as_str())).or_default() += 1;
208 }
209 out
210 }
211
212 async fn register(
214 &self,
215 kind: JobKind,
216 total: usize,
217 start: Start,
218 crawl: Option<CrawlRequest>,
219 batch: Option<BatchSpec>,
220 ) -> Result<(String, Option<Hook>), ApiError> {
221 let id = uuid::Uuid::new_v4().to_string();
222 let job = Job::new(kind, total, start.owner.clone());
223 let meta = Meta {
224 kind,
225 state: JobState::Scraping,
226 created_ms: to_ms(job.created),
227 total,
228 completed: 0,
229 owner: start.owner.as_ref().map(|c| c.account.to_string()),
230 webhook: start.webhook.clone(),
231 crawl,
232 batch,
233 summary: None,
234 };
235 {
236 let mut map = self.inner.map.lock().await;
237 if let Some(c) = &start.owner {
238 if c.limits.max_concurrent_jobs.is_some() {
239 let active = map
240 .values()
241 .filter(|j| {
242 j.state == JobState::Scraping
243 && j.owner.as_ref().is_some_and(|o| o.account == c.account)
244 })
245 .count();
246 self.inner.limiter.check_jobs(c, active)?;
247 }
248 self.inner.limiter.check_quota(c)?;
249 }
250 map.insert(id.clone(), job);
251 }
252 if let Some(store) = &self.inner.store {
253 let (key, value) = (meta_key(&id), serde_json::to_vec(&meta).unwrap_or_default());
254 if let Err(e) = store.blocking(move |s| s.tasks().kv_put(key, &value)).await {
255 tracing::warn!(job = %id, error = %e, "saving job failed; it will not survive a restart");
256 }
257 }
258 self.inner.metrics.job_started(kind.as_str());
259 tracing::info!(
260 job = %id,
261 kind = kind.as_str(),
262 account = start.owner.as_ref().map(|c| &*c.account),
263 "job started"
264 );
265 let hook = match (&self.inner.webhooks, start.webhook) {
266 (Some(w), Some(cfg)) => {
267 let hook = w.start(&id, kind.as_str(), cfg);
268 hook.send(Event::Started, Vec::new(), None);
269 Some(hook)
270 }
271 _ => None,
272 };
273 Ok((id, hook))
274 }
275
276 pub async fn start_batch(
278 &self,
279 engine: Arc<dyn Engine>,
280 urls: Vec<String>,
281 opts: ScrapeOptions,
282 concurrency: usize,
283 ) -> String {
284 self.start_batch_with(engine, urls, opts, concurrency, Start::default())
285 .await
286 .expect("no limits without a caller")
287 }
288
289 pub(crate) async fn start_batch_with(
290 &self,
291 engine: Arc<dyn Engine>,
292 urls: Vec<String>,
293 opts: ScrapeOptions,
294 concurrency: usize,
295 start: Start,
296 ) -> Result<String, ApiError> {
297 let spec = BatchSpec {
298 urls,
299 options: opts,
300 concurrency,
301 };
302 let total = spec.urls.len();
303 let (id, hook) = self
304 .register(
305 JobKind::BatchScrape,
306 total,
307 start,
308 None,
309 self.inner.store.is_some().then(|| spec.clone()),
310 )
311 .await?;
312 let pending = spec.urls.into_iter().enumerate().collect();
313 self.spawn_batch(id.clone(), engine, pending, spec.options, concurrency, hook);
314 Ok(id)
315 }
316
317 fn sink(
318 &self,
319 id: &str,
320 kind: JobKind,
321 hook: Option<Hook>,
322 resume: Option<CrawlResume>,
323 ) -> Arc<JobSink> {
324 Arc::new(JobSink {
325 jobs: self.clone(),
326 id: id.to_string(),
327 kind,
328 hook,
329 resume: StdMutex::new(resume),
330 quota_hit: AtomicBool::new(false),
331 })
332 }
333
334 pub(super) fn spawn_batch(
335 &self,
336 id: String,
337 engine: Arc<dyn Engine>,
338 pending: Vec<(usize, String)>,
339 opts: ScrapeOptions,
340 concurrency: usize,
341 hook: Option<Hook>,
342 ) {
343 let sink = self.sink(&id, JobKind::BatchScrape, hook, None);
344 let opts = Arc::new(opts);
345 tokio::spawn(async move {
346 let sem = Arc::new(Semaphore::new(concurrency.max(1)));
347 let mut tasks = tokio::task::JoinSet::new();
348 for (i, url) in pending {
349 let (sink, engine, opts, sem) =
350 (sink.clone(), engine.clone(), opts.clone(), sem.clone());
351 tasks.spawn(async move {
352 let _permit = sem.acquire_owned().await.expect("semaphore open");
353 if sink.stopped().await {
354 return;
355 }
356 let done = i.to_string();
357 match engine.scrape(&url, &opts).await {
358 Ok(doc) => sink.record_page(done, doc).await,
359 Err(e) => sink.record_error(done, url, e).await,
360 }
361 });
362 }
363 while tasks.join_next().await.is_some() {}
364 sink.finish(Ok(())).await;
365 });
366 }
367
368 pub async fn start_crawl(&self, engine: Arc<dyn Engine>, req: CrawlRequest) -> String {
370 self.start_crawl_with(engine, req, Start::default())
371 .await
372 .expect("no limits without a caller")
373 }
374
375 pub(crate) async fn start_crawl_with(
376 &self,
377 engine: Arc<dyn Engine>,
378 req: CrawlRequest,
379 start: Start,
380 ) -> Result<String, ApiError> {
381 let (id, hook) = self
382 .register(
383 JobKind::Crawl,
384 0,
385 start,
386 self.inner.store.is_some().then(|| req.clone()),
387 None,
388 )
389 .await?;
390 self.spawn_crawl(id.clone(), engine, req, hook, None);
391 Ok(id)
392 }
393
394 pub(super) fn spawn_crawl(
395 &self,
396 id: String,
397 engine: Arc<dyn Engine>,
398 req: CrawlRequest,
399 hook: Option<Hook>,
400 resume: Option<CrawlResume>,
401 ) {
402 let sink = self.sink(&id, JobKind::Crawl, hook, resume);
403 tokio::spawn(async move {
404 let result = engine.crawl(&req, sink.clone()).await;
405 sink.finish(result.map_err(|e| (req.url.clone(), e))).await;
406 });
407 }
408}