1use 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
24pub 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}