1use 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, pub bucket_time_millis: TimeMillis, pub location_id: Id, }
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 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 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 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
136pub 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
142pub fn bucket_durations_for_type(bucket_type: BucketType) -> &'static [DurationMillis] {
146 match bucket_type {
147 BucketType::Sequel => &BUCKET_DURATIONS, _ => &BUCKET_DURATIONS[1..], }
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 #[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 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 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 assert!(bucket_location.is_sealed_at(bucket_end_millis + config::ENCODED_POST_BUNDLE_V1_ELAPSED_THRESHOLD_MILLIS + MILLIS_IN_MILLISECOND, false));
235
236 assert!(bucket_location.is_sealed_at(bucket_location.bucket_time_millis, true));
238 Ok(())
239 }
240}