Skip to main content

hashiverse_server_lib/environment/
disk_environment_store.rs

1//! # Production disk [`EnvironmentStore`]
2//!
3//! Backing store used by the real server binary. Persists to a `fjall` keyspace for
4//! metadata and a two-level directory tree (256² slots, ≈16 M bundles before any
5//! single directory grows past ~64 K entries) for the bundle bodies. Writes go
6//! through a temp-file + rename dance so a mid-write crash never leaves a torn
7//! bundle, and feedback-update batches are atomic at the `fjall` level.
8//!
9//! Library choice rationale:
10//! - **fjall** over **redb** (redb compacts synchronously and blocks writes during
11//!   it, which we can't afford on a single-node server) and over **sled** (stalled
12//!   upstream; its v1.0 rewrite hasn't landed).
13//! - **postcard** for metadata serialisation — compact, fast, stable format, no
14//!   schema on disk.
15//!
16//! Feedback entries are stored under composite keys `(location_id, post_id,
17//! feedback_type)` so range iteration can walk all feedback for a given bundle
18//! location during decimation in a single sequential scan.
19
20use bytes::Bytes;
21use crate::environment::environment::{Environment, EnvironmentDimensions, EnvironmentFactory, PostBundleMetadata};
22use crate::environment::environment_store::EnvironmentStore;
23use anyhow::anyhow;
24use async_trait::async_trait;
25use fjall::{Database, Keyspace};
26use hashiverse_lib::tools::time::{TimeMillis, TimeMillisBytes};
27use hashiverse_lib::tools::types::{ID_BYTES, Id, SALT_BYTES, Salt, Pow};
28use log::{info, trace, warn};
29use std::collections::HashMap;
30use std::fs;
31use std::fs::OpenOptions;
32use std::path::PathBuf;
33use std::sync::Arc;
34use hashiverse_lib::protocol::posting::encoded_post_feedback::EncodedPostFeedbackV1;
35
36const MAX_ENVIRONMENTS_PER_NODE: usize = 256;
37
38pub struct DiskEnvironmentFactory {
39    base_path: String,
40}
41
42#[async_trait]
43impl EnvironmentFactory for DiskEnvironmentFactory {
44    fn new(base_path: &str) -> Self {
45        Self { base_path: base_path.to_string() }
46    }
47
48    async fn open_next_available(&self, environment_dimensions: EnvironmentDimensions) -> anyhow::Result<Environment> {
49        for env_id in 1..=MAX_ENVIRONMENTS_PER_NODE {
50            let disk_environment_store = DiskEnvironmentStore::new(&self.base_path, env_id);
51            match disk_environment_store {
52                Ok(disk_environment_store) => return Environment::new(Arc::new(disk_environment_store), environment_dimensions).await,
53                Err(_) => continue,
54            }
55        }
56
57        anyhow::bail!("no environments available")
58    }
59}
60
61/// DiskEnvironmentStore implements the `EnvironmentStore` trait for production use.  PostBundles are stored as files in a directory tree, while config, metadata, feedback, and post-expiry metainformation are stored in a key-value database.
62///
63/// A lot of time was spent comparing the different available databases.  Given the nature of hashiverse, it makes sense that some sort of key-value (KV) database is used.
64/// For simplicity of building and maintenance, we decided to use a pure-Rust implementation.  This narrowed teh field down to redb, sled, and fjall.
65/// We would have liked to use redb, because of its self-reported performance with individual writes and random range reads - both things that hashiverse does a lot of.
66/// Unfortunately it requires occasional compaction of the ever-growing files on disk, something that needs downtime and 2x disk space to do.  This essentially killed it for us.
67/// Next was sled, which autocompacts and seemed the next most performant, but there seems to be little improvement and support for it - it's own github repo says use this as beta software only, and work on its v1.0 release seems to have stalled.
68/// That leaves fjall - seemingly third place in performance, but gauging by its github activity, is ever improving and is highly supported.  It also does autocompaction.
69pub struct DiskEnvironmentStore {
70    path: PathBuf,
71    #[allow(dead_code)]
72    lock_file: fs::File, // Blocks other processes from claiming this environment on disk
73    database: Database,
74    keyspace_config: Keyspace,
75    keyspace_post_bundle_last_accessed: Keyspace, // location_id -> TimeMillis
76    keyspace_post_bundle_metadata: Keyspace,      // location_id -> PostBundleMetadata
77    keyspace_post_bundle_feedback: Keyspace,      // [location_id,post_id,feedback_type] -> [salt,pow]
78}
79
80impl DiskEnvironmentStore {
81    fn new(base_path: &str, env_id: usize) -> anyhow::Result<Self> {
82        let path = PathBuf::from(base_path).join(env_id.to_string());
83
84        // Check that the path exists
85        fs::create_dir_all(&path)?;
86
87        // Attempt to grab the lock
88        let lock_path = path.join("lock");
89        let lock_file = OpenOptions::new().create(true).truncate(true).read(true).write(true).open(&lock_path)?;
90        lock_file.try_lock()?;
91
92        // Make sure the various stores exist
93        let database = Database::builder(path.join("database")).open()?;
94        let keyspace_config = database.keyspace("config", fjall::KeyspaceCreateOptions::default)?;
95        let keyspace_post_bundle_last_accessed = database.keyspace("post_bundle_last_accessed", fjall::KeyspaceCreateOptions::default)?;
96        let keyspace_post_bundle_metadata = database.keyspace("keyspace_post_bundle_metadata", fjall::KeyspaceCreateOptions::default)?;
97        let keyspace_post_bundle_feedback = database.keyspace("keyspace_post_bundle_feedback", fjall::KeyspaceCreateOptions::default)?;
98
99        info!("using environment {} at {}", env_id, path.to_str().unwrap());
100
101        Ok(Self {
102            path,
103            lock_file,
104            database,
105            keyspace_config,
106            keyspace_post_bundle_last_accessed,
107            keyspace_post_bundle_metadata,
108            keyspace_post_bundle_feedback,
109        })
110    }
111
112    fn path_for_location_id(&self, location_id: &Id) -> (PathBuf, PathBuf) {
113        // Two indirections of with 256 files in the folder allows 256^3 = 16million postbundles
114        // Assuming each is at tiniest 1kb, that implies about 16Gb of disk space, which is what we are aiming for.
115        // If we wanted to substantially increase the disk size supported by a hashiverse node, we might have to intro another level of indirection.
116        // Note that it is likely ext4 will also start running out of inodes if posts are too small - inodes on ext4 assume a 16kb file on average.
117        // Perhaps XFS is mandated?  Though the tradeoff is XFS is slower than ext4 when handling little files...
118        let b0 = format!("{:02x}", location_id.0[0]);
119        let b1 = format!("{:02x}", location_id.0[1]);
120
121        let directory = self.path.join("post_bundles").join(b0).join(b1);
122        let filename = directory.join(location_id.to_hex_str());
123
124        (directory, filename)
125    }
126}
127
128impl EnvironmentStore for DiskEnvironmentStore {
129    fn post_bundle_count(&self) -> anyhow::Result<usize> {
130        let len = self.keyspace_post_bundle_last_accessed.len()?;
131        Ok(len)
132    }
133
134    fn post_bundle_feedback_count(&self) -> anyhow::Result<usize> {
135        let len = self.keyspace_post_bundle_feedback.len()?;
136        Ok(len)
137    }
138    fn post_bundle_metadata_get(&self, location_id: &Id) -> anyhow::Result<Option<PostBundleMetadata>> {
139        let guard = self.keyspace_post_bundle_metadata.get(location_id)?;
140        match guard {
141            Some(guard) => Ok(Some(postcard::from_bytes(&guard)?)),
142            None => Ok(None),
143        }
144    }
145
146    fn post_bundle_metadata_put(&self, location_id: &Id, post_bundle_metadata: &PostBundleMetadata) -> anyhow::Result<()> {
147        // Much faster than allocating a Vec each time...
148        let mut scratch = [0u8; 64];
149        let scratch_used = postcard::to_slice(post_bundle_metadata, &mut scratch)?;
150        self.keyspace_post_bundle_metadata.insert(location_id.0, scratch_used.as_ref())?;
151        Ok(())
152    }
153
154    fn post_bundle_bytes_get(&self, location_id: &Id) -> anyhow::Result<Option<Bytes>> {
155        let (_directory, filename) = self.path_for_location_id(location_id);
156        let result = fs::read(filename).ok().map(Bytes::from);
157        Ok(result)
158    }
159
160    fn post_bundle_bytes_put(&self, location_id: &Id, bytes: &[u8]) -> anyhow::Result<()> {
161        let (directory, filename) = self.path_for_location_id(location_id);
162        let filename_temp = filename.with_added_extension("tmp");
163        fs::create_dir_all(&directory)?;
164        fs::write(&filename_temp, bytes)?;
165        fs::rename(&filename_temp, &filename)?;
166        Ok(())
167    }
168
169    fn post_bundle_feedbacks_bytes_get(&self, post_bundle_location_id: &Id) -> anyhow::Result<Bytes> {
170        let mut bytes = Vec::new();
171
172        self.keyspace_post_bundle_feedback.prefix(post_bundle_location_id).for_each(|guard| {
173            let try_result = try {
174                let (key, value) = guard.into_inner().map_err(|e| anyhow!("{}", e))?;
175                let post_bundle_feedback_key = PostBundleFeedbackKey::from_slice(&key)?;
176                let post_bundle_feedback_value = PostBundleFeedbackValue::from_slice(&value)?;
177                EncodedPostFeedbackV1::append_encode_direct_to_bytes(
178                    &mut bytes,
179                    post_bundle_feedback_key.post_id_bytes(),
180                    post_bundle_feedback_key.feedback_type(),
181                    post_bundle_feedback_value.salt_bytes(),
182                    post_bundle_feedback_value.pow(),
183                )?;
184            };
185
186            if let Err(e) = try_result {
187                warn!("unexpectedly unable to encode post bundle feedback: {}", e);
188            }
189        });
190
191        Ok(Bytes::from(bytes))
192    }
193
194
195    fn post_feedback_put_if_more_powerful(&self, location_id: &Id, encoded_post_feedback: &EncodedPostFeedbackV1) -> anyhow::Result<()> {
196        let post_bundle_feedback_key = PostBundleFeedbackKey::new(location_id, &encoded_post_feedback.post_id, encoded_post_feedback.feedback_type);
197
198        // Check that we don't already have much better feedback
199        let post_bundle_feedback_value = self.keyspace_post_bundle_feedback.get(&post_bundle_feedback_key)?;
200        if let Some(post_bundle_feedback_value) = post_bundle_feedback_value {
201            let post_bundle_feedback_value = PostBundleFeedbackValue::from_slice(&post_bundle_feedback_value)?;
202            if post_bundle_feedback_value.pow() >= encoded_post_feedback.pow {
203                trace!("Not storing lesser feedback for location_id={} with existing pow={}: feedback={:?}", location_id, post_bundle_feedback_value.pow(), encoded_post_feedback);
204                return Ok(());
205            }
206        }
207
208        // Commit the improved feedback
209        {
210            let post_bundle_feedback_value = PostBundleFeedbackValue::new(encoded_post_feedback.salt, encoded_post_feedback.pow);
211            self.keyspace_post_bundle_feedback.insert(post_bundle_feedback_key.0, post_bundle_feedback_value.0)?;
212        }
213
214        Ok(())
215    }
216
217    fn post_bundles_delete(&self, location_ids: &[Id]) -> anyhow::Result<()> {
218        let post_bundle_bytes_delete = |location_id: &Id| -> anyhow::Result<()> {
219            let (_directory, filename) = self.path_for_location_id(location_id);
220            let _result = fs::remove_file(filename);
221            Ok(())
222        };
223
224        // Collect feedback keys before opening the batch to avoid borrow conflicts
225        let mut feedback_keys_to_delete: Vec<Vec<u8>> = Vec::new();
226        for location_id in location_ids {
227            self.keyspace_post_bundle_feedback.prefix(location_id).for_each(|guard| {
228                let try_result: anyhow::Result<()> = try {
229                    let (key, _) = guard.into_inner().map_err(|e| anyhow!("{}", e))?;
230                    feedback_keys_to_delete.push(key.to_vec());
231                };
232                if let Err(e) = try_result {
233                    warn!("failed to collect feedback key for deletion: {}", e);
234                }
235            });
236        }
237
238        let mut batch = self.database.batch();
239        for location_id in location_ids {
240            batch.remove(&self.keyspace_post_bundle_metadata, location_id.0);
241            batch.remove(&self.keyspace_post_bundle_last_accessed, location_id.0);
242            post_bundle_bytes_delete(location_id)?;
243        }
244        for key in &feedback_keys_to_delete {
245            batch.remove(&self.keyspace_post_bundle_feedback, key.as_slice());
246        }
247        batch.commit()?;
248
249        Ok(())
250    }
251
252    fn post_bundles_last_accessed_flush(&self, post_bundles_last_accessed: &HashMap<Id, TimeMillis>) -> anyhow::Result<()> {
253        let mut batch = self.database.batch();
254        for (location_id, time_millis) in post_bundles_last_accessed.iter() {
255            let time_millis_bytes = time_millis.encode_be();
256            batch.insert(&self.keyspace_post_bundle_last_accessed, location_id.0, time_millis_bytes.0);
257        }
258        batch.commit()?;
259        Ok(())
260    }
261
262    fn post_bundles_last_accessed_iter(&self, location_id: &Id) -> Box<dyn Iterator<Item = Result<(Id, TimeMillisBytes), anyhow::Error>> + '_> {
263        let it = self
264            .keyspace_post_bundle_last_accessed
265            .range(location_id.to_string()..)
266            .chain(self.keyspace_post_bundle_last_accessed.range(..location_id.to_string()))
267            .map(|guard| {
268                let (location_id, time_millis_bytes) = guard.into_inner().map_err(|e| anyhow!("{}", e))?;
269                let location_id = Id::from_slice(&location_id)?;
270                let time_millis_bytes = TimeMillisBytes::from_bytes(&time_millis_bytes)?;
271                Ok((location_id, time_millis_bytes))
272            });
273
274        Box::new(it)
275    }
276
277    fn config_get_bytes(&self, key: &str) -> anyhow::Result<Option<Vec<u8>>> {
278        Ok(self.keyspace_config.get(key)?.map(|v| v.to_vec()))
279    }
280
281    fn config_put_bytes(&self, key: &str, v: Vec<u8>) -> anyhow::Result<()> {
282        self.keyspace_config.insert(key, v)?;
283        Ok(())
284    }
285}
286
287const POST_BUNDLE_FEEDBACK_KEY_SIZE: usize = ID_BYTES + ID_BYTES + 1;
288pub struct PostBundleFeedbackKey(pub [u8; POST_BUNDLE_FEEDBACK_KEY_SIZE]);
289
290impl PostBundleFeedbackKey {
291    pub fn new(location_id: &Id, post_id: &Id, feedback_type: u8) -> Self {
292        let mut bytes = [0u8; POST_BUNDLE_FEEDBACK_KEY_SIZE];
293        bytes[0..ID_BYTES].copy_from_slice(location_id.as_bytes());
294        bytes[ID_BYTES..2*ID_BYTES].copy_from_slice(post_id.as_bytes());
295        bytes[2*ID_BYTES] = feedback_type;
296        Self(bytes)
297    }
298
299    pub fn from_slice(bytes: &[u8]) -> anyhow::Result<Self> {
300        let bytes: [u8; POST_BUNDLE_FEEDBACK_KEY_SIZE] = bytes
301            .try_into()
302            .map_err(|_| anyhow::anyhow!("Invalid PostBundleFeedbackKey length: expected {}, got {}", POST_BUNDLE_FEEDBACK_KEY_SIZE, bytes.len()))?;
303        Ok(Self(bytes))
304    }
305
306    pub fn post_bundle_location_id_bytes(&self) -> &[u8] {
307        &self.0[0..ID_BYTES]
308    }
309    pub fn post_id_bytes(&self) -> &[u8] {
310        &self.0[ID_BYTES..2*ID_BYTES]
311    }
312
313    pub fn feedback_type(&self) -> u8 {
314        self.0[2*ID_BYTES]
315    }
316}
317
318impl AsRef<[u8]> for PostBundleFeedbackKey {
319    fn as_ref(&self) -> &[u8] {
320        &self.0
321    }
322}
323
324const POST_BUNDLE_FEEDBACK_VALUE_SIZE: usize = SALT_BYTES + 1;
325pub struct PostBundleFeedbackValue(pub [u8; POST_BUNDLE_FEEDBACK_VALUE_SIZE]);
326
327impl PostBundleFeedbackValue {
328    pub fn new(salt: Salt, pow: Pow) -> Self {
329        let mut bytes = [0u8; POST_BUNDLE_FEEDBACK_VALUE_SIZE];
330        bytes[0..SALT_BYTES].copy_from_slice(salt.as_slice());
331        bytes[SALT_BYTES] = pow.0;
332        Self(bytes)
333    }
334
335    pub fn from_slice(bytes: &[u8]) -> anyhow::Result<Self> {
336        let bytes: [u8; POST_BUNDLE_FEEDBACK_VALUE_SIZE] = bytes
337            .try_into()
338            .map_err(|_| anyhow::anyhow!("Invalid PostBundleFeedbackValue length: expected {}, got {}", POST_BUNDLE_FEEDBACK_VALUE_SIZE, bytes.len()))?;
339        Ok(Self(bytes))
340    }
341    pub fn salt_bytes(&self) -> &[u8] {
342        &self.0[0..SALT_BYTES]
343    }
344    pub fn pow(&self) -> Pow {
345        Pow(self.0[SALT_BYTES])
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use crate::environment;
352    use crate::environment::disk_environment_store::{DiskEnvironmentFactory, DiskEnvironmentStore};
353
354    #[tokio::test]
355    async fn basics_test() -> anyhow::Result<()> {
356        environment::environment::tests::basics_test::<DiskEnvironmentFactory>().await
357    }
358
359
360    #[tokio::test]
361    async fn feedback_bytes_get_test() -> anyhow::Result<()> {
362        environment::environment::tests::feedback_bytes_get_test::<DiskEnvironmentFactory>().await
363    }
364
365    #[tokio::test]
366    async fn feedback_put_if_more_powerful_test() -> anyhow::Result<()> {
367        environment::environment::tests::feedback_put_if_more_powerful_test::<DiskEnvironmentFactory>().await
368    }
369
370    #[test]
371    fn second_store_on_same_environment_is_locked_until_first_dropped() -> anyhow::Result<()> {
372        let (_temp_dir_handle, temp_dir_path) = hashiverse_lib::tools::tools::get_temp_dir()?;
373
374        let first_disk_environment_store = DiskEnvironmentStore::new(&temp_dir_path, 1)?;
375        assert!(DiskEnvironmentStore::new(&temp_dir_path, 1).is_err(), "environment 1 must be locked while the first store is alive");
376
377        // A different environment id in the same base path is unaffected
378        let other_disk_environment_store = DiskEnvironmentStore::new(&temp_dir_path, 2)?;
379
380        drop(first_disk_environment_store);
381        let reopened_disk_environment_store = DiskEnvironmentStore::new(&temp_dir_path, 1);
382        assert!(reopened_disk_environment_store.is_ok(), "environment 1 must be claimable once the first store is dropped");
383
384        drop(other_disk_environment_store);
385        Ok(())
386    }
387
388    // These are brutally expensive on disk - we rely on the memory stub for the decimation tests
389    //
390    // #[tokio::test]
391    // async fn decimation_feedback_deleted_test() -> anyhow::Result<()> {
392    //     environment::environment::tests::decimation_feedback_deleted_test::<DiskEnvironmentFactory>().await
393    // }
394    // #[tokio::test]
395    // async fn decimation_test() -> anyhow::Result<()> {
396    //     environment::environment::tests::decimation_test::<DiskEnvironmentFactory>().await
397    // }
398    // #[tokio::test]
399    // async fn decimation_convergence_test() -> anyhow::Result<()> {
400    //     environment::environment::tests::decimation_convergence_test::<DiskEnvironmentFactory>(2 * 1000).await
401    // }
402    // #[tokio::test]
403    // async fn decimation_existence_test() -> anyhow::Result<()> { environment::environment::tests::decimation_existence_test::<DiskEnvironmentFactory>().await }
404}