Skip to main content

hashiverse_server_lib/environment/
environment.rs

1//! # Server-side data environment — persistence, quotas, decimation
2//!
3//! [`Environment`] is the facade every handler in
4//! [`crate::server`] writes through when it needs to read or mutate persisted
5//! data: post bundles, per-bundle metadata, feedback, and server config. It wraps a
6//! pluggable [`crate::environment::environment_store::EnvironmentStore`] and layers
7//! on top:
8//!
9//! - **Striped locks** — 256 mutexes keyed by the first byte of the bundle id, so
10//!   unrelated bundles never contend. Hot locations stay local to their own stripe.
11//! - **Quota enforcement** — disk usage is tracked against the operator's configured
12//!   cap (from [`crate::server::args::Args`]). When the cap is crossed, decimation
13//!   runs as an offline `tokio::task::spawn_blocking` job, evicting the
14//!   least-recently-accessed bundles until the store is back under quota — without
15//!   stalling the live request loop.
16//! - **Feedback merging** — writes are monotonic: a new feedback entry is kept only
17//!   if its proof-of-work is at least as strong as any existing entry for the same
18//!   `(location, post, feedback_type)`, so sybil floods can't overwrite real votes.
19
20use crate::environment::environment_store::EnvironmentStore;
21use async_trait::async_trait;
22use bytes::Bytes;
23use hashiverse_lib::tools::time::TimeMillis;
24use hashiverse_lib::tools::types::Id;
25use log::{info, warn};
26use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard};
27use serde::{Deserialize, Serialize};
28use std::collections::{BinaryHeap, HashMap};
29use std::sync::Arc;
30use std::sync::atomic::{AtomicUsize, Ordering};
31use serde::de::DeserializeOwned;
32use tokio_util::sync::CancellationToken;
33use hashiverse_lib::protocol::posting::encoded_post_feedback::EncodedPostFeedbackV1;
34use hashiverse_lib::tools::{compression, json};
35
36/// An environment is a 'folder' location that contains everything to persist a server
37///  - e.g. config , keys, posts, etc
38
39pub const CONFIG_SERVER_ID: &str = "server_id";
40pub const CONFIG_KADEMLIA_PEER_BUCKETS: &str = "kademlia_peer_buckets";
41pub const CONFIG_POST_BUNDLE_CURRENT_SIZE_BYTES: &str = "post_bundle_current_size_bytes";
42
43#[derive(Clone, Copy)]
44pub struct EnvironmentDimensions {
45    pub max_size_bytes: usize,
46    pub max_quota_used: f64,
47}
48
49impl EnvironmentDimensions {
50    pub fn with_max_size_bytes(&self, max_size_bytes: usize) -> Self {
51        Self { max_size_bytes, ..*self }
52    }
53}
54
55impl Default for EnvironmentDimensions {
56    fn default() -> Self {
57        Self {
58            max_size_bytes: 20 * 1024 * 1024 * 1024,
59            max_quota_used: 0.9,
60        }
61    }
62}
63
64#[async_trait]
65pub trait EnvironmentFactory: Send + Sync {
66    fn new(base_path: &str) -> Self
67    where
68        Self: Sized;
69    async fn open_next_available(&self, environment_dimensions: EnvironmentDimensions) -> anyhow::Result<Environment>;
70}
71
72#[derive(Serialize, Deserialize, Debug, PartialEq)]
73pub struct PostBundleMetadata {
74    pub size: usize,
75    pub num_posts: u8,
76    pub num_posts_granted: u8,
77    pub overflowed: bool,
78    pub sealed: bool,
79}
80
81impl PostBundleMetadata {
82    pub fn zero() -> PostBundleMetadata {
83        Self {
84            size: 0,
85            num_posts: 0,
86            num_posts_granted: 0,
87            overflowed: false,
88            sealed: false,
89        }
90    }
91}
92
93pub struct Environment {
94    environment_dimensions: EnvironmentDimensions,
95    environment_store: Arc<dyn EnvironmentStore>,
96
97    post_bundle_locks: [RwLock<()>; 256], // We do striped locking to allow better parallelism
98    post_bundles_last_touched_batch: RwLock<HashMap<Id, TimeMillis>>,
99    post_bundle_current_size_bytes: AtomicUsize,
100    decimation_lock: RwLock<()>, // Allows only one decimation process at a time
101}
102
103impl Environment {
104    pub async fn new(environment_store: Arc<dyn EnvironmentStore>, environment_dimensions: EnvironmentDimensions) -> anyhow::Result<Environment> {
105        let post_bundle_locks: [RwLock<()>; 256] = std::array::from_fn(|_| RwLock::new(()));
106        let post_bundles_last_touched = RwLock::new(HashMap::new());
107        let post_bundle_current_size_bytes = environment_store.config_get_usize(CONFIG_POST_BUNDLE_CURRENT_SIZE_BYTES)?.unwrap_or(0);
108        let post_bundle_current_size_bytes = AtomicUsize::new(post_bundle_current_size_bytes);
109
110        Ok(Environment {
111            environment_dimensions,
112            environment_store,
113            post_bundle_locks,
114            post_bundles_last_touched_batch: post_bundles_last_touched,
115            post_bundle_current_size_bytes,
116            decimation_lock: RwLock::new(()),
117        })
118    }
119
120    pub fn get_read_lock_for_location_id(&self, location_id: &Id) -> RwLockReadGuard<'_, ()> {
121        self.post_bundle_locks[location_id.0[0] as usize].read()
122    }
123
124    pub fn get_write_lock_for_location_id(&self, location_id: &Id) -> RwLockWriteGuard<'_, ()> {
125        self.post_bundle_locks[location_id.0[0] as usize].write()
126    }
127
128    pub fn config_get_bytes(&self, key: &str) -> anyhow::Result<Option<Vec<u8>>> {
129        self.environment_store.config_get_bytes(key)
130    }
131
132    pub fn config_put_bytes(&self, key: &str, v: Vec<u8>) -> anyhow::Result<()> {
133        self.environment_store.config_put_bytes(key, v)
134    }
135
136    pub fn config_put_struct<T: Serialize>(&self, key: &str, value: &T) -> anyhow::Result<()> {
137        let bytes = json::struct_to_bytes(value)?;
138        let bytes_compressed = compression::compress_for_speed(&bytes)?.to_bytes();
139        self.config_put_bytes(key, bytes_compressed.to_vec())
140    }
141
142    pub fn config_get_struct<T: DeserializeOwned>(&self, key: &str) -> anyhow::Result<Option<T>> {
143        let result = self.config_get_bytes(key)?;
144        match result {
145            Some(bytes_compressed) => {
146                let bytes = compression::decompress(&bytes_compressed)?.to_bytes();
147                let value = json::bytes_to_struct::<T>(&bytes)?;
148                Ok(Some(value))
149            }
150            None => Ok(None),
151        }
152    }
153
154
155    pub fn get_post_bundle_metadata(&self, time_millis: TimeMillis, location_id: &Id) -> anyhow::Result<Option<PostBundleMetadata>> {
156        self.post_bundles_last_touched_batch.write().insert(*location_id, time_millis);
157        self.environment_store.post_bundle_metadata_get(location_id)
158    }
159
160    pub fn get_post_bundle_bytes(&self, time_millis: TimeMillis, location_id: &Id) -> anyhow::Result<Option<Bytes>> {
161        self.post_bundles_last_touched_batch.write().insert(*location_id, time_millis);
162        self.environment_store.post_bundle_bytes_get(location_id)
163    }
164
165    pub fn get_post_bundle_encoded_post_feedbacks_bytes(&self, time_millis: TimeMillis, location_id: &Id) -> anyhow::Result<Bytes> {
166        self.post_bundles_last_touched_batch.write().insert(*location_id, time_millis);
167        self.environment_store.post_bundle_feedbacks_bytes_get(location_id)
168    }
169    
170    pub fn put_post_feedback_if_more_powerful(&self, time_millis: TimeMillis, location_id: &Id, encoded_post_feedback: &EncodedPostFeedbackV1) -> anyhow::Result<()> {
171        self.post_bundles_last_touched_batch.write().insert(*location_id, time_millis);
172        self.environment_store.post_feedback_put_if_more_powerful(location_id, encoded_post_feedback)
173    }
174
175    pub fn put_post_bundle_bytes(&self, time_millis: TimeMillis, location_id: &Id, bytes: &[u8]) -> anyhow::Result<()> {
176        self.post_bundles_last_touched_batch.write().insert(*location_id, time_millis);
177        self.environment_store.post_bundle_bytes_put(location_id, bytes)?;
178        Ok(())
179    }
180
181    pub fn put_post_bundle_metadata(&self, time_millis: TimeMillis, location_id: &Id, post_bundle_metadata: &PostBundleMetadata, additional_bytes: usize) -> anyhow::Result<()> {
182        self.post_bundles_last_touched_batch.write().insert(*location_id, time_millis);
183        self.environment_store.post_bundle_metadata_put(location_id, post_bundle_metadata)?;
184        self.post_bundle_current_size_bytes.fetch_add(additional_bytes, Ordering::Relaxed);
185        Ok(())
186    }
187
188    fn post_bundle_current_size_bytes(&self) -> usize {
189        self.post_bundle_current_size_bytes.load(Ordering::Relaxed)
190    }
191
192    pub async fn do_maintenance(self: &Arc<Self>, cancellation_token: &CancellationToken, time_millis: TimeMillis) -> anyhow::Result<()> {
193        // log::trace!("do_maintenance");
194
195        // Push the current total disk used to store
196        let post_bundle_current_size_bytes = self.post_bundle_current_size_bytes();
197        self.environment_store.config_put_usize(CONFIG_POST_BUNDLE_CURRENT_SIZE_BYTES, post_bundle_current_size_bytes)?;
198
199        // Flush the last_accessed times to store
200        {
201            let mut post_bundles_last_touched = self.post_bundles_last_touched_batch.write();
202            if !post_bundles_last_touched.is_empty() {
203                log::trace!("Flushing {} last accessed post bundles", post_bundles_last_touched.len());
204                self.environment_store.post_bundles_last_accessed_flush(&post_bundles_last_touched)?;
205                post_bundles_last_touched.clear();
206            }
207        }
208
209        // Have we passed our allowed quota of available space?
210        let quota_used = post_bundle_current_size_bytes as f64 / self.environment_dimensions.max_size_bytes as f64;
211        if quota_used > self.environment_dimensions.max_quota_used {
212            let env = self.clone();
213            let cancellation_token = cancellation_token.clone();
214            let max_quota_used = self.environment_dimensions.max_quota_used;
215            tokio::task::spawn_blocking(move || env.do_decimation(cancellation_token, time_millis, quota_used, max_quota_used)).await??;
216        }
217
218        Ok(())
219    }
220
221    fn do_decimation(self: &Arc<Self>, cancellation_token: CancellationToken, _time_millis: TimeMillis, quota_used: f64, max_quota_used: f64) -> anyhow::Result<()> {
222        let lock = self.decimation_lock.try_read();
223        if lock.is_none() {
224            log::trace!("A decimation is already in progress.");
225            return Ok(());
226        }
227
228        // Make sure we flush our size update to the database
229        scopeguard::defer! {
230            let post_bundle_current_size_bytes = self.post_bundle_current_size_bytes();
231            let _ = self.environment_store.config_put_usize(CONFIG_POST_BUNDLE_CURRENT_SIZE_BYTES, post_bundle_current_size_bytes);
232        }
233
234        // Work out how much we need to decimate
235        let total_rows = self.environment_store.post_bundle_count()?;
236        let decimation_count = (total_rows as f64) * (1f64 - 0.98f64 * max_quota_used / quota_used);
237
238        if 0.0 >= decimation_count {
239            warn!("Decimation count is unexpectedly zero, skipping decimation");
240            return Ok(());
241        }
242
243        // Find a neatish rounding of batches and rows per batch
244        let rows_per_batch = 100f64.min(decimation_count);
245        let num_batches = decimation_count / rows_per_batch;
246        let num_batches = num_batches.ceil() as usize;
247        let rows_per_batch = decimation_count as usize / num_batches;
248
249        info!("Decimation of {}/{} items will be done in {} batches of {} deletes each", decimation_count, total_rows, num_batches, rows_per_batch);
250        let post_bundle_current_size_bytes_before = self.post_bundle_current_size_bytes();
251        info!(
252            "Total size before decimation is {} ({}%)",
253            post_bundle_current_size_bytes_before,
254            100 * post_bundle_current_size_bytes_before / self.environment_dimensions.max_size_bytes
255        );
256
257        for batch in 0..num_batches {
258            if cancellation_token.is_cancelled() {
259                break;
260            }
261
262            // Who will we kill
263            let mut heap_of_locations_to_decimate = BinaryHeap::new();
264            {
265                let random_prefix = Id::random();
266                log::trace!("Decimation batch {} prefix: {}", batch, random_prefix);
267                self.environment_store.post_bundles_last_accessed_iter(&random_prefix).take(5 * rows_per_batch).for_each(|pair| {
268                    match pair {
269                        Ok((location_id, time_millis_bytes)) => {
270                            if heap_of_locations_to_decimate.len() >= rows_per_batch {
271                                let &(most_recent_time_millis_bytes, _) = heap_of_locations_to_decimate.peek().expect("should always work as we have checked the length");
272                                if most_recent_time_millis_bytes > time_millis_bytes {
273                                    heap_of_locations_to_decimate.pop();
274                                    heap_of_locations_to_decimate.push((time_millis_bytes, location_id));
275                                }
276                            }
277                            else {
278                                heap_of_locations_to_decimate.push((time_millis_bytes, location_id));
279                            }
280                        }
281                        Err(e) => {
282                            warn!("Error while decimating: {}", e);
283                        }
284                    }
285                });
286            }
287
288            // Kill the victims
289            {
290                let mut location_ids: Vec<Id> = Vec::new();
291                {
292                    let mut post_bundles_last_touched = self.post_bundles_last_touched_batch.write();
293
294                    for (_time_millis_bytes, location_id) in heap_of_locations_to_decimate {
295                        location_ids.push(location_id);
296                        post_bundles_last_touched.remove(&location_id);
297
298                        let metadata = self.environment_store.post_bundle_metadata_get(&location_id);
299                        if let Ok(metadata) = metadata {
300                            if let Some(metadata) = metadata {
301                                self.post_bundle_current_size_bytes.fetch_sub(metadata.size, Ordering::Relaxed);
302                            }
303                        }
304                    }
305                }
306
307                self.environment_store.post_bundles_delete(&location_ids)?;
308            }
309        }
310
311        let post_bundle_current_size_bytes_after = self.post_bundle_current_size_bytes();
312        info!(
313            "Total size after decimation is {} ({}%): delta={}",
314            post_bundle_current_size_bytes_after,
315            100 * post_bundle_current_size_bytes_after / self.environment_dimensions.max_size_bytes,
316            post_bundle_current_size_bytes_before - post_bundle_current_size_bytes_after
317        );
318
319        Ok(())
320    }
321}
322
323// ----------------------------------------------------------------------------------------------------------------------------------
324
325#[cfg(test)]
326pub mod tests {
327    use crate::environment::environment::{EnvironmentDimensions, EnvironmentFactory, PostBundleMetadata};
328    use hashiverse_lib::anyhow_assert_ge;
329    use hashiverse_lib::tools::tools;
330    use hashiverse_lib::tools::tools::get_temp_dir;
331    use hashiverse_lib::tools::types::Id;
332    use std::sync::Arc;
333    use tokio_util::sync::CancellationToken;
334    use hashiverse_lib::tools::time_provider::time_provider::{ScaledTimeProvider, TimeProvider};
335
336    pub async fn basics_test<TEnvironmentFactory: EnvironmentFactory>() -> anyhow::Result<()> {
337        let time_provider = Arc::new(ScaledTimeProvider::new(60.0));
338        // configure_logging_with_time_provider("trace", time_provider.clone());
339
340        let (_temp_dir, temp_dir_path) = get_temp_dir()?;
341        let environment_dimensions = EnvironmentDimensions::default().with_max_size_bytes(1024 * 1024);
342        let environment_factory = TEnvironmentFactory::new(&temp_dir_path);
343        let environment = environment_factory.open_next_available(environment_dimensions).await?;
344
345        let delete_post_bundle = |location_id: &Id| -> anyhow::Result<()> {
346            let vec = vec![*location_id].to_vec();
347            environment.environment_store.post_bundles_delete(&vec)
348        };
349
350        // Put some metadata
351        {
352            let num_bytes = 16 * 1024;
353            let time_millis = time_provider.current_time_millis();
354            let location_id = Id::random();
355            let post_bundle_metadata = PostBundleMetadata::zero();
356            environment.put_post_bundle_metadata(time_millis, &location_id, &post_bundle_metadata, num_bytes)?;
357
358            let post_bundle_metadata_check = environment.get_post_bundle_metadata(time_millis, &location_id)?;
359            assert_eq!(post_bundle_metadata_check, Some(post_bundle_metadata));
360
361            delete_post_bundle(&location_id)?;
362            let post_bundle_metadata_check = environment.get_post_bundle_metadata(time_millis, &location_id)?;
363            assert_eq!(post_bundle_metadata_check, None);
364        }
365
366        // Put some bytes
367        {
368            let num_bytes = 16 * 1024;
369            let time_millis = time_provider.current_time_millis();
370            let location_id = Id::random();
371            let bytes = tools::random_bytes(num_bytes);
372            environment.put_post_bundle_bytes(time_millis, &location_id, &bytes)?;
373            assert_eq!(num_bytes, environment.post_bundle_current_size_bytes());
374
375            let bytes_check = environment.get_post_bundle_bytes(time_millis, &location_id)?;
376            assert_eq!(bytes_check, Some(Bytes::from(bytes)));
377
378            delete_post_bundle(&location_id)?;
379            let bytes_check = environment.get_post_bundle_bytes(time_millis, &location_id)?;
380            assert_eq!(bytes_check, None);
381        }
382
383        Ok(())
384    }
385
386    fn get_random_post_bundle(max_size: usize) -> (Id, PostBundleMetadata, Vec<u8>) {
387        let num_bytes = tools::random_usize_bounded(max_size);
388        let location_id = Id::random();
389        let bytes = tools::random_bytes(num_bytes);
390        let mut post_bundle_metadata = PostBundleMetadata::zero();
391        post_bundle_metadata.size = num_bytes;
392
393        (location_id, post_bundle_metadata, bytes)
394    }
395
396    pub async fn decimation_test<TEnvironmentFactory: EnvironmentFactory>() -> anyhow::Result<()> {
397        let time_provider = Arc::new(ScaledTimeProvider::new(60.0));
398        // configure_logging_with_time_provider("trace", time_provider.clone());
399        let cancellation_token = CancellationToken::new();
400
401        let (_temp_dir, temp_dir_path) = get_temp_dir()?;
402        let environment_dimensions = EnvironmentDimensions::default().with_max_size_bytes(4 * 1024 * 1024);
403        let environment_factory = TEnvironmentFactory::new(&temp_dir_path);
404        let environment = Arc::new(environment_factory.open_next_available(environment_dimensions).await?);
405
406        // Post to many locations
407        let num_iterations = 10000;
408        for i in 0..num_iterations {
409            let time_millis = time_provider.current_time_millis();
410            let (location_id, post_bundle_metadata, _bytes) = get_random_post_bundle(8 * 1024);
411
412            environment.put_post_bundle_metadata(time_millis, &location_id, &post_bundle_metadata, post_bundle_metadata.size)?;
413            // We dont actually have to write the bundles to disk for this test - we just want to confirm the eviction is taking place...
414            // environment.put_post_bundle_bytes(time_millis, &location_id, &bytes)?;
415
416            if 0 == i % 10 {
417                environment.do_maintenance(&cancellation_token, time_millis).await?;
418            }
419        }
420
421        {
422            let time_millis = time_provider.current_time_millis();
423            environment.do_maintenance(&cancellation_token, time_millis).await?;
424
425            anyhow_assert_ge!(environment_dimensions.max_size_bytes, environment.post_bundle_current_size_bytes());
426        }
427
428        Ok(())
429    }
430
431    pub async fn decimation_convergence_test<TEnvironmentFactory: EnvironmentFactory>(num_posts: usize) -> anyhow::Result<()> {
432        let time_provider = Arc::new(ScaledTimeProvider::new(60.0));
433        // configure_logging_with_time_provider("trace", time_provider.clone());
434        let cancellation_token = CancellationToken::new();
435
436        let (_temp_dir, temp_dir_path) = get_temp_dir()?;
437        let environment_dimensions = EnvironmentDimensions::default().with_max_size_bytes(4 * 1024 * 1024);
438        let environment_factory = TEnvironmentFactory::new(&temp_dir_path);
439        let environment = Arc::new(environment_factory.open_next_available(environment_dimensions).await?);
440
441        // Post to many locations
442        for i in 0..num_posts {
443            let time_millis = time_provider.current_time_millis();
444            let (location_id, post_bundle_metadata, _bytes) = get_random_post_bundle(1024);
445
446            environment.put_post_bundle_metadata(time_millis, &location_id, &post_bundle_metadata, post_bundle_metadata.size)?;
447            // We dont actually have to write the bundles to disk for this test - we just want to confirm the eviction is taking place...
448            //environment.put_post_bundle_bytes(time_millis, &location_id, &bytes)?;
449
450            if 0 == i % 100 {
451                environment.do_maintenance(&cancellation_token, time_millis).await?;
452            }
453        }
454
455        {
456            let time_millis = time_provider.current_time_millis();
457            environment.do_maintenance(&cancellation_token, time_millis).await?;
458            assert!(environment.post_bundle_current_size_bytes() < environment_dimensions.max_size_bytes * 11 / 10);
459        }
460
461        Ok(())
462    }
463
464    pub async fn decimation_existence_test<TEnvironmentFactory: EnvironmentFactory>() -> anyhow::Result<()> {
465        let time_provider = Arc::new(ScaledTimeProvider::new(60.0));
466        // configure_logging_with_time_provider("trace", time_provider.clone());
467        let cancellation_token = CancellationToken::new();
468
469        let (_temp_dir, temp_dir_path) = get_temp_dir()?;
470        let environment_dimensions = EnvironmentDimensions::default().with_max_size_bytes(4 * 1024 * 1024);
471        let environment_factory = TEnvironmentFactory::new(&temp_dir_path);
472        let environment = Arc::new(environment_factory.open_next_available(environment_dimensions).await?);
473
474        let time_millis = time_provider.current_time_millis();
475
476        let location_id_should_exist_metadata = {
477            let (location_id, post_bundle_metadata, bytes) = get_random_post_bundle(1024);
478            environment.put_post_bundle_metadata(time_millis, &location_id, &post_bundle_metadata, post_bundle_metadata.size)?;
479            environment.put_post_bundle_bytes(time_millis, &location_id, &bytes)?;
480            location_id
481        };
482
483        let location_id_should_exist_bytes = {
484            let (location_id, post_bundle_metadata, bytes) = get_random_post_bundle(1024);
485            environment.put_post_bundle_metadata(time_millis, &location_id, &post_bundle_metadata, post_bundle_metadata.size)?;
486            environment.put_post_bundle_bytes(time_millis, &location_id, &bytes)?;
487            location_id
488        };
489
490        let location_id_should_not_exist = {
491            let (location_id, post_bundle_metadata, bytes) = get_random_post_bundle(1024);
492            environment.put_post_bundle_metadata(time_millis, &location_id, &post_bundle_metadata, post_bundle_metadata.size)?;
493            environment.put_post_bundle_bytes(time_millis, &location_id, &bytes)?;
494            location_id
495        };
496
497        // Post to many locations
498        let num_iterations = 128 * 1000;
499        for i in 0..num_iterations {
500            let time_millis = time_provider.current_time_millis();
501            let (location_id, post_bundle_metadata, _bytes) = get_random_post_bundle(1024);
502
503            environment.put_post_bundle_metadata(time_millis, &location_id, &post_bundle_metadata, post_bundle_metadata.size)?;
504            // We dont actually have to write the bundles to disk for this test - we just want to confirm the eviction is taking place...
505            //environment.put_post_bundle_bytes(time_millis, &location_id, &bytes)?;
506
507            // Lets keep some posts active
508            if 0 == i % 30 {
509                let _ = environment.get_post_bundle_metadata(time_millis, &location_id_should_exist_metadata)?;
510                let _ = environment.get_post_bundle_bytes(time_millis, &location_id_should_exist_bytes);
511            }
512
513            if 0 == i % 100 {
514                environment.do_maintenance(&cancellation_token, time_millis).await?;
515            }
516        }
517
518        {
519            let time_millis = time_provider.current_time_millis();
520            environment.do_maintenance(&cancellation_token, time_millis).await?;
521            assert!(environment.post_bundle_current_size_bytes() < environment_dimensions.max_size_bytes * 11 / 10);
522        }
523
524        // Check the things that should still exist
525        {
526            // Existence by metadata queries
527            {
528                let result = environment.get_post_bundle_metadata(time_millis, &location_id_should_exist_metadata)?;
529                assert!(result.is_some());
530            }
531            {
532                let result = environment.get_post_bundle_bytes(time_millis, &location_id_should_exist_metadata)?;
533                assert!(result.is_some());
534            }
535        }
536        {
537            // Existence by bytes queries
538            {
539                let result = environment.get_post_bundle_metadata(time_millis, &location_id_should_exist_bytes)?;
540                assert!(result.is_some());
541            }
542            {
543                let result = environment.get_post_bundle_bytes(time_millis, &location_id_should_exist_bytes)?;
544                assert!(result.is_some());
545            }
546        }
547
548        // Check the thing that should have died
549        {
550            {
551                let result = environment.get_post_bundle_metadata(time_millis, &location_id_should_not_exist)?;
552                assert!(result.is_none());
553            }
554            {
555                let result = environment.get_post_bundle_bytes(time_millis, &location_id_should_not_exist)?;
556                assert!(result.is_none());
557            }
558        }
559
560        Ok(())
561    }
562
563    pub async fn decimation_feedback_deleted_test<TEnvironmentFactory: EnvironmentFactory>() -> anyhow::Result<()> {
564        let time_provider = Arc::new(ScaledTimeProvider::new(60.0));
565        let cancellation_token = CancellationToken::new();
566
567        let (_temp_dir, temp_dir_path) = get_temp_dir()?;
568        let environment_dimensions = EnvironmentDimensions::default().with_max_size_bytes(4 * 1024 * 1024);
569        let environment_factory = TEnvironmentFactory::new(&temp_dir_path);
570        let environment = Arc::new(environment_factory.open_next_available(environment_dimensions).await?);
571        let store = &environment.environment_store;
572
573        let time_millis = time_provider.current_time_millis();
574
575        // Create a bundle and attach feedback to it
576        let (location_id, post_bundle_metadata, bytes) = get_random_post_bundle(1024);
577        environment.put_post_bundle_metadata(time_millis, &location_id, &post_bundle_metadata, post_bundle_metadata.size)?;
578        environment.put_post_bundle_bytes(time_millis, &location_id, &bytes)?;
579
580        let feedback = EncodedPostFeedbackV1::new(Id::random(), 1, Salt::random(), Pow(10));
581        store.post_feedback_put_if_more_powerful(&location_id, &feedback)?;
582        assert!(!store.post_bundle_feedbacks_bytes_get(&location_id)?.is_empty());
583
584        // Flood with bundles (never re-access ours) to trigger decimation
585        for i in 0..128 * 1000usize {
586            let time_millis = time_provider.current_time_millis();
587            let (flood_id, flood_meta, _) = get_random_post_bundle(1024);
588            environment.put_post_bundle_metadata(time_millis, &flood_id, &flood_meta, flood_meta.size)?;
589            if i % 100 == 0 {
590                environment.do_maintenance(&cancellation_token, time_millis).await?;
591            }
592        }
593        environment.do_maintenance(&cancellation_token, time_provider.current_time_millis()).await?;
594
595        // The bundle must have been evicted
596        assert!(environment.get_post_bundle_metadata(time_millis, &location_id)?.is_none(), "bundle should have been decimated");
597
598        // Its feedback must be gone too
599        assert!(store.post_bundle_feedbacks_bytes_get(&location_id)?.is_empty(), "feedback should have been deleted with the bundle");
600
601        Ok(())
602    }
603
604    // ── feedback tests ────────────────────────────────────────────────────────
605
606    use bytes::{Buf, Bytes};
607    use hashiverse_lib::protocol::posting::encoded_post_feedback::EncodedPostFeedbackV1;
608    use hashiverse_lib::tools::types::{Pow, Salt};
609
610    fn decode_all_feedbacks(mut bytes: Bytes) -> anyhow::Result<Vec<EncodedPostFeedbackV1>> {
611        let mut result = Vec::new();
612        while bytes.has_remaining() {
613            result.push(EncodedPostFeedbackV1::decode_from_bytes(&mut bytes)?);
614        }
615        Ok(result)
616    }
617
618    pub async fn feedback_bytes_get_test<TEnvironmentFactory: EnvironmentFactory>() -> anyhow::Result<()> {
619        let (_temp_dir, temp_dir_path) = get_temp_dir()?;
620        let factory = TEnvironmentFactory::new(&temp_dir_path);
621        let env = factory.open_next_available(EnvironmentDimensions::default()).await?;
622        let store = &env.environment_store;
623
624        let location_id = Id::random();
625        let other_location_id = Id::random();
626
627        // Empty: unknown location returns empty bytes
628        assert!(store.post_bundle_feedbacks_bytes_get(&location_id)?.is_empty());
629
630        let f1 = EncodedPostFeedbackV1::new(Id::random(), 1, Salt::random(), Pow(10));
631        let f2 = EncodedPostFeedbackV1::new(Id::random(), 2, Salt::random(), Pow(20));
632        let f_other = EncodedPostFeedbackV1::new(Id::random(), 1, Salt::random(), Pow(5));
633
634        store.post_feedback_put_if_more_powerful(&location_id, &f1)?;
635        store.post_feedback_put_if_more_powerful(&location_id, &f2)?;
636        store.post_feedback_put_if_more_powerful(&other_location_id, &f_other)?;
637
638        // Correct entries returned for location_id
639        let bytes = store.post_bundle_feedbacks_bytes_get(&location_id)?;
640        let mut decoded = decode_all_feedbacks(bytes)?;
641        decoded.sort_by_key(|f| f.feedback_type);
642        assert_eq!(decoded.len(), 2);
643        assert_eq!(decoded[0], f1);
644        assert_eq!(decoded[1], f2);
645
646        // Other location unaffected
647        let other_bytes = store.post_bundle_feedbacks_bytes_get(&other_location_id)?;
648        let other_decoded = decode_all_feedbacks(other_bytes)?;
649        assert_eq!(other_decoded.len(), 1);
650        assert_eq!(other_decoded[0], f_other);
651
652        Ok(())
653    }
654
655    pub async fn feedback_put_if_more_powerful_test<TEnvironmentFactory: EnvironmentFactory>() -> anyhow::Result<()> {
656        let (_temp_dir, temp_dir_path) = get_temp_dir()?;
657        let factory = TEnvironmentFactory::new(&temp_dir_path);
658        let env = factory.open_next_available(EnvironmentDimensions::default()).await?;
659        let store = &env.environment_store;
660
661        let location_id = Id::random();
662        let post_id = Id::random();
663        let feedback_type = 3u8;
664
665        let get_single = |pow_expected: Pow| -> anyhow::Result<()> {
666            let bytes = store.post_bundle_feedbacks_bytes_get(&location_id)?;
667            let decoded = decode_all_feedbacks(bytes)?;
668            assert_eq!(decoded.len(), 1);
669            assert_eq!(decoded[0].post_id, post_id);
670            assert_eq!(decoded[0].feedback_type, feedback_type);
671            assert_eq!(decoded[0].pow, pow_expected);
672            Ok(())
673        };
674
675        // First put: stored
676        let f_low = EncodedPostFeedbackV1::new(post_id, feedback_type, Salt::random(), Pow(10));
677        store.post_feedback_put_if_more_powerful(&location_id, &f_low)?;
678        get_single(Pow(10))?;
679
680        // Higher pow: replaces
681        let f_high = EncodedPostFeedbackV1::new(post_id, feedback_type, Salt::random(), Pow(20));
682        store.post_feedback_put_if_more_powerful(&location_id, &f_high)?;
683        get_single(Pow(20))?;
684
685        // Equal pow: existing kept (salt unchanged)
686        let salt_before = decode_all_feedbacks(store.post_bundle_feedbacks_bytes_get(&location_id)?)?[0].salt;
687        let f_equal = EncodedPostFeedbackV1::new(post_id, feedback_type, Salt::random(), Pow(20));
688        store.post_feedback_put_if_more_powerful(&location_id, &f_equal)?;
689        let salt_after = decode_all_feedbacks(store.post_bundle_feedbacks_bytes_get(&location_id)?)?[0].salt;
690        assert_eq!(salt_before, salt_after);
691
692        // Lower pow: existing kept
693        let f_weaker = EncodedPostFeedbackV1::new(post_id, feedback_type, Salt::random(), Pow(5));
694        store.post_feedback_put_if_more_powerful(&location_id, &f_weaker)?;
695        get_single(Pow(20))?;
696
697        // Different feedback_type for same post: stored as a separate entry
698        let f_other_type = EncodedPostFeedbackV1::new(post_id, feedback_type + 1, Salt::random(), Pow(1));
699        store.post_feedback_put_if_more_powerful(&location_id, &f_other_type)?;
700        let all = decode_all_feedbacks(store.post_bundle_feedbacks_bytes_get(&location_id)?)?;
701        assert_eq!(all.len(), 2);
702
703        // Different post_id for same feedback_type: stored as a separate entry
704        let f_other_post = EncodedPostFeedbackV1::new(Id::random(), feedback_type, Salt::random(), Pow(1));
705        store.post_feedback_put_if_more_powerful(&location_id, &f_other_post)?;
706        let all = decode_all_feedbacks(store.post_bundle_feedbacks_bytes_get(&location_id)?)?;
707        assert_eq!(all.len(), 3);
708
709        // Different location_id: does not affect original location
710        let f_other_loc = EncodedPostFeedbackV1::new(post_id, feedback_type, Salt::random(), Pow(99));
711        store.post_feedback_put_if_more_powerful(&Id::random(), &f_other_loc)?;
712        let all_original = decode_all_feedbacks(store.post_bundle_feedbacks_bytes_get(&location_id)?)?;
713        assert_eq!(all_original.len(), 3);
714
715        Ok(())
716    }
717}