Skip to main content

millipede_storage_fs/
kvs.rs

1use crate::layout::{is_temporary_file, temporary_suffix, validate_key};
2use bytes::Bytes;
3use millipede_core::storage::{
4    KeyInfo, KeyList, KeyValueStore, KvEntry, ListKeysOptions, StorageResult,
5};
6use std::{collections::BTreeMap, path::PathBuf, sync::Arc};
7use tokio::sync::{Mutex, RwLock};
8
9/// A file-system-backed byte-oriented key-value store.
10pub struct FsKeyValueStore {
11    name: String,
12    path: PathBuf,
13    operations: Arc<RwLock<()>>,
14    writes: Mutex<()>,
15}
16
17impl FsKeyValueStore {
18    pub(crate) fn open(name: String, path: PathBuf, operations: Arc<RwLock<()>>) -> Self {
19        Self {
20            name,
21            path,
22            operations,
23            writes: Mutex::new(()),
24        }
25    }
26
27    async fn matching_files(&self, key: &str) -> StorageResult<Vec<(String, PathBuf)>> {
28        tokio::fs::create_dir_all(&self.path).await?;
29        let mut entries = tokio::fs::read_dir(&self.path).await?;
30        let mut matches = Vec::new();
31        while let Some(entry) = entries.next_entry().await? {
32            if !entry.file_type().await?.is_file() {
33                continue;
34            }
35            let name = entry.file_name();
36            let Some(name) = name.to_str() else {
37                continue;
38            };
39            if is_temporary_file(name) {
40                continue;
41            }
42            let Some((stored_key, extension)) = name.rsplit_once('.') else {
43                continue;
44            };
45            if stored_key != key || extension.is_empty() {
46                continue;
47            }
48            matches.push((extension.to_owned(), entry.path()));
49        }
50        matches.sort_unstable_by(|left, right| left.1.cmp(&right.1));
51        Ok(matches)
52    }
53}
54
55#[async_trait::async_trait]
56impl KeyValueStore for FsKeyValueStore {
57    async fn get_bytes(&self, key: &str) -> StorageResult<Option<KvEntry>> {
58        validate_key(key)?;
59        let _operation = self.operations.read().await;
60        let _guard = self.writes.lock().await;
61        let Some((extension, path)) = self.matching_files(key).await?.into_iter().next() else {
62            return Ok(None);
63        };
64        Ok(Some(KvEntry {
65            key: key.to_owned(),
66            value: Bytes::from(tokio::fs::read(path).await?),
67            content_type: content_type_for_extension(&extension).to_owned(),
68        }))
69    }
70
71    async fn set_bytes(&self, key: &str, bytes: Bytes, content_type: &str) -> StorageResult<()> {
72        validate_key(key)?;
73        let _operation = self.operations.read().await;
74        let _guard = self.writes.lock().await;
75        tokio::fs::create_dir_all(&self.path).await?;
76        let extension = extension_for_content_type(content_type);
77        let destination = self.path.join(format!("{key}.{extension}"));
78        let temporary = self
79            .path
80            .join(format!("{key}.{extension}.{}", temporary_suffix()));
81        tokio::fs::write(&temporary, &bytes).await?;
82        if let Err(error) = tokio::fs::rename(&temporary, &destination).await {
83            let _ = tokio::fs::remove_file(&temporary).await;
84            return Err(error.into());
85        }
86        for (_, old_path) in self.matching_files(key).await? {
87            if old_path != destination {
88                tokio::fs::remove_file(old_path).await?;
89            }
90        }
91        tracing::trace!(store = %self.name, key, content_type, "stored key-value entry");
92        Ok(())
93    }
94
95    async fn delete(&self, key: &str) -> StorageResult<()> {
96        validate_key(key)?;
97        let _operation = self.operations.read().await;
98        let _guard = self.writes.lock().await;
99        for (_, path) in self.matching_files(key).await? {
100            tokio::fs::remove_file(path).await?;
101        }
102        Ok(())
103    }
104
105    /// Lists keys in lexical order after the exclusive cursor.
106    ///
107    /// A zero limit returns an empty, non-truncated page, matching the memory
108    /// backend's pagination semantics.
109    async fn list_keys(&self, opts: ListKeysOptions) -> StorageResult<KeyList> {
110        let _operation = self.operations.read().await;
111        if opts.limit == Some(0) {
112            return Ok(KeyList {
113                keys: Vec::new(),
114                is_truncated: false,
115                next_exclusive_start_key: None,
116            });
117        }
118
119        let _guard = self.writes.lock().await;
120        tokio::fs::create_dir_all(&self.path).await?;
121        let mut entries = tokio::fs::read_dir(&self.path).await?;
122        let mut keys = BTreeMap::new();
123        while let Some(entry) = entries.next_entry().await? {
124            if !entry.file_type().await?.is_file() {
125                continue;
126            }
127            let name = entry.file_name();
128            let Some(name) = name.to_str() else {
129                continue;
130            };
131            if is_temporary_file(name) {
132                continue;
133            }
134            let Some((key, extension)) = name.rsplit_once('.') else {
135                continue;
136            };
137            if key.is_empty() || extension.is_empty() {
138                continue;
139            }
140            let size = entry.metadata().await?.len();
141            keys.entry(key.to_owned()).or_insert(size);
142        }
143
144        let start = opts.exclusive_start_key.as_deref();
145        let mut filtered = keys
146            .into_iter()
147            .filter(|(key, _)| start.is_none_or(|start| key.as_str() > start));
148        let limit = opts.limit.unwrap_or(usize::MAX);
149        let selected: Vec<_> = filtered.by_ref().take(limit).collect();
150        let is_truncated = filtered.next().is_some();
151        let next_exclusive_start_key = is_truncated
152            .then(|| selected.last().map(|(key, _)| key.clone()))
153            .flatten();
154        Ok(KeyList {
155            keys: selected
156                .into_iter()
157                .map(|(key, size)| KeyInfo { key, size })
158                .collect(),
159            is_truncated,
160            next_exclusive_start_key,
161        })
162    }
163}
164
165fn extension_for_content_type(content_type: &str) -> &'static str {
166    match content_type
167        .split(';')
168        .next()
169        .unwrap_or_default()
170        .trim()
171        .to_ascii_lowercase()
172        .as_str()
173    {
174        "application/json" => "json",
175        "text/plain" => "txt",
176        "text/html" => "html",
177        "application/xml" | "text/xml" => "xml",
178        "image/png" => "png",
179        "image/jpeg" => "jpeg",
180        "application/octet-stream" => "bin",
181        _ => "bin",
182    }
183}
184
185fn content_type_for_extension(extension: &str) -> &'static str {
186    match extension.to_ascii_lowercase().as_str() {
187        "json" => "application/json",
188        "txt" => "text/plain",
189        "html" => "text/html",
190        "xml" => "application/xml",
191        "png" => "image/png",
192        "jpeg" => "image/jpeg",
193        "bin" => "application/octet-stream",
194        _ => "application/octet-stream",
195    }
196}