Skip to main content

millipede_storage_fs/
dataset.rs

1use futures_util::{StreamExt, stream, stream::BoxStream};
2use millipede_core::storage::{
3    Dataset, DatasetInfo, ListOptions, Page, StorageError, StorageResult,
4};
5use serde_json::Value;
6use std::{collections::BTreeSet, path::Path, path::PathBuf, sync::Arc, time::SystemTime};
7use time::OffsetDateTime;
8use tokio::sync::{Mutex, RwLock};
9
10use crate::layout::{is_temporary_file, temporary_suffix};
11
12const MAX_SEQUENCE: u64 = 999_999_999;
13
14/// A file-system-backed, append-only JSON dataset.
15pub struct FsDataset {
16    name: String,
17    path: PathBuf,
18    operations: Arc<RwLock<()>>,
19    next_sequence: Mutex<u64>,
20}
21
22impl FsDataset {
23    pub(crate) async fn open(
24        name: String,
25        path: PathBuf,
26        operations: Arc<RwLock<()>>,
27    ) -> StorageResult<Self> {
28        let next_sequence = max_sequence(&path).await?.saturating_add(1).max(1);
29        Ok(Self {
30            name,
31            path,
32            operations,
33            next_sequence: Mutex::new(next_sequence),
34        })
35    }
36
37    pub(crate) async fn reset_sequence(&self) {
38        *self.next_sequence.lock().await = 1;
39    }
40
41    async fn read_items(&self) -> StorageResult<Vec<Value>> {
42        tokio::fs::create_dir_all(&self.path).await?;
43        let files = item_files(&self.path).await?;
44        let mut items = Vec::with_capacity(files.len());
45        for (_, path) in files {
46            let bytes = tokio::fs::read(&path).await?;
47            match serde_json::from_slice(&bytes) {
48                Ok(item) => items.push(item),
49                Err(error) => {
50                    // Atomic appends prevent this backend from exposing partial writes. Skip
51                    // corrupt files as well because Crawlee-compatible directories may have
52                    // been created by another process or left over from an older crash.
53                    tracing::warn!(
54                        dataset = %self.name,
55                        path = %path.display(),
56                        %error,
57                        "skipping corrupt dataset item"
58                    );
59                }
60            }
61        }
62        Ok(items)
63    }
64
65    async fn append_locked(&self, next: &mut u64, item: &Value) -> StorageResult<()> {
66        if *next > MAX_SEQUENCE {
67            return Err(StorageError::Backend(anyhow::anyhow!(
68                "dataset {} exhausted its nine-digit sequence space",
69                self.name
70            )));
71        }
72        tokio::fs::create_dir_all(&self.path).await?;
73        let destination = self.path.join(format!("{next:09}.json"));
74        let temporary = self
75            .path
76            .join(format!("{next:09}.json.{}", temporary_suffix()));
77        tokio::fs::write(&temporary, serde_json::to_vec_pretty(item)?).await?;
78        if let Err(error) = tokio::fs::rename(&temporary, &destination).await {
79            let _ = tokio::fs::remove_file(&temporary).await;
80            return Err(error.into());
81        }
82        *next += 1;
83        Ok(())
84    }
85}
86
87#[async_trait::async_trait]
88impl Dataset for FsDataset {
89    async fn push_json(&self, item: Value) -> StorageResult<()> {
90        let _operation = self.operations.read().await;
91        let mut next = self.next_sequence.lock().await;
92        self.append_locked(&mut next, &item).await
93    }
94
95    async fn push_json_batch(&self, items: Vec<Value>) -> StorageResult<()> {
96        let _operation = self.operations.read().await;
97        let mut next = self.next_sequence.lock().await;
98        for item in items {
99            self.append_locked(&mut next, &item).await?;
100        }
101        Ok(())
102    }
103
104    async fn list_raw(&self, opts: ListOptions) -> StorageResult<Page<Value>> {
105        let _operation = self.operations.read().await;
106        let _guard = self.next_sequence.lock().await;
107        let mut items = self.read_items().await?;
108        let total = items.len() as u64;
109        if opts.desc {
110            items.reverse();
111        }
112        let items = items
113            .into_iter()
114            .skip(usize::try_from(opts.offset).unwrap_or(usize::MAX))
115            .take(opts.limit.map_or(usize::MAX, |limit| {
116                usize::try_from(limit).unwrap_or(usize::MAX)
117            }))
118            .collect();
119        Ok(Page {
120            items,
121            total,
122            offset: opts.offset,
123            limit: opts.limit,
124        })
125    }
126
127    fn stream_raw(&self, opts: ListOptions) -> BoxStream<'_, StorageResult<Value>> {
128        let page = async move {
129            match self.list_raw(opts).await {
130                Ok(page) => page.items.into_iter().map(Ok).collect::<Vec<_>>(),
131                Err(error) => vec![Err(error)],
132            }
133        };
134        Box::pin(stream::once(page).flat_map(stream::iter))
135    }
136
137    async fn export_json(&self, path: &Path) -> StorageResult<()> {
138        let _operation = self.operations.read().await;
139        let _guard = self.next_sequence.lock().await;
140        let items = self.read_items().await?;
141        tokio::fs::write(path, serde_json::to_vec_pretty(&items)?).await?;
142        Ok(())
143    }
144
145    async fn export_csv(&self, path: &Path) -> StorageResult<()> {
146        let _operation = self.operations.read().await;
147        let _guard = self.next_sequence.lock().await;
148        let items = self.read_items().await?;
149        let mut columns = BTreeSet::new();
150        for item in &items {
151            let object = item.as_object().ok_or(StorageError::Unsupported(
152                "export_csv requires object items",
153            ))?;
154            columns.extend(object.keys().cloned());
155        }
156        let columns: Vec<_> = columns.into_iter().collect();
157        let mut rows = vec![
158            columns
159                .iter()
160                .map(|key| csv_field(key))
161                .collect::<Vec<_>>()
162                .join(","),
163        ];
164        for item in &items {
165            let object = item.as_object().expect("objects validated above");
166            rows.push(
167                columns
168                    .iter()
169                    .map(|key| match object.get(key) {
170                        None => String::new(),
171                        Some(Value::String(value)) => csv_field(value),
172                        Some(value) => csv_field(&value.to_string()),
173                    })
174                    .collect::<Vec<_>>()
175                    .join(","),
176            );
177        }
178        tokio::fs::write(path, rows.join("\r\n")).await?;
179        Ok(())
180    }
181
182    /// Returns metadata derived from directory and item-file timestamps.
183    ///
184    /// File systems do not expose Crawlee's logical creation metadata, so the
185    /// directory creation time (falling back to its modification time) and the
186    /// newest directory or item modification time are approximations.
187    async fn info(&self) -> StorageResult<DatasetInfo> {
188        let _operation = self.operations.read().await;
189        let _guard = self.next_sequence.lock().await;
190        tokio::fs::create_dir_all(&self.path).await?;
191        let directory = tokio::fs::metadata(&self.path).await?;
192        let directory_modified = directory.modified()?;
193        let created = directory.created().unwrap_or(directory_modified);
194        let files = item_files(&self.path).await?;
195        let mut modified = directory_modified;
196        for (_, path) in &files {
197            let candidate = tokio::fs::metadata(path).await?.modified()?;
198            modified = modified.max(candidate);
199        }
200        Ok(DatasetInfo::new(
201            self.name.clone(),
202            files.len() as u64,
203            system_time(created),
204            system_time(modified),
205        ))
206    }
207}
208
209async fn max_sequence(path: &Path) -> StorageResult<u64> {
210    Ok(item_files(path)
211        .await?
212        .last()
213        .map_or(0, |(sequence, _)| *sequence))
214}
215
216async fn item_files(path: &Path) -> StorageResult<Vec<(u64, PathBuf)>> {
217    let mut entries = tokio::fs::read_dir(path).await?;
218    let mut files = Vec::new();
219    while let Some(entry) = entries.next_entry().await? {
220        if !entry.file_type().await?.is_file() {
221            continue;
222        }
223        let name = entry.file_name();
224        let Some(name) = name.to_str() else {
225            continue;
226        };
227        if is_temporary_file(name) {
228            continue;
229        }
230        let Some(sequence) = dataset_sequence(name) else {
231            continue;
232        };
233        files.push((sequence, entry.path()));
234    }
235    files.sort_unstable_by_key(|(sequence, _)| *sequence);
236    Ok(files)
237}
238
239fn dataset_sequence(name: &str) -> Option<u64> {
240    if name.len() != 14 || !name.ends_with(".json") {
241        return None;
242    }
243    let digits = &name[..9];
244    if !digits.bytes().all(|byte| byte.is_ascii_digit()) {
245        return None;
246    }
247    digits.parse().ok().filter(|sequence| *sequence > 0)
248}
249
250fn csv_field(value: &str) -> String {
251    if value.contains([',', '"', '\r', '\n']) {
252        format!("\"{}\"", value.replace('"', "\"\""))
253    } else {
254        value.to_owned()
255    }
256}
257
258fn system_time(value: SystemTime) -> OffsetDateTime {
259    OffsetDateTime::from(value)
260}