Skip to main content

hashiverse_lib/tools/
buckets.rs

1//! # Hierarchical time buckets for post storage and discovery
2//!
3//! Posts on hashiverse aren't stored by post id — they are stored inside *buckets* keyed
4//! by `(bucket_type, base_id, duration, bucket_time_millis)`, and every bucket deterministically
5//! hashes down to a single [`Id`] known as its `location_id`. That location_id is what the
6//! Kademlia DHT uses to decide which servers are responsible for holding the bucket.
7//!
8//! ## Bucket types
9//!
10//! [`BucketType`] picks what the bucket is *about*:
11//! - `User` — posts authored by a specific client id
12//! - `Hashtag` — posts tagged with a given hashtag id
13//! - `Mention` — posts that mention a given client id
14//! - `ReplyToPost` — replies to a specific parent post
15//! - `Sequel` — long-running threads that continue from an earlier post
16//!
17//! ## Duration hierarchy
18//!
19//! [`BUCKET_DURATIONS`] is a coarse-to-fine ladder (year → month → week → day → 6h → 1h →
20//! 15m → 5m → 1m) chosen so that each level fans out ~4–6x into the next. When a bucket
21//! overflows at one granularity the client recurses into the finer granularity; the fan-out
22//! factor keeps the worst case from exploding into thousands of tiny buckets.
23//!
24//! Sequel buckets start at year granularity (cheaper discoverability for long-lived threads);
25//! all other types start at month so that a single location_id isn't responsible for a full
26//! year of heavy activity — see [`bucket_durations_for_type`].
27//!
28//! ## Types
29//!
30//! - [`Bucket`] — a duration-only description of a bucket.
31//! - [`BucketLocation`] — the full `(bucket_type, base_id, duration, bucket_time_millis,
32//!   location_id)` tuple with deterministic hashing, self-verification via
33//!   [`BucketLocation::validate`], and a versioned tilde-delimited serialisation for use in
34//!   HTML attributes and URLs.
35
36use crate::tools::{config, hashing};
37use crate::tools::time::{DurationMillis, MILLIS_IN_DAY, MILLIS_IN_HOUR, MILLIS_IN_MINUTE, MILLIS_IN_MONTH, MILLIS_IN_WEEK, MILLIS_IN_YEAR, TimeMillis};
38use crate::tools::types::Id;
39use derive_more::Display;
40use serde::{Deserialize, Serialize};
41
42pub struct Bucket {
43    pub duration: DurationMillis,
44}
45
46#[repr(u8)]
47#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy, Display)]
48pub enum BucketType {
49    User = 0,
50    Hashtag = 1,
51    Mention = 2,
52    ReplyToPost = 3,
53    Sequel = 4,
54}
55
56#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Display)]
57#[display("[ location_id: {}, bucket_type: {}, base_id: {}, duration: {}, bucket_time_millis: {}]", location_id, bucket_type, base_id, duration, bucket_time_millis)]
58pub struct BucketLocation {
59    pub bucket_type: BucketType,
60    pub base_id: Id,
61    pub duration: DurationMillis, // The bucket granularity
62    pub bucket_time_millis: TimeMillis,    // The timestamp at the beginning of the bucket (i.e. rounded down by duration)
63    pub location_id: Id,          // The resulting location_id
64}
65
66impl BucketLocation {
67    pub fn round_down_to_bucket_start(timestamp: TimeMillis, duration: DurationMillis) -> TimeMillis {
68        TimeMillis((timestamp.0 / duration.0) * duration.0)
69    }
70
71    pub fn new(bucket_type: BucketType, base_id: Id, duration: DurationMillis, timestamp: TimeMillis) -> anyhow::Result<Self> {
72        let bucket_time_millis = Self::round_down_to_bucket_start(timestamp, duration);
73
74        let duration_be = duration.encode_be();
75        let bucket_time_millis_be = bucket_time_millis.encode_be();
76
77        let hash = hashing::hash_multiple(&[&[bucket_type as u8], base_id.as_ref(), duration_be.as_ref(), bucket_time_millis_be.as_ref()]);
78        let location_id = Id::from_hash(hash)?;
79
80        Ok(BucketLocation { bucket_type, base_id, duration, bucket_time_millis, location_id })
81    }
82    pub fn get_hash_for_signing(&self) -> crate::tools::types::Hash {
83        let bucket_type_byte = [self.bucket_type as u8];
84        let duration_be = self.duration.encode_be();
85        let bucket_time_be = self.bucket_time_millis.encode_be();
86        hashing::hash_multiple(&[&bucket_type_byte, self.base_id.as_ref(), duration_be.as_ref(), bucket_time_be.as_ref()])
87    }
88
89    /// A bucket is sealed (no more posts will be accepted) once it has overflowed, or once enough time has elapsed past its end.
90    pub fn is_sealed_at(&self, time_millis: TimeMillis, overflowed: bool) -> bool {
91        overflowed || time_millis > self.bucket_time_millis + self.duration + config::ENCODED_POST_BUNDLE_V1_ELAPSED_THRESHOLD_MILLIS
92    }
93
94    pub fn validate(&self) -> anyhow::Result<()> {
95        let other = Self::new(self.bucket_type, self.base_id, self.duration, self.bucket_time_millis)?;
96        if self.location_id != other.location_id {
97            anyhow::bail!("BucketLocation validation failed");
98        }
99        Ok(())
100    }
101
102    /// Serialise to a versioned, tilde-delimited string safe for use as an HTML attribute value or URL segment.
103    /// Format: `{version}~{bucket_type}~{base_id_hex}~{duration}~{bucket_time_millis}`
104    /// Example: `1~User~aabb...cc~1D~20240115.123045.000`
105    /// `location_id` is omitted — it is recomputed from the other fields on deserialisation.
106    /// Future versions may append additional tilde-separated fields; readers must ignore unknown fields.
107    pub fn to_html_attr(&self) -> String {
108        format!("1~{}~{}~{}~{}", self.bucket_type, self.base_id.to_hex_str(), self.duration, self.bucket_time_millis)
109    }
110
111    /// Deserialise from the format produced by [`BucketLocation::to_html_attr`].
112    pub fn from_html_attr(s: &str) -> anyhow::Result<Self> {
113        let parts: Vec<&str> = s.splitn(6, '~').collect();
114        anyhow::ensure!(parts.len() >= 5, "Invalid BucketLocation attr: expected at least 5 tilde-separated parts");
115        anyhow::ensure!(parts[0] == "1", "Unsupported BucketLocation attr version: {}", parts[0]);
116
117        let bucket_type = match parts[1] {
118            "User" => BucketType::User,
119            "Hashtag" => BucketType::Hashtag,
120            "Mention" => BucketType::Mention,
121            "ReplyToPost" => BucketType::ReplyToPost,
122            "Sequel" => BucketType::Sequel,
123            other => anyhow::bail!("Unknown BucketType: {}", other),
124        };
125        let base_id = Id::from_hex_str(parts[2])?;
126        let duration = DurationMillis::parse(parts[3])?;
127        let bucket_time_millis = TimeMillis::parse(parts[4])?;
128
129        Self::new(bucket_type, base_id, duration, bucket_time_millis)
130    }
131}
132pub fn generate_bucket_location(bucket_type: BucketType, base_id: Id, bucket_duration: DurationMillis, time_millis: TimeMillis) -> anyhow::Result<BucketLocation> {
133        BucketLocation::new(bucket_type, base_id, bucket_duration, time_millis)
134    }
135
136/// These are the durations of the hierarchical buckets that posts will collect in.
137/// They are spaced such that there are always roughly 4-6 "more granular" buckets per "less granular" bucket.
138/// This ensures that no overflowed bucket would result in many tiny buckets needing to be scanned at the next level.
139/// Also make sure that the "less granular" bucket is a round multiple of its "more` granular" successor.
140pub const BUCKET_DURATIONS: [DurationMillis; 9] = [MILLIS_IN_YEAR, MILLIS_IN_MONTH, MILLIS_IN_WEEK, MILLIS_IN_DAY, MILLIS_IN_HOUR.const_mul(6), MILLIS_IN_HOUR, MILLIS_IN_MINUTE.const_mul(15), MILLIS_IN_MINUTE.const_mul(5), MILLIS_IN_MINUTE];
141
142/// Returns the appropriate bucket duration slice for a given bucket type.
143/// Sequel buckets start at year granularity for cheaper discoverability.
144/// All other bucket types start at month granularity so that a single location_id (set of servers) is not responsible for a year of potentially high activity.
145pub fn bucket_durations_for_type(bucket_type: BucketType) -> &'static [DurationMillis] {
146    match bucket_type {
147        BucketType::Sequel => &BUCKET_DURATIONS,       // starts at year
148        _ => &BUCKET_DURATIONS[1..],                    // starts at month
149    }
150}
151
152
153#[cfg(test)]
154pub mod tests {
155    use crate::tools::buckets::{bucket_durations_for_type, BucketLocation, BucketType, BUCKET_DURATIONS};
156    use crate::tools::config;
157    use crate::tools::time::{TimeMillis, MILLIS_IN_DAY, MILLIS_IN_MILLISECOND, MILLIS_IN_MONTH, MILLIS_IN_YEAR};
158    use crate::tools::types::{Id, ID_BYTES};
159
160    #[tokio::test]
161    async fn ensure_bucket_duration_multiples_test() -> anyhow::Result<()> {
162        for i in 0..BUCKET_DURATIONS.len()-1 {
163            assert_eq!(0, BUCKET_DURATIONS[i].0 % BUCKET_DURATIONS[i+1].0)
164        }
165        Ok(())
166    }
167
168    /// `BucketType`'s `Display` is part of the `to_html_attr` wire format and `BucketLocation`'s
169    /// `Display` comes from a `derive_more` format attribute — pin both so a macro-crate bump
170    /// cannot silently change them.
171    #[tokio::test]
172    async fn bucket_type_and_location_display_are_stable() -> anyhow::Result<()> {
173        assert_eq!(BucketType::User.to_string(), "User");
174        assert_eq!(BucketType::Hashtag.to_string(), "Hashtag");
175        assert_eq!(BucketType::Mention.to_string(), "Mention");
176        assert_eq!(BucketType::ReplyToPost.to_string(), "ReplyToPost");
177        assert_eq!(BucketType::Sequel.to_string(), "Sequel");
178
179        let base_id = Id::from_slice(&[0xab; ID_BYTES])?;
180        let bucket_time_millis = TimeMillis(1_700_000_000_000);
181        let bucket_location = BucketLocation::new(BucketType::Hashtag, base_id, MILLIS_IN_DAY, bucket_time_millis)?;
182        assert_eq!(
183            bucket_location.to_string(),
184            format!("[ location_id: {}, bucket_type: Hashtag, base_id: {}, duration: {}, bucket_time_millis: {}]", bucket_location.location_id, base_id, MILLIS_IN_DAY, bucket_location.bucket_time_millis)
185        );
186        assert_eq!(bucket_location.to_html_attr(), format!("1~Hashtag~{}~{}~{}", "ab".repeat(ID_BYTES), MILLIS_IN_DAY, bucket_location.bucket_time_millis));
187        Ok(())
188    }
189
190    #[tokio::test]
191    async fn bucket_location_html_attr_round_trip() -> anyhow::Result<()> {
192        let base_id = Id::random();
193        let original = BucketLocation::new(BucketType::Hashtag, base_id, MILLIS_IN_DAY, TimeMillis(1_700_000_000_000))?;
194        let attr = original.to_html_attr();
195        let restored = BucketLocation::from_html_attr(&attr)?;
196        assert_eq!(original, restored);
197        // Sanity: attr starts with version prefix
198        assert!(attr.starts_with("1~"));
199        Ok(())
200    }
201
202    #[tokio::test]
203    async fn bucket_location_sequel_html_attr_round_trip() -> anyhow::Result<()> {
204        let base_id = Id::random();
205        let original = BucketLocation::new(BucketType::Sequel, base_id, MILLIS_IN_YEAR, TimeMillis(1_700_000_000_000))?;
206        let attr = original.to_html_attr();
207        let restored = BucketLocation::from_html_attr(&attr)?;
208        assert_eq!(original, restored);
209        assert!(attr.contains("Sequel"));
210        Ok(())
211    }
212
213    #[tokio::test]
214    async fn bucket_durations_for_type_sequel_starts_at_year() -> anyhow::Result<()> {
215        let sequel_durations = bucket_durations_for_type(BucketType::Sequel);
216        let user_durations = bucket_durations_for_type(BucketType::User);
217        assert_eq!(sequel_durations[0], MILLIS_IN_YEAR);
218        assert_eq!(user_durations[0], MILLIS_IN_MONTH);
219        assert_eq!(sequel_durations.len(), user_durations.len() + 1);
220        Ok(())
221    }
222
223    #[tokio::test]
224    async fn is_sealed_at_test() -> anyhow::Result<()> {
225        let bucket_location = BucketLocation::new(BucketType::Hashtag, Id::random(), MILLIS_IN_DAY, TimeMillis(10 * MILLIS_IN_DAY.0))?;
226        let bucket_end_millis = bucket_location.bucket_time_millis + bucket_location.duration;
227
228        // Not sealed during the bucket, nor during the grace period after it ends
229        assert!(!bucket_location.is_sealed_at(bucket_location.bucket_time_millis, false));
230        assert!(!bucket_location.is_sealed_at(bucket_end_millis, false));
231        assert!(!bucket_location.is_sealed_at(bucket_end_millis + config::ENCODED_POST_BUNDLE_V1_ELAPSED_THRESHOLD_MILLIS, false));
232
233        // Sealed once the grace period has passed
234        assert!(bucket_location.is_sealed_at(bucket_end_millis + config::ENCODED_POST_BUNDLE_V1_ELAPSED_THRESHOLD_MILLIS + MILLIS_IN_MILLISECOND, false));
235
236        // Overflow seals immediately
237        assert!(bucket_location.is_sealed_at(bucket_location.bucket_time_millis, true));
238        Ok(())
239    }
240}