Skip to main content

crw_server/
state.rs

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
19/// Validate that a request's pinned renderer is available before accepting
20/// the job. Returns `InvalidRequest` (→ HTTP 400) when the named renderer is
21/// not in the configured pool. Skipped when `renderJs:false` is set, since
22/// HTTP-only ignores the pin.
23///
24/// We surface this explicitly (rather than silently falling back to "auto")
25/// so users get clear feedback when they ask for a renderer the operator
26/// hasn't configured. Sites that fail under one renderer often need a
27/// specific other one — silent fallback would leave callers wondering why
28/// "chrome" gave them the same broken result as "auto".
29pub(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    // Mirror the fetch-path resolution at `crw-crawl/src/single.rs:41-50` so
39    // validation is consistent with what the actual request does. "Pinned
40    // implies JS" — when a renderer is pinned and the request omits
41    // `renderJs`, force the request to JS=true so a `render_js_default=false`
42    // server config doesn't silently send the request through HTTP-only.
43    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
67/// Crawl-specific wrapper around [`validate_renderer_pin`].
68pub(crate) fn validate_crawl_renderer(req: &CrawlRequest, state: &AppState) -> CrwResult<()> {
69    validate_renderer_pin(req.renderer, req.render_js, state)
70}
71
72/// Tracks a crawl job receiver + creation time for TTL cleanup.
73pub struct CrawlJob {
74    pub rx: watch::Receiver<CrawlState>,
75    /// Sender kept alongside the receiver so cancel handlers can flip the
76    /// job to a terminal `Cancelled` state after aborting the task.
77    pub tx: watch::Sender<CrawlState>,
78    pub created_at: Instant,
79    /// Handle to abort the crawl task.
80    pub abort_handle: Option<tokio::task::AbortHandle>,
81}
82
83/// RAII guard that inc/decrements the `crw_batch_pipelines_inflight` gauge for
84/// the lifetime of one in-flight batch URL-pipeline.
85struct 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
98/// Maximum number of concurrent crawl jobs.
99const MAX_CONCURRENT_CRAWLS: usize = 10;
100/// Interval between expired crawl job cleanup runs.
101const JOB_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
102
103/// Canonical lifecycle of an async extract job.
104#[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/// One URL's extraction outcome. Powers the native `/v1/extract` per-URL array
133/// contract (`results:[{url,status,data,error,llmUsage}]`), which sidesteps the
134/// FC-legacy last-write-wins merge. `llm_usage` lets the SaaS settle real cost.
135///
136/// The `basis*` / `llm_input_hash` fields carry per-field evidence and are
137/// populated only when the request set `basis: true`. They stay per-URL on
138/// purpose: a citation is only meaningful next to the document it came from, so
139/// merging them into the FC-legacy flattened `data` object would destroy the
140/// attribution.
141#[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/// A URL prepared by the handler for the worker, in original request order.
154/// `preflight_error: Some(..)` marks a parse/SSRF failure that must surface as a
155/// `failed` result without being fetched (native contract: no silent drops).
156#[derive(Debug, Clone)]
157pub struct PreparedUrl {
158    pub url: String,
159    pub preflight_error: Option<String>,
160}
161
162/// An async extract job record. `data` is the single merged JSON object (the
163/// scrape's `json` field unioned across URLs), preserved for the FC-legacy
164/// `GET /v2/extract/{id}` `data` shape. `per_url` is the native per-URL array
165/// (`GET /v1/extract/{id}`), in original request order.
166#[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    /// Absolute wall-clock expiry captured once at admission. Serializers use
176    /// this persisted value so repeated lifecycle envelopes never drift.
177    pub expires_at: SystemTime,
178    /// The one URL currently dispatched by the sequential worker. Cancellation
179    /// cannot cross its terminal barrier until this slot has persisted.
180    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    /// Complete the cancellation barrier. Call only while holding the extract
189    /// jobs write lock and after observing that no URL remains claimed.
190    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            // A genuine cancel: at least one in-flight URL was actually stopped.
209            self.status = ExtractStatus::Cancelled;
210        } else {
211            // Every URL had already reached a terminal state before the cancel
212            // landed. Reporting "cancelled" would contradict the per-URL results
213            // (all completed/failed with real data), so settle it as the
214            // naturally finished job it actually is.
215            self.complete_from_outcomes();
216        }
217    }
218
219    /// Set the terminal job status from the per-URL outcomes of a job that ran
220    /// to the end: completed if any URL succeeded, otherwise failed, with the
221    /// one-credit floor a naturally finished job carries.
222    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        // Preserve the existing one-credit floor for a naturally finished
240        // all-failed job. Cancelled jobs retain only measured usage.
241        self.credits_used = self.credits_used.max(1);
242    }
243
244    /// Commit the worker's final job-level write. DELETE races this method on
245    /// the same map lock, so exactly one transition can win and terminal state
246    /// is never rewritten by the loser.
247    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/// Shared application state.
260#[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    /// `/v2/extract` jobs. Separate from `crawl_jobs` because an extract result
266    /// is a single merged JSON object, not a `Vec<ScrapeData>`.
267    pub extract_jobs: Arc<RwLock<HashMap<Uuid, ExtractRecord>>>,
268    pub crawl_semaphore: Arc<tokio::sync::Semaphore>,
269    /// Process-wide cap on the total in-flight `/v2/batch/scrape` URL-pipelines
270    /// across all batch-scrape jobs (aggregate bound so `N jobs × width` can't
271    /// explode). Targets batch scrape specifically because that's the only wide
272    /// fan-out: crawl is BFS-sequential and `/v2/extract` scrapes one URL at a
273    /// time, both already bounded by the `crawl_semaphore` job cap. `None` =
274    /// unbounded (config `max_aggregate_batch_pipelines = 0`/absent). Acquired
275    /// as the first op in each batch URL future, before fetch.
276    pub batch_pipeline_sem: Option<Arc<tokio::sync::Semaphore>>,
277    /// SearXNG client. `None` when `[search].searxng_url` is unset, in which
278    /// case `/v1/search` returns a clear `search_disabled` error.
279    pub searxng: Option<Arc<SearxngClient>>,
280    /// Server-wide default /map URL filter. `None` disables the filter
281    /// entirely (legacy behaviour). Per-request overrides may swap or
282    /// extend this at handler time.
283    pub url_filter: Option<Arc<crw_crawl::url_filter::UrlFilterCfg>>,
284}
285
286impl AppState {
287    pub fn new(config: AppConfig) -> CrwResult<Self> {
288        // Build the proxy rotator from config (list takes precedence over the
289        // single `proxy`). When present, it owns ALL proxy routing (HTTP pool +
290        // per-request CDP proxyServer), so `new()` gets `proxy = None` and the
291        // rotator is attached via `with_proxy_rotator`. An invalid proxy URL is
292        // a hard startup error — never a silent direct-connection fallback.
293        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            // Dedicated reqwest client for SearXNG so its connection pool is
317            // hot and isolated from the renderer / scrape paths. SearXNG runs
318            // on the same docker network in the bundled compose so a 5s
319            // connect_timeout is generous.
320            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        // One-shot snapshot of how many rules the filter knows about. Helps
335        // operators confirm at boot that the deny-lists actually loaded.
336        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        // Install the process-wide reserved-lane limits (extract / PDF / LLM)
361        // HERE — inside `AppState::new` — so every entry point that builds an
362        // AppState (the `crw-server` binary AND `crw serve` / embedded CLI) gets
363        // the configured concurrency + reservations, not just the fallbacks.
364        // All three are idempotent (first-call-wins).
365        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        // `0`/absent = unbounded aggregate (no cap); any n>0 bounds total
385        // in-flight batch URL-pipelines process-wide.
386        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        // Wrap the not-yet-returned state in a block to keep the Ok() shape at the end.
403        // Spawn background job cleanup task.
404        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                    // Keep if not done, or if done but within TTL.
417                    !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                // TTL is authoritative for every extract lifecycle state. A
430                // stalled processing/cancelling job must not live forever.
431                cleanup_state.prune_expired_extract_jobs(ttl).await;
432            }
433        });
434
435        Ok(state)
436    }
437
438    /// Start a new crawl job and return its UUID.
439    /// Spawns a background task that acquires the crawl semaphore before running.
440    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            // Crawl pages are `Batch` traffic (same reserved-lane treatment as
496            // batch scrape). Scoped inside the job's spawned task so the
497            // task-local reaches every per-page fetch/extract; a handler-level
498            // scope would be lost across this `tokio::spawn`.
499            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        // Store the abort handle so the job can be cancelled via DELETE.
522        {
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    /// Start a `/v2/batch/scrape` job over an explicit URL list and return its
533    /// UUID. Reuses the crawl-job machinery (`crawl_jobs` + `CrawlState`) but
534    /// scrapes the given URLs directly — no link discovery, no same-origin
535    /// filtering, no dedup; input order is recoverable via `metadata.sourceURL`.
536    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        // Per-job OUTER pipeline width: the SaaS-injected (plan-scaled)
571        // `maxConcurrency`, or `max_concurrency` when absent. BOTH paths are
572        // clamped to `[1, max_batch_concurrency]` so a batch job never exceeds
573        // the ceiling regardless of source (wire value never trusted).
574        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            // Stamp every URL in this job as `Batch` traffic. The scope wraps the
625            // whole `for_each_concurrent` stream; that combinator polls its
626            // futures cooperatively within THIS task (no `tokio::spawn` per URL),
627            // so the task-local propagates to each per-URL `scrape_url` and on to
628            // the reserved lanes it reads. Scoped here (inside the job's spawned
629            // task), not at the handler, because the task-local would be lost
630            // across this job's `tokio::spawn`.
631            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                                // Aggregate cap: acquire a process-wide pipeline
642                                // permit BEFORE fetching so `N jobs × width` can't
643                                // explode. `None` = unbounded. Held for this URL's
644                                // whole lifetime.
645                                let _pipeline_permit = match &batch_pipeline_sem {
646                                    Some(sem) => sem.acquire().await.ok(),
647                                    None => None,
648                                };
649                                // In-flight batch-pipeline gauge (RAII inc/dec).
650                                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                                // Mutate the shared status in place — push one document
665                                // and bump the counter without cloning the whole
666                                // accumulated Vec on every completion (avoids O(n^2)
667                                // copying on large batches). A failed scrape still
668                                // advances `completed`.
669                                tx.send_modify(|st| {
670                                    if let Some(d) = scraped {
671                                        st.data.push(d);
672                                    }
673                                    st.completed += 1;
674                                    // Only flip to Completed from InProgress — never
675                                    // overwrite a terminal Cancelled set by DELETE.
676                                    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    /// Start an async extract job. Each entry is scraped with `formats:[json]` +
699    /// the shared template; per-URL `json` objects are both (a) merged into one
700    /// object for the FC-legacy `data` shape and (b) kept as an ordered per-URL
701    /// array for the native `/v1/extract` contract. `entries` is in original
702    /// request order and may include preflight-failed URLs (surfaced as `failed`
703    /// results without being fetched).
704    pub async fn start_extract_job(
705        &self,
706        entries: Vec<PreparedUrl>,
707        template: ScrapeRequest,
708    ) -> Uuid {
709        let id = Uuid::new_v4();
710        // Seed the fixed-cardinality result array before the worker can run.
711        // Preflight failures are already final; valid URLs remain processing
712        // until claimed and persisted, or are converted to cancelled at the
713        // cancellation barrier.
714        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            // `/v2/extract` is a multi-URL background job — `Batch` traffic, so its
760            // scrapes use the batch lanes and don't consume the interactive reserve.
761            // Scoped inside the spawned task (a handler-level scope is lost across
762            // `tokio::spawn`).
763            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                        // Preflight failures were persisted at admission and
774                        // never count as dispatched work.
775                        if entry.preflight_error.is_some() {
776                            continue;
777                        }
778
779                        // Claim exactly one slot while holding the same state
780                        // lock DELETE uses. Once cancelling is visible no new
781                        // URL can cross this point.
782                        {
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                        // Persist the completed claimed slot and cumulative
864                        // measured usage before dispatching anything else.
865                        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    /// Request cancellation and return the persisted canonical state. Repeated
899    /// calls are idempotent; terminal jobs are never rewritten.
900    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    /// TTL-aware canonical lookup shared by v1, v2, and MCP handlers. A write
920    /// lock makes expiry observation and removal one atomic operation.
921    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        // Hold the exact lock used by both operations, then enqueue them in a
1015        // known order. Tokio's fair write lock releases to the first waiter,
1016        // making both legal race winners deterministic rather than probabilistic.
1017        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        // Whichever of the final write or the DELETE wins, a job whose URLs all
1040        // completed settles as Completed: a late cancel that stopped nothing
1041        // in-flight must not relabel a finished job (the per-URL results below
1042        // are all Completed in both branches).
1043        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}