1use crw_core::Deadline;
2use crw_core::config::AppConfig;
3use crw_core::error::{CrwError, CrwResult};
4use crw_core::types::{
5 CrawlRequest, CrawlState, CrawlStatus, RequestedRenderer, ScrapeRequest,
6 resolve_pinned_renderer, resolve_render_js,
7};
8use crw_crawl::crawl::{CrawlOptions, run_crawl};
9use crw_crawl::single::scrape_url;
10use crw_renderer::FallbackRenderer;
11use crw_search::SearxngClient;
12use futures::stream::StreamExt;
13use std::collections::HashMap;
14use std::sync::Arc;
15use std::time::{Duration, Instant, SystemTime};
16use tokio::sync::{RwLock, watch};
17use uuid::Uuid;
18
19pub(crate) fn validate_renderer_pin(
30 renderer: Option<RequestedRenderer>,
31 render_js: Option<bool>,
32 state: &AppState,
33) -> CrwResult<()> {
34 let Some(name) = resolve_pinned_renderer(renderer) else {
35 return Ok(());
36 };
37
38 let effective_request = if render_js.is_none() {
44 Some(true)
45 } else {
46 render_js
47 };
48 let effective_render_js =
49 resolve_render_js(effective_request, state.config.renderer.render_js_default);
50
51 if effective_render_js == Some(false) {
52 return Ok(());
53 }
54
55 let available = state.renderer.js_renderer_names();
56 if !available.contains(&name) {
57 return Err(CrwError::InvalidRequest(format!(
58 "renderer '{}' not available; configured renderers: [{}]. \
59 Update server config or omit the 'renderer' field.",
60 name,
61 available.join(", ")
62 )));
63 }
64 Ok(())
65}
66
67pub(crate) fn validate_crawl_renderer(req: &CrawlRequest, state: &AppState) -> CrwResult<()> {
69 validate_renderer_pin(req.renderer, req.render_js, state)
70}
71
72pub struct CrawlJob {
74 pub rx: watch::Receiver<CrawlState>,
75 pub tx: watch::Sender<CrawlState>,
78 pub created_at: Instant,
79 pub abort_handle: Option<tokio::task::AbortHandle>,
81}
82
83struct InflightGuard;
86impl InflightGuard {
87 fn new() -> Self {
88 crw_core::metrics::metrics().batch_pipelines_inflight.inc();
89 InflightGuard
90 }
91}
92impl Drop for InflightGuard {
93 fn drop(&mut self) {
94 crw_core::metrics::metrics().batch_pipelines_inflight.dec();
95 }
96}
97
98const MAX_CONCURRENT_CRAWLS: usize = 10;
100const JOB_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum ExtractStatus {
106 Processing,
107 Cancelling,
108 Completed,
109 Failed,
110 Cancelled,
111}
112
113impl ExtractStatus {
114 pub fn as_str(self) -> &'static str {
115 match self {
116 ExtractStatus::Processing => "processing",
117 ExtractStatus::Cancelling => "cancelling",
118 ExtractStatus::Completed => "completed",
119 ExtractStatus::Failed => "failed",
120 ExtractStatus::Cancelled => "cancelled",
121 }
122 }
123
124 pub fn is_terminal(self) -> bool {
125 matches!(
126 self,
127 ExtractStatus::Completed | ExtractStatus::Failed | ExtractStatus::Cancelled
128 )
129 }
130}
131
132#[derive(Debug, Clone)]
142pub struct UrlResult {
143 pub url: String,
144 pub status: ExtractStatus,
145 pub data: Option<serde_json::Value>,
146 pub error: Option<String>,
147 pub llm_usage: Option<crw_core::types::LlmUsage>,
148 pub basis: Option<Vec<crw_core::evidence::Basis>>,
149 pub basis_warnings: Vec<crw_core::evidence::BasisWarning>,
150 pub llm_input_hash: Option<String>,
151}
152
153#[derive(Debug, Clone)]
157pub struct PreparedUrl {
158 pub url: String,
159 pub preflight_error: Option<String>,
160}
161
162#[derive(Debug, Clone)]
167pub struct ExtractRecord {
168 pub status: ExtractStatus,
169 pub data: Option<serde_json::Value>,
170 pub per_url: Vec<UrlResult>,
171 pub tokens_used: u32,
172 pub credits_used: u32,
173 pub error: Option<String>,
174 pub created_at: Instant,
175 pub expires_at: SystemTime,
178 pub claimed_index: Option<usize>,
181}
182
183impl ExtractRecord {
184 fn is_expired(&self, ttl: Duration) -> bool {
185 self.created_at.elapsed() >= ttl
186 }
187
188 fn finish_cancellation(&mut self) {
191 if self.status != ExtractStatus::Cancelling || self.claimed_index.is_some() {
192 return;
193 }
194 let mut cancelled_any = false;
195 for result in &mut self.per_url {
196 if result.status == ExtractStatus::Processing {
197 result.status = ExtractStatus::Cancelled;
198 result.data = None;
199 result.error = None;
200 result.llm_usage = None;
201 result.basis = None;
202 result.basis_warnings.clear();
203 result.llm_input_hash = None;
204 cancelled_any = true;
205 }
206 }
207 if cancelled_any {
208 self.status = ExtractStatus::Cancelled;
210 } else {
211 self.complete_from_outcomes();
216 }
217 }
218
219 fn complete_from_outcomes(&mut self) {
223 let any_ok = self
224 .per_url
225 .iter()
226 .any(|result| result.status == ExtractStatus::Completed);
227 if any_ok {
228 self.status = ExtractStatus::Completed;
229 self.data
230 .get_or_insert_with(|| serde_json::Value::Object(Default::default()));
231 } else {
232 self.status = ExtractStatus::Failed;
233 self.error = self
234 .per_url
235 .iter()
236 .rev()
237 .find_map(|result| result.error.clone());
238 }
239 self.credits_used = self.credits_used.max(1);
242 }
243
244 fn finish_processing(&mut self) {
248 if self.status != ExtractStatus::Processing {
249 if self.status == ExtractStatus::Cancelling {
250 self.finish_cancellation();
251 }
252 return;
253 }
254
255 self.complete_from_outcomes();
256 }
257}
258
259#[derive(Clone)]
261pub struct AppState {
262 pub config: Arc<AppConfig>,
263 pub renderer: Arc<FallbackRenderer>,
264 pub crawl_jobs: Arc<RwLock<HashMap<Uuid, CrawlJob>>>,
265 pub extract_jobs: Arc<RwLock<HashMap<Uuid, ExtractRecord>>>,
268 pub crawl_semaphore: Arc<tokio::sync::Semaphore>,
269 pub batch_pipeline_sem: Option<Arc<tokio::sync::Semaphore>>,
277 pub searxng: Option<Arc<SearxngClient>>,
280 pub url_filter: Option<Arc<crw_crawl::url_filter::UrlFilterCfg>>,
284}
285
286impl AppState {
287 pub fn new(config: AppConfig) -> CrwResult<Self> {
288 let proxy_rotator = crw_core::ProxyRotator::build(
294 &config.crawler.proxy_list,
295 config.crawler.proxy.as_deref(),
296 config.crawler.proxy_rotation,
297 )
298 .map_err(CrwError::ConfigError)?
299 .map(Arc::new);
300 let renderer = FallbackRenderer::new(
301 &config.renderer,
302 &config.crawler.user_agent,
303 None,
304 &config.crawler.stealth,
305 )?
306 .with_proxy_rotator(proxy_rotator)?
307 .with_host_limits(
308 config.crawler.requests_per_second,
309 config.crawler.per_host_max_concurrent,
310 config.crawler.per_host_interactive_reserve,
311 );
312
313 let searxng = if config.search.enabled
314 && let Some(url) = config.search.searxng_url.as_ref()
315 {
316 let http = reqwest::Client::builder()
321 .connect_timeout(Duration::from_secs(5))
322 .build()
323 .map_err(|e| {
324 CrwError::Internal(format!("failed to build SearXNG http client: {e}"))
325 })?;
326 let timeout = Duration::from_millis(config.search.timeout_ms);
327 Some(Arc::new(SearxngClient::new(Arc::new(http), url, timeout)))
328 } else {
329 None
330 };
331
332 let url_filter_cfg =
333 crw_crawl::url_filter::UrlFilterCfg::from_map_config(&config.map.url_filter);
334 let m = crw_core::metrics::metrics();
337 m.map_filter_rules_loaded
338 .with_label_values(&["action"])
339 .inc_by(
340 (crw_crawl::url_filter_data::DEFAULT_ACTION_PARAMS.len()
341 + url_filter_cfg.action_params.len()) as u64,
342 );
343 m.map_filter_rules_loaded
344 .with_label_values(&["tracking"])
345 .inc_by(
346 (crw_crawl::url_filter_data::DEFAULT_TRACKING_PARAMS.len()
347 + url_filter_cfg.tracking_params.len()) as u64,
348 );
349 m.map_filter_rules_loaded
350 .with_label_values(&["preserve"])
351 .inc_by(
352 (crw_crawl::url_filter_data::ALWAYS_PRESERVE.len()
353 + url_filter_cfg.preserve_params.len()) as u64,
354 );
355 m.map_filter_rules_loaded
356 .with_label_values(&["host_override"])
357 .inc_by(url_filter_cfg.host_overrides.len() as u64);
358 let url_filter = Some(Arc::new(url_filter_cfg));
359
360 let extract_total = config.extraction.max_concurrent_extracts;
366 crw_crawl::extract_pool::configure_extract_limit(
367 extract_total,
368 crw_core::config::resolve_interactive_reserve(
369 config.extraction.reserved_interactive_extracts,
370 extract_total,
371 ),
372 );
373 crw_crawl::pdf::configure_limits(&config.document);
374 if let Some(llm) = &config.extraction.llm {
375 crw_extract::llm_gate::configure_llm_limits(
376 llm.max_concurrency,
377 crw_core::config::resolve_interactive_reserve(
378 llm.reserved_interactive_llm,
379 llm.max_concurrency,
380 ),
381 );
382 }
383
384 let batch_pipeline_sem = match config.crawler.max_aggregate_batch_pipelines {
387 0 => None,
388 n => Some(Arc::new(tokio::sync::Semaphore::new(n))),
389 };
390
391 let state = Self {
392 config: Arc::new(config),
393 renderer: Arc::new(renderer),
394 crawl_jobs: Arc::new(RwLock::new(HashMap::new())),
395 extract_jobs: Arc::new(RwLock::new(HashMap::new())),
396 crawl_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_CRAWLS)),
397 batch_pipeline_sem,
398 searxng,
399 url_filter,
400 };
401
402 let cleanup_state = state.clone();
405 tokio::spawn(async move {
406 let ttl = Duration::from_secs(cleanup_state.config.crawler.job_ttl_secs);
407 loop {
408 tokio::time::sleep(JOB_CLEANUP_INTERVAL).await;
409 let mut jobs = cleanup_state.crawl_jobs.write().await;
410 let before = jobs.len();
411 jobs.retain(|_id, job| {
412 let is_done = matches!(
413 job.rx.borrow().status,
414 CrawlStatus::Completed | CrawlStatus::Failed | CrawlStatus::Cancelled
415 );
416 !is_done || job.created_at.elapsed() < ttl
418 });
419 let removed = before - jobs.len();
420 if removed > 0 {
421 tracing::info!(
422 removed,
423 remaining = jobs.len(),
424 "Cleaned up expired crawl jobs"
425 );
426 }
427 drop(jobs);
428
429 cleanup_state.prune_expired_extract_jobs(ttl).await;
432 }
433 });
434
435 Ok(state)
436 }
437
438 pub async fn start_crawl_job(&self, req: CrawlRequest) -> Uuid {
441 let id = Uuid::new_v4();
442 let initial = CrawlState {
443 id,
444 success: true,
445 status: CrawlStatus::InProgress,
446 total: 0,
447 completed: 0,
448 data: vec![],
449 error: None,
450 };
451
452 let (tx, rx) = watch::channel(initial);
453
454 {
455 let mut jobs = self.crawl_jobs.write().await;
456 jobs.insert(
457 id,
458 CrawlJob {
459 rx,
460 tx: tx.clone(),
461 created_at: Instant::now(),
462 abort_handle: None,
463 },
464 );
465 }
466
467 let renderer = self.renderer.clone();
468 let max_concurrency = self.config.crawler.max_concurrency;
469 let respect_robots = self.config.crawler.respect_robots_txt;
470 let rps = self.config.crawler.requests_per_second;
471 let user_agent = self.config.crawler.user_agent.clone();
472 let crawl_semaphore = self.crawl_semaphore.clone();
473 let llm_config = self.config.extraction.llm.clone();
474 let proxy = self.config.crawler.proxy.clone();
475 let jitter_factor = self.config.crawler.stealth.jitter_factor;
476 let deadline_ms_per_page = self.config.effective_deadline_ms(None, req.wait_for);
477 let per_host_max_concurrent = self.config.crawler.per_host_max_concurrent;
478
479 let handle = tokio::spawn(async move {
480 let _permit = match crawl_semaphore.acquire().await {
481 Ok(p) => p,
482 Err(_) => {
483 let _ = tx.send(CrawlState {
484 id,
485 success: false,
486 status: CrawlStatus::Failed,
487 total: 0,
488 completed: 0,
489 data: vec![],
490 error: Some("Server is overloaded, try again later".into()),
491 });
492 return;
493 }
494 };
495 crw_core::REQUEST_CLASS
500 .scope(crw_core::ScrapeClass::Batch, async {
501 run_crawl(CrawlOptions {
502 id,
503 req,
504 renderer,
505 max_concurrency,
506 respect_robots,
507 requests_per_second: rps,
508 user_agent: &user_agent,
509 state_tx: tx,
510 llm_config: llm_config.as_ref(),
511 proxy,
512 jitter_factor,
513 deadline_ms_per_page,
514 per_host_max_concurrent,
515 })
516 .await;
517 })
518 .await;
519 });
520
521 {
523 let mut jobs = self.crawl_jobs.write().await;
524 if let Some(job) = jobs.get_mut(&id) {
525 job.abort_handle = Some(handle.abort_handle());
526 }
527 }
528
529 id
530 }
531
532 pub async fn start_batch_job(
537 &self,
538 urls: Vec<String>,
539 template: ScrapeRequest,
540 max_concurrency_override: Option<usize>,
541 ) -> Uuid {
542 let id = Uuid::new_v4();
543 let total = urls.len() as u32;
544 let (tx, rx) = watch::channel(CrawlState {
545 id,
546 success: true,
547 status: CrawlStatus::InProgress,
548 total,
549 completed: 0,
550 data: vec![],
551 error: None,
552 });
553 {
554 let mut jobs = self.crawl_jobs.write().await;
555 jobs.insert(
556 id,
557 CrawlJob {
558 rx,
559 tx: tx.clone(),
560 created_at: Instant::now(),
561 abort_handle: None,
562 },
563 );
564 }
565
566 let renderer = self.renderer.clone();
567 let crawl_semaphore = self.crawl_semaphore.clone();
568 let batch_pipeline_sem = self.batch_pipeline_sem.clone();
569 let config = self.config.clone();
570 let width_ceiling = config.crawler.max_batch_concurrency.max(1);
575 let max_concurrency = max_concurrency_override
576 .unwrap_or(config.crawler.max_concurrency)
577 .clamp(1, width_ceiling);
578
579 let handle = tokio::spawn(async move {
580 let _permit = match crawl_semaphore.acquire().await {
581 Ok(p) => p,
582 Err(_) => {
583 let _ = tx.send(CrawlState {
584 id,
585 success: false,
586 status: CrawlStatus::Failed,
587 total,
588 completed: 0,
589 data: vec![],
590 error: Some("Server is overloaded, try again later".into()),
591 });
592 return;
593 }
594 };
595
596 if total == 0 {
597 let _ = tx.send(CrawlState {
598 id,
599 success: true,
600 status: CrawlStatus::Completed,
601 total: 0,
602 completed: 0,
603 data: vec![],
604 error: None,
605 });
606 return;
607 }
608
609 let user_agent = config.crawler.user_agent.clone();
610 let default_stealth =
611 config.crawler.stealth.enabled && config.crawler.stealth.inject_headers;
612 let render_js_default = config.renderer.render_js_default;
613 let deadline_ms = config.effective_deadline_ms(template.deadline_ms, template.wait_for);
614
615 let reqs: Vec<ScrapeRequest> = urls
616 .into_iter()
617 .map(|u| {
618 let mut r = template.clone();
619 r.url = u;
620 r
621 })
622 .collect();
623
624 crw_core::REQUEST_CLASS
632 .scope(crw_core::ScrapeClass::Batch, async move {
633 futures::stream::iter(reqs)
634 .for_each_concurrent(max_concurrency, |req| {
635 let renderer = renderer.clone();
636 let config = config.clone();
637 let user_agent = user_agent.clone();
638 let tx = tx.clone();
639 let batch_pipeline_sem = batch_pipeline_sem.clone();
640 async move {
641 let _pipeline_permit = match &batch_pipeline_sem {
646 Some(sem) => sem.acquire().await.ok(),
647 None => None,
648 };
649 let _inflight = InflightGuard::new();
651 let deadline = Deadline::from_request_ms(deadline_ms);
652 let scraped = scrape_url(
653 &req,
654 &renderer,
655 config.extraction.llm.as_ref(),
656 &config.extraction,
657 &user_agent,
658 default_stealth,
659 render_js_default,
660 deadline,
661 )
662 .await
663 .ok();
664 tx.send_modify(|st| {
670 if let Some(d) = scraped {
671 st.data.push(d);
672 }
673 st.completed += 1;
674 if st.completed >= total && st.status == CrawlStatus::InProgress
677 {
678 st.status = CrawlStatus::Completed;
679 }
680 });
681 }
682 })
683 .await;
684 })
685 .await;
686 });
687
688 {
689 let mut jobs = self.crawl_jobs.write().await;
690 if let Some(job) = jobs.get_mut(&id) {
691 job.abort_handle = Some(handle.abort_handle());
692 }
693 }
694
695 id
696 }
697
698 pub async fn start_extract_job(
705 &self,
706 entries: Vec<PreparedUrl>,
707 template: ScrapeRequest,
708 ) -> Uuid {
709 let id = Uuid::new_v4();
710 let per_url = entries
715 .iter()
716 .map(|entry| UrlResult {
717 url: entry.url.clone(),
718 status: if entry.preflight_error.is_some() {
719 ExtractStatus::Failed
720 } else {
721 ExtractStatus::Processing
722 },
723 data: None,
724 error: entry.preflight_error.clone(),
725 llm_usage: None,
726 basis: None,
727 basis_warnings: Vec::new(),
728 llm_input_hash: None,
729 })
730 .collect();
731 {
732 let mut jobs = self.extract_jobs.write().await;
733 let created_at = Instant::now();
734 let wall_now = SystemTime::now();
735 let expires_at =
736 wall_now.checked_add(Duration::from_secs(self.config.crawler.job_ttl_secs));
737 jobs.insert(
738 id,
739 ExtractRecord {
740 status: ExtractStatus::Processing,
741 data: None,
742 per_url,
743 tokens_used: 0,
744 credits_used: 0,
745 error: None,
746 created_at,
747 expires_at: expires_at.unwrap_or(wall_now),
748 claimed_index: None,
749 },
750 );
751 }
752
753 let renderer = self.renderer.clone();
754 let config = self.config.clone();
755 let extract_jobs = self.extract_jobs.clone();
756 let finalizer = self.clone();
757
758 tokio::spawn(async move {
759 crw_core::REQUEST_CLASS
764 .scope(crw_core::ScrapeClass::Batch, async move {
765 let user_agent = config.crawler.user_agent.clone();
766 let default_stealth =
767 config.crawler.stealth.enabled && config.crawler.stealth.inject_headers;
768 let render_js_default = config.renderer.render_js_default;
769 let deadline_ms =
770 config.effective_deadline_ms(template.deadline_ms, template.wait_for);
771
772 for (index, entry) in entries.into_iter().enumerate() {
773 if entry.preflight_error.is_some() {
776 continue;
777 }
778
779 {
783 let mut jobs = extract_jobs.write().await;
784 let Some(rec) = jobs.get_mut(&id) else {
785 return;
786 };
787 match rec.status {
788 ExtractStatus::Processing => {
789 if rec.per_url[index].status != ExtractStatus::Processing {
790 continue;
791 }
792 rec.claimed_index = Some(index);
793 }
794 ExtractStatus::Cancelling => {
795 rec.finish_cancellation();
796 return;
797 }
798 ExtractStatus::Completed
799 | ExtractStatus::Failed
800 | ExtractStatus::Cancelled => return,
801 }
802 }
803
804 let mut req = template.clone();
805 req.url = entry.url.clone();
806 let deadline = Deadline::from_request_ms(deadline_ms);
807 let (result, merged_fields, tokens, credits) = match scrape_url(
808 &req,
809 &renderer,
810 config.extraction.llm.as_ref(),
811 &config.extraction,
812 &user_agent,
813 default_stealth,
814 render_js_default,
815 deadline,
816 )
817 .await
818 {
819 Ok(d) => {
820 let merged_fields = match &d.json {
821 Some(serde_json::Value::Object(obj)) => Some(obj.clone()),
822 _ => None,
823 };
824 let tokens =
825 d.llm_usage.as_ref().map_or(0, |usage| usage.total_tokens);
826 let credits = if d.credit_cost == 0 { 1 } else { d.credit_cost };
827 (
828 UrlResult {
829 url: entry.url,
830 status: ExtractStatus::Completed,
831 data: d.json,
832 error: None,
833 llm_usage: d.llm_usage,
834 basis: d.basis,
835 basis_warnings: d.basis_warnings,
836 llm_input_hash: d.llm_input_hash,
837 },
838 merged_fields,
839 tokens,
840 credits,
841 )
842 }
843 Err(e) => {
844 let msg = e.to_string();
845 (
846 UrlResult {
847 url: entry.url,
848 status: ExtractStatus::Failed,
849 data: None,
850 error: Some(msg),
851 llm_usage: None,
852 basis: None,
853 basis_warnings: Vec::new(),
854 llm_input_hash: None,
855 },
856 None,
857 0,
858 0,
859 )
860 }
861 };
862
863 let mut jobs = extract_jobs.write().await;
866 let Some(rec) = jobs.get_mut(&id) else {
867 return;
868 };
869 if rec.status.is_terminal() || rec.claimed_index != Some(index) {
870 return;
871 }
872 rec.per_url[index] = result;
873 if let Some(fields) = merged_fields {
874 let merged = rec.data.get_or_insert_with(|| {
875 serde_json::Value::Object(Default::default())
876 });
877 if let serde_json::Value::Object(merged) = merged {
878 merged.extend(fields);
879 }
880 }
881 rec.tokens_used = rec.tokens_used.saturating_add(tokens);
882 rec.credits_used = rec.credits_used.saturating_add(credits);
883 rec.claimed_index = None;
884 if rec.status == ExtractStatus::Cancelling {
885 rec.finish_cancellation();
886 return;
887 }
888 }
889
890 finalizer.finalize_extract_job(id).await;
891 })
892 .await;
893 });
894
895 id
896 }
897
898 pub async fn cancel_extract_job(&self, id: Uuid) -> CrwResult<ExtractRecord> {
901 let mut jobs = self.extract_jobs.write().await;
902 let ttl = Duration::from_secs(self.config.crawler.job_ttl_secs);
903 if jobs.get(&id).is_some_and(|rec| rec.is_expired(ttl)) {
904 jobs.remove(&id);
905 return Err(CrwError::NotFound(format!("Extract job {id} not found")));
906 }
907 let rec = jobs
908 .get_mut(&id)
909 .ok_or_else(|| CrwError::NotFound(format!("Extract job {id} not found")))?;
910 if rec.status == ExtractStatus::Processing {
911 rec.status = ExtractStatus::Cancelling;
912 }
913 if rec.status == ExtractStatus::Cancelling {
914 rec.finish_cancellation();
915 }
916 Ok(rec.clone())
917 }
918
919 pub async fn get_extract_job(&self, id: Uuid) -> CrwResult<ExtractRecord> {
922 let mut jobs = self.extract_jobs.write().await;
923 let ttl = Duration::from_secs(self.config.crawler.job_ttl_secs);
924 if jobs.get(&id).is_some_and(|rec| rec.is_expired(ttl)) {
925 jobs.remove(&id);
926 return Err(CrwError::NotFound(format!("Extract job {id} not found")));
927 }
928 jobs.get(&id)
929 .cloned()
930 .ok_or_else(|| CrwError::NotFound(format!("Extract job {id} not found")))
931 }
932
933 async fn finalize_extract_job(&self, id: Uuid) {
934 let mut jobs = self.extract_jobs.write().await;
935 if let Some(rec) = jobs.get_mut(&id) {
936 rec.finish_processing();
937 }
938 }
939
940 async fn prune_expired_extract_jobs(&self, ttl: Duration) -> usize {
941 let mut jobs = self.extract_jobs.write().await;
942 let before = jobs.len();
943 jobs.retain(|_id, rec| !rec.is_expired(ttl));
944 before - jobs.len()
945 }
946}
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951 use crate::routes::extract::serialize_extract_status;
952 use serde_json::json;
953 use tokio::sync::oneshot;
954
955 fn completed_record(created_at: Instant) -> ExtractRecord {
956 ExtractRecord {
957 status: ExtractStatus::Processing,
958 data: Some(json!({"last": 3})),
959 per_url: (1..=3)
960 .map(|index| UrlResult {
961 url: format!("https://example.com/{index}"),
962 status: ExtractStatus::Completed,
963 data: Some(json!({"index": index})),
964 error: None,
965 llm_usage: None,
966 basis: None,
967 basis_warnings: Vec::new(),
968 llm_input_hash: None,
969 })
970 .collect(),
971 tokens_used: 30,
972 credits_used: 3,
973 error: None,
974 created_at,
975 expires_at: SystemTime::now() + Duration::from_secs(3_600),
976 claimed_index: None,
977 }
978 }
979
980 fn spawn_final_write(
981 state: AppState,
982 id: Uuid,
983 ) -> (oneshot::Receiver<()>, tokio::task::JoinHandle<()>) {
984 let (ready_tx, ready_rx) = oneshot::channel();
985 let handle = tokio::spawn(async move {
986 let _ = ready_tx.send(());
987 state.finalize_extract_job(id).await;
988 });
989 (ready_rx, handle)
990 }
991
992 fn spawn_delete(
993 state: AppState,
994 id: Uuid,
995 ) -> (oneshot::Receiver<()>, tokio::task::JoinHandle<()>) {
996 let (ready_tx, ready_rx) = oneshot::channel();
997 let handle = tokio::spawn(async move {
998 let _ = ready_tx.send(());
999 state.cancel_extract_job(id).await.unwrap();
1000 });
1001 (ready_rx, handle)
1002 }
1003
1004 async fn run_final_write_delete_race(final_write_first: bool) -> (AppState, Uuid) {
1005 let config: AppConfig = toml::from_str("").unwrap();
1006 let state = AppState::new(config).unwrap();
1007 let id = Uuid::new_v4();
1008 state
1009 .extract_jobs
1010 .write()
1011 .await
1012 .insert(id, completed_record(Instant::now()));
1013
1014 let gate = state.extract_jobs.write().await;
1018 let ((first_ready, first), (second_ready, second)) = if final_write_first {
1019 (
1020 spawn_final_write(state.clone(), id),
1021 spawn_delete(state.clone(), id),
1022 )
1023 } else {
1024 (
1025 spawn_delete(state.clone(), id),
1026 spawn_final_write(state.clone(), id),
1027 )
1028 };
1029 first_ready.await.unwrap();
1030 second_ready.await.unwrap();
1031 drop(gate);
1032 first.await.unwrap();
1033 second.await.unwrap();
1034 (state, id)
1035 }
1036
1037 #[tokio::test]
1038 async fn final_write_versus_delete_race_covers_both_winners_and_freezes_terminal_state() {
1039 for (final_write_first, expected) in [
1044 (true, ExtractStatus::Completed),
1045 (false, ExtractStatus::Completed),
1046 ] {
1047 let (state, id) = run_final_write_delete_race(final_write_first).await;
1048 let terminal = state.get_extract_job(id).await.unwrap();
1049 assert_eq!(terminal.status, expected);
1050 assert_eq!(terminal.per_url.len(), 3);
1051 assert_eq!(
1052 terminal
1053 .per_url
1054 .iter()
1055 .map(|result| result.url.as_str())
1056 .collect::<Vec<_>>(),
1057 [
1058 "https://example.com/1",
1059 "https://example.com/2",
1060 "https://example.com/3"
1061 ]
1062 );
1063 assert!(
1064 terminal
1065 .per_url
1066 .iter()
1067 .all(|result| result.status == ExtractStatus::Completed)
1068 );
1069
1070 let frozen = serde_json::to_value(serialize_extract_status(id, terminal)).unwrap();
1071 state.finalize_extract_job(id).await;
1072 let repeated_delete = state.cancel_extract_job(id).await.unwrap();
1073 let repeated =
1074 serde_json::to_value(serialize_extract_status(id, repeated_delete)).unwrap();
1075 assert_eq!(repeated, frozen, "terminal envelope must be immutable");
1076 }
1077 }
1078
1079 #[tokio::test]
1080 async fn cleanup_removes_expired_nonterminal_extract_jobs() {
1081 let config: AppConfig = toml::from_str("").unwrap();
1082 let state = AppState::new(config).unwrap();
1083 let processing_id = Uuid::new_v4();
1084 let cancelling_id = Uuid::new_v4();
1085 let old = Instant::now() - Duration::from_secs(5);
1086 let mut cancelling = completed_record(old);
1087 cancelling.status = ExtractStatus::Cancelling;
1088 {
1089 let mut jobs = state.extract_jobs.write().await;
1090 jobs.insert(processing_id, completed_record(old));
1091 jobs.insert(cancelling_id, cancelling);
1092 }
1093
1094 assert_eq!(
1095 state
1096 .prune_expired_extract_jobs(Duration::from_secs(1))
1097 .await,
1098 2
1099 );
1100 assert!(state.extract_jobs.read().await.is_empty());
1101 }
1102}