Skip to main content

millipede_storage_fs/
queue.rs

1//! Durable file-system request queue.
2
3use crate::layout::{is_temporary_file, temporary_suffix};
4use millipede_core::{
5    request::{Method, Request, RequestBody, RequestId, RequestState, UserData},
6    storage::{
7        AddOptions, BatchAddHandle, Lease, LeaseId, ProcessedRequest, QueueOpInfo, ReclaimOptions,
8        RequestQueue, RequestSource, StorageError, StorageResult,
9    },
10};
11use serde::{Deserialize, Serialize};
12use std::{
13    collections::{BTreeMap, HashMap},
14    fmt,
15    path::{Path, PathBuf},
16    sync::Arc,
17    time::{Duration, Instant},
18};
19use tokio::sync::{Mutex, RwLock};
20
21const LEASE_TTL: Duration = Duration::from_secs(180);
22const STATE_VERSION: u8 = 1;
23
24/// A durable FIFO request queue backed by atomic file replacements.
25///
26/// Request files, rather than `state.json`, are the source of truth. Leases are
27/// deliberately process-local, so requests leased by a process that exits are
28/// pending again when a new client rescans the queue. Writes use a temporary
29/// file and same-directory rename, but do not call `fsync`; a machine-level
30/// power loss can therefore lose writes that the operating system had not yet
31/// flushed.
32pub struct FsRequestQueue {
33    name: String,
34    path: PathBuf,
35    requests_path: PathBuf,
36    operations: Arc<RwLock<()>>,
37    state: Mutex<QueueState>,
38}
39
40struct QueueState {
41    pending: BTreeMap<i64, RequestId>,
42    dedup: HashMap<String, (RequestId, bool)>,
43    leases: HashMap<LeaseId, (RequestId, Instant)>,
44    requests: HashMap<RequestId, StoredRequest>,
45    handled_count: u64,
46    next_order: i64,
47    next_forefront_order: i64,
48    next_lease_id: u64,
49}
50
51struct StoredRequest {
52    request: Request,
53    order_no: Option<i64>,
54}
55
56#[derive(Serialize)]
57struct RequestEnvelope {
58    id: String,
59    url: String,
60    #[serde(rename = "uniqueKey")]
61    unique_key: String,
62    method: String,
63    #[serde(rename = "retryCount")]
64    retry_count: u32,
65    #[serde(rename = "orderNo")]
66    order_no: Option<i64>,
67    json: Request,
68}
69
70#[derive(Deserialize)]
71struct ReadEnvelope {
72    #[serde(rename = "orderNo")]
73    order_no: Option<i64>,
74    json: Request,
75}
76
77#[derive(Deserialize)]
78struct CrawleeRequestEnvelope {
79    id: RequestId,
80    url: String,
81    #[serde(rename = "uniqueKey")]
82    unique_key: String,
83    #[serde(default = "default_method")]
84    method: String,
85    #[serde(default)]
86    headers: HashMap<String, String>,
87    #[serde(default)]
88    payload: Option<String>,
89    #[serde(default, rename = "userData")]
90    user_data: UserData,
91    #[serde(default, rename = "retryCount")]
92    retry_count: u32,
93    #[serde(default, rename = "sessionRotationCount")]
94    session_rotation_count: u32,
95    #[serde(default, rename = "maxRetries")]
96    max_retries: Option<u32>,
97    #[serde(default, rename = "noRetry")]
98    no_retry: bool,
99    #[serde(default, rename = "errorMessages")]
100    error_messages: Vec<String>,
101    #[serde(default, rename = "loadedUrl")]
102    loaded_url: Option<String>,
103    #[serde(default, rename = "handledAt", with = "time::serde::rfc3339::option")]
104    handled_at: Option<time::OffsetDateTime>,
105    #[serde(default, rename = "crawlDepth")]
106    crawl_depth: u32,
107    #[serde(default, rename = "skipNavigation")]
108    skip_navigation: bool,
109    #[serde(rename = "orderNo")]
110    order_no: Option<i64>,
111}
112
113fn default_method() -> String {
114    "GET".to_owned()
115}
116
117impl CrawleeRequestEnvelope {
118    fn into_request(self) -> Result<(Request, Option<i64>), serde_json::Error> {
119        let method = Method::from_bytes(self.method.as_bytes()).map_err(json_error)?;
120        let mut builder = Request::builder()
121            .url(self.url)
122            .method(method)
123            .unique_key(self.unique_key.clone())
124            .user_data(self.user_data.clone())
125            .crawl_depth(self.crawl_depth)
126            .no_retry(self.no_retry)
127            .skip_navigation(self.skip_navigation);
128        if let Some(payload) = self.payload {
129            builder = builder.body(RequestBody::Bytes(payload.into_bytes()));
130        }
131        if let Some(max_retries) = self.max_retries {
132            builder = builder.max_retries(max_retries);
133        }
134        for (name, value) in self.headers {
135            builder = builder.header(&name, &value);
136        }
137        if let Some(label) = self.user_data.get("label").and_then(|value| value.as_str()) {
138            builder = builder.label(label.to_owned());
139        }
140        let mut request = builder.build().map_err(json_error)?;
141        request.id = self.id;
142        request.unique_key = self.unique_key;
143        request.retry_count = self.retry_count;
144        request.session_rotation_count = self.session_rotation_count;
145        request.error_messages = self.error_messages;
146        request.handled_at = self.handled_at;
147        request.state = if self.order_no.is_some() {
148            RequestState::Unprocessed
149        } else {
150            RequestState::Done
151        };
152        request.loaded_url = self
153            .loaded_url
154            .map(|url| url.parse().map_err(json_error))
155            .transpose()?;
156        Ok((request, self.order_no))
157    }
158}
159
160fn json_error(error: impl fmt::Display) -> serde_json::Error {
161    <serde_json::Error as serde::de::Error>::custom(error)
162}
163
164#[derive(Serialize, Deserialize)]
165struct QueueCache {
166    version: u8,
167    #[serde(rename = "handledRequestCount")]
168    handled_request_count: u64,
169    #[serde(rename = "pendingRequestCount")]
170    pending_request_count: u64,
171    next_order: i64,
172    next_forefront_order: i64,
173}
174
175impl fmt::Debug for FsRequestQueue {
176    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
177        formatter
178            .debug_struct("FsRequestQueue")
179            .field("name", &self.name)
180            .field("path", &self.path)
181            .finish_non_exhaustive()
182    }
183}
184
185impl FsRequestQueue {
186    pub(crate) async fn open(
187        name: String,
188        path: PathBuf,
189        operations: Arc<RwLock<()>>,
190    ) -> StorageResult<Self> {
191        let requests_path = path.join("requests");
192        tokio::fs::create_dir_all(&requests_path).await?;
193        let state = scan_queue(&name, &path, &requests_path).await?;
194        let queue = Self {
195            name,
196            path,
197            requests_path,
198            operations,
199            state: Mutex::new(state),
200        };
201        {
202            let state = queue.state.lock().await;
203            queue.write_cache_best_effort(&state).await;
204        }
205        Ok(queue)
206    }
207
208    pub(crate) async fn ensure_layout(&self) -> StorageResult<()> {
209        tokio::fs::create_dir_all(&self.requests_path).await?;
210        Ok(())
211    }
212
213    pub(crate) async fn reset(&self) {
214        let mut state = self.state.lock().await;
215        *state = QueueState::empty();
216    }
217
218    async fn write_request(&self, request: &Request, order_no: Option<i64>) -> StorageResult<()> {
219        tokio::fs::create_dir_all(&self.requests_path).await?;
220        let envelope = RequestEnvelope {
221            id: request.id.to_string(),
222            url: request.url.to_string(),
223            unique_key: request.unique_key.clone(),
224            method: request.method.as_str().to_owned(),
225            retry_count: request.retry_count,
226            order_no,
227            json: request.clone(),
228        };
229        let destination = self
230            .requests_path
231            .join(format!("{}.json", request.id.as_str()));
232        atomic_write(&destination, &serde_json::to_vec_pretty(&envelope)?).await
233    }
234
235    async fn write_cache_best_effort(&self, state: &QueueState) {
236        let cache = QueueCache {
237            version: STATE_VERSION,
238            handled_request_count: state.handled_count,
239            pending_request_count: state
240                .requests
241                .values()
242                .filter(|request| request.order_no.is_some())
243                .count() as u64,
244            next_order: state.next_order,
245            next_forefront_order: state.next_forefront_order,
246        };
247        let result = match serde_json::to_vec_pretty(&cache) {
248            Ok(bytes) => atomic_write(&self.path.join("state.json"), &bytes).await,
249            Err(error) => Err(error.into()),
250        };
251        if let Err(error) = result {
252            tracing::warn!(
253                queue = %self.name,
254                %error,
255                "failed to refresh request queue state cache"
256            );
257        }
258    }
259
260    fn lease_request_id(state: &QueueState, lease_id: &LeaseId) -> StorageResult<RequestId> {
261        state
262            .leases
263            .get(lease_id)
264            .map(|(request_id, _)| request_id.clone())
265            .ok_or_else(|| StorageError::LeaseNotFound {
266                lease_id: lease_id.clone(),
267            })
268    }
269
270    fn validate_unique_key(
271        state: &QueueState,
272        request_id: &RequestId,
273        request: &Request,
274    ) -> StorageResult<()> {
275        if let Some((known_id, _)) = state.dedup.get(&request.unique_key) {
276            if known_id != request_id {
277                return Err(StorageError::Backend(anyhow::anyhow!(
278                    "request lease changed unique_key to an existing queue key"
279                )));
280            }
281        }
282        Ok(())
283    }
284
285    fn replace_stored_request(
286        state: &mut QueueState,
287        request_id: &RequestId,
288        request: Request,
289        order_no: Option<i64>,
290        handled: bool,
291    ) {
292        if let Some(previous) = state.requests.get(request_id) {
293            if previous.request.unique_key != request.unique_key {
294                state.dedup.remove(&previous.request.unique_key);
295            }
296        }
297        state
298            .dedup
299            .insert(request.unique_key.clone(), (request_id.clone(), handled));
300        state
301            .requests
302            .insert(request_id.clone(), StoredRequest { request, order_no });
303    }
304
305    fn reclaim_expired(state: &mut QueueState) {
306        let now = Instant::now();
307        let expired: Vec<_> = state
308            .leases
309            .iter()
310            .filter(|(_, (_, expires_at))| *expires_at <= now)
311            .map(|(lease_id, _)| lease_id.clone())
312            .collect();
313        for lease_id in expired {
314            if let Some((request_id, _)) = state.leases.remove(&lease_id) {
315                if let Some(order_no) = state
316                    .requests
317                    .get(&request_id)
318                    .and_then(|request| request.order_no)
319                {
320                    state.pending.insert(order_no, request_id);
321                }
322            }
323        }
324    }
325
326    async fn add_locked(
327        &self,
328        state: &mut QueueState,
329        request: Request,
330        opts: &AddOptions,
331    ) -> StorageResult<QueueOpInfo> {
332        let unique_key = request.unique_key.clone();
333        if let Some((request_id, handled)) = state.dedup.get(&unique_key) {
334            return Ok(ProcessedRequest {
335                request_id: request_id.clone(),
336                unique_key,
337                was_already_present: true,
338                was_already_handled: *handled,
339            });
340        }
341
342        let request_id = request.id.clone();
343        let order_no = state.take_order(opts.forefront)?;
344        self.write_request(&request, Some(order_no)).await?;
345        state.pending.insert(order_no, request_id.clone());
346        state
347            .dedup
348            .insert(unique_key.clone(), (request_id.clone(), false));
349        state.requests.insert(
350            request_id.clone(),
351            StoredRequest {
352                request,
353                order_no: Some(order_no),
354            },
355        );
356        self.write_cache_best_effort(state).await;
357        Ok(ProcessedRequest {
358            request_id,
359            unique_key,
360            was_already_present: false,
361            was_already_handled: false,
362        })
363    }
364
365    async fn requeue(
366        &self,
367        lease: Lease,
368        forefront: bool,
369        increment_retry: bool,
370    ) -> StorageResult<()> {
371        let _operation = self.operations.read().await;
372        let mut state = self.state.lock().await;
373        let request_id = Self::lease_request_id(&state, &lease.lease_id)?;
374        let mut request = lease.request;
375        Self::validate_unique_key(&state, &request_id, &request)?;
376        if increment_retry {
377            request.retry_count += 1;
378        }
379        let order_no = state.take_order(forefront)?;
380        self.write_request(&request, Some(order_no)).await?;
381        state.leases.remove(&lease.lease_id);
382        state.pending.insert(order_no, request_id.clone());
383        Self::replace_stored_request(&mut state, &request_id, request, Some(order_no), false);
384        self.write_cache_best_effort(&state).await;
385        Ok(())
386    }
387}
388
389impl QueueState {
390    fn empty() -> Self {
391        Self {
392            pending: BTreeMap::new(),
393            dedup: HashMap::new(),
394            leases: HashMap::new(),
395            requests: HashMap::new(),
396            handled_count: 0,
397            next_order: 1,
398            next_forefront_order: -1,
399            next_lease_id: 0,
400        }
401    }
402
403    fn take_order(&mut self, forefront: bool) -> StorageResult<i64> {
404        if forefront {
405            let order_no = self.next_forefront_order;
406            self.next_forefront_order = order_no.checked_sub(1).ok_or_else(|| {
407                StorageError::Backend(anyhow::anyhow!("request queue forefront order exhausted"))
408            })?;
409            Ok(order_no)
410        } else {
411            let order_no = self.next_order;
412            self.next_order = order_no.checked_add(1).ok_or_else(|| {
413                StorageError::Backend(anyhow::anyhow!("request queue order exhausted"))
414            })?;
415            Ok(order_no)
416        }
417    }
418}
419
420#[async_trait::async_trait]
421impl RequestQueue for FsRequestQueue {
422    async fn add(&self, request: Request, opts: AddOptions) -> StorageResult<QueueOpInfo> {
423        let _operation = self.operations.read().await;
424        let mut state = self.state.lock().await;
425        self.add_locked(&mut state, request, &opts).await
426    }
427
428    async fn add_batch(
429        &self,
430        requests: Vec<RequestSource>,
431        opts: AddOptions,
432    ) -> StorageResult<BatchAddHandle> {
433        let _operation = self.operations.read().await;
434        let mut state = self.state.lock().await;
435        let mut processed = Vec::with_capacity(requests.len());
436        for source in requests {
437            let request = match source {
438                RequestSource::Request(request) => request,
439                _ => {
440                    return Err(StorageError::Backend(anyhow::anyhow!(
441                        "unsupported request source"
442                    )));
443                }
444            };
445            processed.push(self.add_locked(&mut state, request, &opts).await?);
446        }
447        Ok(BatchAddHandle::ready(processed))
448    }
449
450    async fn fetch_next(&self) -> StorageResult<Option<Lease>> {
451        let _operation = self.operations.read().await;
452        let mut state = self.state.lock().await;
453        Self::reclaim_expired(&mut state);
454        let Some((_, request_id)) = state.pending.pop_first() else {
455            return Ok(None);
456        };
457        let request = state
458            .requests
459            .get(&request_id)
460            .expect("pending request has a stored request")
461            .request
462            .clone();
463        let raw_lease_id = state.next_lease_id;
464        state.next_lease_id = raw_lease_id.checked_add(1).ok_or_else(|| {
465            StorageError::Backend(anyhow::anyhow!("request queue lease identifiers exhausted"))
466        })?;
467        let lease_id = LeaseId::new(raw_lease_id);
468        let expires_at = Instant::now() + LEASE_TTL;
469        state
470            .leases
471            .insert(lease_id.clone(), (request_id, expires_at));
472        Ok(Some(Lease {
473            request,
474            lease_id,
475            expires_at,
476        }))
477    }
478
479    async fn mark_handled(&self, lease: Lease) -> StorageResult<()> {
480        let _operation = self.operations.read().await;
481        let mut state = self.state.lock().await;
482        let request_id = Self::lease_request_id(&state, &lease.lease_id)?;
483        Self::validate_unique_key(&state, &request_id, &lease.request)?;
484        self.write_request(&lease.request, None).await?;
485        state.leases.remove(&lease.lease_id);
486        state.handled_count = state.handled_count.saturating_add(1);
487        Self::replace_stored_request(&mut state, &request_id, lease.request, None, true);
488        self.write_cache_best_effort(&state).await;
489        Ok(())
490    }
491
492    async fn reclaim(&self, lease: Lease, opts: ReclaimOptions) -> StorageResult<()> {
493        self.requeue(lease, opts.forefront, opts.increment_retry)
494            .await
495    }
496
497    async fn renew(&self, lease_id: &LeaseId, extend_by: Duration) -> StorageResult<()> {
498        let _operation = self.operations.read().await;
499        let mut state = self.state.lock().await;
500        let expired = state
501            .leases
502            .get(lease_id)
503            .is_some_and(|(_, expires_at)| *expires_at <= Instant::now());
504        if expired {
505            if let Some((request_id, _)) = state.leases.remove(lease_id) {
506                if let Some(order_no) = state
507                    .requests
508                    .get(&request_id)
509                    .and_then(|request| request.order_no)
510                {
511                    state.pending.insert(order_no, request_id);
512                }
513            }
514            return Err(StorageError::LeaseNotFound {
515                lease_id: lease_id.clone(),
516            });
517        }
518        let (_, expires_at) =
519            state
520                .leases
521                .get_mut(lease_id)
522                .ok_or_else(|| StorageError::LeaseNotFound {
523                    lease_id: lease_id.clone(),
524                })?;
525        *expires_at += extend_by;
526        Ok(())
527    }
528
529    async fn abandon(&self, lease: Lease) -> StorageResult<()> {
530        self.requeue(lease, true, false).await
531    }
532
533    async fn is_empty(&self) -> StorageResult<bool> {
534        let _operation = self.operations.read().await;
535        Ok(self.state.lock().await.pending.is_empty())
536    }
537
538    async fn is_finished(&self) -> StorageResult<bool> {
539        let _operation = self.operations.read().await;
540        let state = self.state.lock().await;
541        Ok(state.pending.is_empty() && state.leases.is_empty())
542    }
543
544    async fn handled_count(&self) -> StorageResult<u64> {
545        let _operation = self.operations.read().await;
546        Ok(self.state.lock().await.handled_count)
547    }
548
549    async fn pending_count(&self) -> StorageResult<u64> {
550        let _operation = self.operations.read().await;
551        Ok(self.state.lock().await.pending.len() as u64)
552    }
553}
554
555async fn scan_queue(name: &str, path: &Path, requests_path: &Path) -> StorageResult<QueueState> {
556    let mut entries = tokio::fs::read_dir(requests_path).await?;
557    let mut files = Vec::new();
558    while let Some(entry) = entries.next_entry().await? {
559        if !entry.file_type().await?.is_file() {
560            continue;
561        }
562        let file_name = entry.file_name();
563        let Some(file_name) = file_name.to_str() else {
564            continue;
565        };
566        if is_temporary_file(file_name) || !file_name.ends_with(".json") {
567            continue;
568        }
569        files.push(entry.path());
570    }
571    files.sort_unstable();
572
573    let mut decoded = Vec::with_capacity(files.len());
574    for file in files {
575        let bytes = tokio::fs::read(&file).await?;
576        match decode_request(&bytes) {
577            Ok(request) => decoded.push(request),
578            Err(error) => {
579                tracing::warn!(
580                    queue = name,
581                    path = %file.display(),
582                    %error,
583                    "skipping unreadable request queue item"
584                );
585            }
586        }
587    }
588
589    let max_normal = decoded
590        .iter()
591        .filter_map(|(_, order_no, _)| order_no.filter(|order_no| *order_no > 0))
592        .max()
593        .unwrap_or(0);
594    let min_forefront = decoded
595        .iter()
596        .filter_map(|(_, order_no, _)| order_no.filter(|order_no| *order_no < 0))
597        .min()
598        .unwrap_or(0);
599    let mut state = QueueState::empty();
600    state.next_order = max_normal.saturating_add(1);
601    state.next_forefront_order = if min_forefront < 0 {
602        min_forefront.saturating_sub(1)
603    } else {
604        -1
605    };
606
607    for (request, disk_order_no, bare) in decoded {
608        let request_id = request.id.clone();
609        let order_no = if bare {
610            Some(state.take_order(false)?)
611        } else if let Some(order_no) = disk_order_no {
612            if state.pending.contains_key(&order_no) {
613                Some(state.take_order(false)?)
614            } else {
615                Some(order_no)
616            }
617        } else {
618            None
619        };
620        let handled = order_no.is_none();
621        if let Some(order_no) = order_no {
622            state.pending.insert(order_no, request_id.clone());
623        } else {
624            state.handled_count = state.handled_count.saturating_add(1);
625        }
626        state
627            .dedup
628            .insert(request.unique_key.clone(), (request_id.clone(), handled));
629        state
630            .requests
631            .insert(request_id, StoredRequest { request, order_no });
632    }
633
634    compare_cache(name, path, &state).await;
635    Ok(state)
636}
637
638fn decode_request(bytes: &[u8]) -> Result<(Request, Option<i64>, bool), serde_json::Error> {
639    let value: serde_json::Value = serde_json::from_slice(bytes)?;
640    if value.get("json").is_some() {
641        let envelope: ReadEnvelope = serde_json::from_value(value)?;
642        Ok((envelope.json, envelope.order_no, false))
643    } else if value.get("uniqueKey").is_some() || value.get("orderNo").is_some() {
644        let envelope: CrawleeRequestEnvelope = serde_json::from_value(value)?;
645        let (request, order_no) = envelope.into_request()?;
646        Ok((request, order_no, false))
647    } else {
648        let request = serde_json::from_value(value)?;
649        Ok((request, None, true))
650    }
651}
652
653async fn compare_cache(name: &str, path: &Path, state: &QueueState) {
654    let bytes = match tokio::fs::read(path.join("state.json")).await {
655        Ok(bytes) => bytes,
656        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
657        Err(error) => {
658            tracing::warn!(queue = name, %error, "failed to read request queue state cache");
659            return;
660        }
661    };
662    let cache: QueueCache = match serde_json::from_slice::<QueueCache>(&bytes) {
663        Ok(cache) if cache.version == STATE_VERSION => cache,
664        Ok(_) => {
665            tracing::warn!(
666                queue = name,
667                "ignoring unsupported request queue state cache"
668            );
669            return;
670        }
671        Err(error) => {
672            tracing::warn!(queue = name, %error, "ignoring unreadable request queue state cache");
673            return;
674        }
675    };
676    let pending_count = state
677        .requests
678        .values()
679        .filter(|request| request.order_no.is_some())
680        .count() as u64;
681    if cache.handled_request_count != state.handled_count
682        || cache.pending_request_count != pending_count
683    {
684        tracing::warn!(
685            queue = name,
686            cached_handled = cache.handled_request_count,
687            scanned_handled = state.handled_count,
688            cached_pending = cache.pending_request_count,
689            scanned_pending = pending_count,
690            "request queue state cache disagrees with request files"
691        );
692    }
693}
694
695async fn atomic_write(destination: &Path, bytes: &[u8]) -> StorageResult<()> {
696    let file_name = destination
697        .file_name()
698        .and_then(|name| name.to_str())
699        .ok_or_else(|| StorageError::Backend(anyhow::anyhow!("invalid queue file path")))?;
700    let temporary = destination.with_file_name(format!("{file_name}.{}", temporary_suffix()));
701    tokio::fs::write(&temporary, bytes).await?;
702    if let Err(error) = tokio::fs::rename(&temporary, destination).await {
703        let _ = tokio::fs::remove_file(&temporary).await;
704        return Err(error.into());
705    }
706    Ok(())
707}