use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use pagesift_store::Store;
use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, Semaphore};
use crate::engine::{CrawlResume, Engine};
use crate::error::{ApiError, ErrorCode};
use crate::limits::{Caller, Limiter};
use crate::metrics::{Metrics, PageSource};
use crate::types::{CrawlRequest, CrawlSummary, Document, JobError, ScrapeOptions};
use crate::webhook::{Event, Hook, WebhookConfig, Webhooks};
mod sink;
mod store;
use sink::JobSink;
use store::{BatchSpec, Meta, delete_job, list_key, meta_key, to_ms};
pub const JOB_TTL: Duration = Duration::from_secs(24 * 3600);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JobState {
Scraping,
Completed,
Cancelled,
Failed,
}
impl JobState {
pub fn as_str(self) -> &'static str {
match self {
JobState::Scraping => "scraping",
JobState::Completed => "completed",
JobState::Cancelled => "cancelled",
JobState::Failed => "failed",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JobKind {
Crawl,
BatchScrape,
}
impl JobKind {
pub fn as_str(self) -> &'static str {
match self {
JobKind::Crawl => "crawl",
JobKind::BatchScrape => "batch_scrape",
}
}
pub(super) fn pages(self) -> PageSource {
match self {
JobKind::Crawl => PageSource::Crawl,
JobKind::BatchScrape => PageSource::Batch,
}
}
}
#[derive(Debug)]
pub struct Job {
pub kind: JobKind,
pub state: JobState,
pub total: usize,
pub completed: usize,
pub data: Vec<Document>,
pub errors: Vec<JobError>,
pub robots_blocked: Vec<String>,
pub created: SystemTime,
pub summary: Option<CrawlSummary>,
pub(super) stored_docs: usize,
pub(super) owner: Option<Caller>,
}
impl Job {
pub(super) fn new(kind: JobKind, total: usize, owner: Option<Caller>) -> Self {
Self {
kind,
state: JobState::Scraping,
total,
completed: 0,
data: Vec::new(),
errors: Vec::new(),
robots_blocked: Vec::new(),
created: SystemTime::now(),
summary: None,
stored_docs: 0,
owner,
}
}
pub fn expires_at(&self) -> SystemTime {
self.created + JOB_TTL
}
fn docs(&self) -> usize {
self.data.len() + self.stored_docs
}
}
pub(crate) struct StatusPage {
pub state: JobState,
pub total: usize,
pub completed: usize,
pub expires_at: SystemTime,
pub docs: usize,
pub data: Vec<Document>,
pub summary: Option<CrawlSummary>,
}
#[derive(Default)]
pub(crate) struct Start {
pub owner: Option<Caller>,
pub webhook: Option<WebhookConfig>,
}
struct Inner {
map: Mutex<HashMap<String, Job>>,
store: Option<Store>,
limiter: Arc<Limiter>,
webhooks: Option<Webhooks>,
metrics: Arc<Metrics>,
}
#[derive(Clone)]
pub struct Jobs {
inner: Arc<Inner>,
}
impl Default for Jobs {
fn default() -> Self {
Self::new(None, Arc::default(), None, Arc::default())
}
}
impl Jobs {
pub(crate) fn new(
store: Option<Store>,
limiter: Arc<Limiter>,
webhooks: Option<Webhooks>,
metrics: Arc<Metrics>,
) -> Self {
Self {
inner: Arc::new(Inner {
map: Mutex::new(HashMap::new()),
store,
limiter,
webhooks,
metrics,
}),
}
}
async fn sweep(&self) {
let now = SystemTime::now();
let expired: Vec<String> = {
let mut map = self.inner.map.lock().await;
let ids: Vec<String> = map
.iter()
.filter(|(_, j)| j.expires_at() <= now)
.map(|(id, _)| id.clone())
.collect();
for id in &ids {
map.remove(id);
}
ids
};
if let (Some(store), false) = (&self.inner.store, expired.is_empty()) {
store
.blocking(move |s| {
for id in expired {
delete_job(s, &id);
}
})
.await;
}
}
pub async fn get<R>(&self, id: &str, f: impl FnOnce(&Job) -> R) -> Option<R> {
self.sweep().await;
self.inner.map.lock().await.get(id).map(f)
}
pub(crate) async fn status(&self, id: &str, skip: usize, limit: usize) -> Option<StatusPage> {
self.sweep().await;
let mut page = {
let map = self.inner.map.lock().await;
let job = map.get(id)?;
let end = (skip + limit).min(job.data.len());
StatusPage {
state: job.state,
total: job.total,
completed: job.completed,
expires_at: job.expires_at(),
docs: job.docs(),
data: job
.data
.get(skip.min(end)..end)
.unwrap_or_default()
.to_vec(),
summary: job.summary.clone(),
}
};
if let Some(store) = &self.inner.store
&& page.data.is_empty()
&& skip < page.docs
{
let key = list_key(id, "pages");
page.data = store
.blocking(move |s| s.tasks().list_range(key, skip as u64, limit))
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "reading job pages failed");
Vec::new()
})
.iter()
.filter_map(|v| serde_json::from_slice(v).ok())
.collect();
}
Some(page)
}
pub(crate) async fn errors(&self, id: &str) -> Option<(Vec<JobError>, Vec<String>)> {
self.sweep().await;
let (mut errors, mut robots) = {
let map = self.inner.map.lock().await;
let job = map.get(id)?;
(job.errors.clone(), job.robots_blocked.clone())
};
if let Some(store) = &self.inner.store {
let key = list_key(id, "errors");
let stored = store
.blocking(move |s| s.tasks().list_items(key, usize::MAX))
.await
.unwrap_or_default();
for v in stored {
if let Ok(e) = serde_json::from_slice::<JobError>(&v) {
if e.code == ErrorCode::RobotsDisallowed.as_str() {
robots.push(e.url.clone());
}
errors.push(e);
}
}
}
Some((errors, robots))
}
pub async fn cancel(&self, id: &str) -> bool {
let found = {
let mut map = self.inner.map.lock().await;
match map.get_mut(id) {
Some(j) => {
if j.state == JobState::Scraping {
j.state = JobState::Cancelled;
}
true
}
None => false,
}
};
if found {
self.save_state(id).await;
}
found
}
pub async fn active(&self) -> Vec<String> {
let map = self.inner.map.lock().await;
map.iter()
.filter(|(_, j)| j.state == JobState::Scraping)
.map(|(id, _)| id.clone())
.collect()
}
pub(crate) async fn counts(&self) -> BTreeMap<(&'static str, &'static str), u64> {
let mut out = BTreeMap::new();
for j in self.inner.map.lock().await.values() {
*out.entry((j.kind.as_str(), j.state.as_str())).or_default() += 1;
}
out
}
async fn register(
&self,
kind: JobKind,
total: usize,
start: Start,
crawl: Option<CrawlRequest>,
batch: Option<BatchSpec>,
) -> Result<(String, Option<Hook>), ApiError> {
let id = uuid::Uuid::new_v4().to_string();
let job = Job::new(kind, total, start.owner.clone());
let meta = Meta {
kind,
state: JobState::Scraping,
created_ms: to_ms(job.created),
total,
completed: 0,
owner: start.owner.as_ref().map(|c| c.account.to_string()),
webhook: start.webhook.clone(),
crawl,
batch,
summary: None,
};
{
let mut map = self.inner.map.lock().await;
if let Some(c) = &start.owner {
if c.limits.max_concurrent_jobs.is_some() {
let active = map
.values()
.filter(|j| {
j.state == JobState::Scraping
&& j.owner.as_ref().is_some_and(|o| o.account == c.account)
})
.count();
self.inner.limiter.check_jobs(c, active)?;
}
self.inner.limiter.check_quota(c)?;
}
map.insert(id.clone(), job);
}
if let Some(store) = &self.inner.store {
let (key, value) = (meta_key(&id), serde_json::to_vec(&meta).unwrap_or_default());
if let Err(e) = store.blocking(move |s| s.tasks().kv_put(key, &value)).await {
tracing::warn!(job = %id, error = %e, "saving job failed; it will not survive a restart");
}
}
self.inner.metrics.job_started(kind.as_str());
tracing::info!(
job = %id,
kind = kind.as_str(),
account = start.owner.as_ref().map(|c| &*c.account),
"job started"
);
let hook = match (&self.inner.webhooks, start.webhook) {
(Some(w), Some(cfg)) => {
let hook = w.start(&id, kind.as_str(), cfg);
hook.send(Event::Started, Vec::new(), None);
Some(hook)
}
_ => None,
};
Ok((id, hook))
}
pub async fn start_batch(
&self,
engine: Arc<dyn Engine>,
urls: Vec<String>,
opts: ScrapeOptions,
concurrency: usize,
) -> String {
self.start_batch_with(engine, urls, opts, concurrency, Start::default())
.await
.expect("no limits without a caller")
}
pub(crate) async fn start_batch_with(
&self,
engine: Arc<dyn Engine>,
urls: Vec<String>,
opts: ScrapeOptions,
concurrency: usize,
start: Start,
) -> Result<String, ApiError> {
let spec = BatchSpec {
urls,
options: opts,
concurrency,
};
let total = spec.urls.len();
let (id, hook) = self
.register(
JobKind::BatchScrape,
total,
start,
None,
self.inner.store.is_some().then(|| spec.clone()),
)
.await?;
let pending = spec.urls.into_iter().enumerate().collect();
self.spawn_batch(id.clone(), engine, pending, spec.options, concurrency, hook);
Ok(id)
}
fn sink(
&self,
id: &str,
kind: JobKind,
hook: Option<Hook>,
resume: Option<CrawlResume>,
) -> Arc<JobSink> {
Arc::new(JobSink {
jobs: self.clone(),
id: id.to_string(),
kind,
hook,
resume: StdMutex::new(resume),
quota_hit: AtomicBool::new(false),
})
}
pub(super) fn spawn_batch(
&self,
id: String,
engine: Arc<dyn Engine>,
pending: Vec<(usize, String)>,
opts: ScrapeOptions,
concurrency: usize,
hook: Option<Hook>,
) {
let sink = self.sink(&id, JobKind::BatchScrape, hook, None);
let opts = Arc::new(opts);
tokio::spawn(async move {
let sem = Arc::new(Semaphore::new(concurrency.max(1)));
let mut tasks = tokio::task::JoinSet::new();
for (i, url) in pending {
let (sink, engine, opts, sem) =
(sink.clone(), engine.clone(), opts.clone(), sem.clone());
tasks.spawn(async move {
let _permit = sem.acquire_owned().await.expect("semaphore open");
if sink.stopped().await {
return;
}
let done = i.to_string();
match engine.scrape(&url, &opts).await {
Ok(doc) => sink.record_page(done, doc).await,
Err(e) => sink.record_error(done, url, e).await,
}
});
}
while tasks.join_next().await.is_some() {}
sink.finish(Ok(())).await;
});
}
pub async fn start_crawl(&self, engine: Arc<dyn Engine>, req: CrawlRequest) -> String {
self.start_crawl_with(engine, req, Start::default())
.await
.expect("no limits without a caller")
}
pub(crate) async fn start_crawl_with(
&self,
engine: Arc<dyn Engine>,
req: CrawlRequest,
start: Start,
) -> Result<String, ApiError> {
let (id, hook) = self
.register(
JobKind::Crawl,
0,
start,
self.inner.store.is_some().then(|| req.clone()),
None,
)
.await?;
self.spawn_crawl(id.clone(), engine, req, hook, None);
Ok(id)
}
pub(super) fn spawn_crawl(
&self,
id: String,
engine: Arc<dyn Engine>,
req: CrawlRequest,
hook: Option<Hook>,
resume: Option<CrawlResume>,
) {
let sink = self.sink(&id, JobKind::Crawl, hook, resume);
tokio::spawn(async move {
let result = engine.crawl(&req, sink.clone()).await;
sink.finish(result.map_err(|e| (req.url.clone(), e))).await;
});
}
}
pub fn rfc3339(t: SystemTime) -> String {
let d = t.duration_since(UNIX_EPOCH).unwrap_or_default();
let secs = d.as_secs() as i64;
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z",
rem / 3600,
rem % 3600 / 60,
rem % 60,
d.subsec_millis()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_utc_dates() {
assert_eq!(rfc3339(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
let t = UNIX_EPOCH + Duration::from_millis(1_792_540_800_123);
assert_eq!(rfc3339(t), "2026-10-21T00:00:00.123Z");
let leap = UNIX_EPOCH + Duration::from_secs(951_782_400); assert_eq!(rfc3339(leap), "2000-02-29T00:00:00.000Z");
}
}