Skip to main content

hashiverse_lib/client/timeline/
recursive_bucket_visitor.rs

1//! # Depth-first walk over the hierarchical bucket ladder
2//!
3//! The pure, storage-free algorithm behind every timeline. Given a time range and the
4//! bucket-duration sequence from [`crate::tools::buckets::BUCKET_DURATIONS`],
5//! `RecursiveBucketVisitor` visits buckets in depth-first order — coarse granularity
6//! first, drilling into finer granularity where the open callback asks for it.
7//!
8//! Two callbacks steer the walk:
9//!
10//! - **Open** — called before descending into a bucket. Returns
11//!   `ContinueWithChildren`, `ContinueWithoutChildren` (skip this subtree — empty
12//!   buckets), or `Stop` (abort the whole walk — enough posts collected).
13//! - **Close** — called when leaving a bucket, so timelines can decide whether to
14//!   back off to coarser granularity or continue stepping within the current level.
15//!
16//! Whatever payload the open callback returns alongside `ContinueWith*` is held on the
17//! stack and handed to that same bucket's close callback (opens and closes nest strictly),
18//! so a caller never has to fetch a bucket's data twice.
19//!
20//! The visitor has zero knowledge of posts, caches, networks, or deduplication — it
21//! is a plain tree walker; everything else is implemented on top of it by
22//! [`crate::client::timeline::single_timeline::SingleTimeline`].
23
24use crate::tools::buckets::BucketLocation;
25use crate::tools::time::{DurationMillis, TimeMillis};
26
27pub enum RecursiveBucketVisitorOpenCallbackResult<T> {
28    ContinueWithChildren(T),
29    ContinueWithoutChildren(T),
30    Stop,
31}
32
33pub enum RecursiveBucketVisitorCloseCallbackResult {
34    Continue,
35    Stop,
36}
37
38pub struct RecursiveBucketVisitor {}
39
40impl RecursiveBucketVisitor {
41    pub async fn visit<T, FOpen, FClose>(time_millis_max_exclusive: TimeMillis, time_millis_min: TimeMillis, bucket_durations: &[DurationMillis], on_bucket_open: &mut FOpen, on_bucket_close: &mut FClose) -> anyhow::Result<bool>
42    where
43        FOpen: AsyncFnMut(TimeMillis, DurationMillis) -> anyhow::Result<RecursiveBucketVisitorOpenCallbackResult<T>>,
44        FClose: AsyncFnMut(TimeMillis, DurationMillis, T) -> anyhow::Result<RecursiveBucketVisitorCloseCallbackResult>,
45    {
46        struct FrameOpen {
47            bucket_time_millis: TimeMillis,
48            bucket_duration_i: usize,
49            oldest_bucket_time_millis_allowed: TimeMillis,
50        }
51
52        struct FrameClose<T> {
53            bucket_time_millis: TimeMillis,
54            bucket_duration_i: usize,
55            payload: T,
56        }
57
58        enum Frame<T> {
59            Open(FrameOpen),
60            Close(FrameClose<T>),
61        }
62
63        anyhow::ensure!(!bucket_durations.is_empty(), "bucket_durations must have at least one element");
64
65        let mut frames = Vec::new();
66
67        // Prime the stack
68        {
69            let next_bucket_duration_i = 0;
70            let next_bucket_duration = bucket_durations[next_bucket_duration_i];
71
72            let next_latest_bucket_time_millis = BucketLocation::round_down_to_bucket_start(time_millis_max_exclusive, next_bucket_duration);
73            let next_oldest_bucket_time_millis_allowed = BucketLocation::round_down_to_bucket_start(time_millis_min, next_bucket_duration);
74
75            frames.push(Frame::Open(FrameOpen {
76                bucket_time_millis: next_latest_bucket_time_millis,
77                bucket_duration_i: next_bucket_duration_i,
78                oldest_bucket_time_millis_allowed: next_oldest_bucket_time_millis_allowed,
79            }));
80        }
81
82        // Pump the stack
83        while let Some(frame) = frames.pop() {
84            match frame {
85                Frame::Close(FrameClose { bucket_time_millis, bucket_duration_i, payload }) => {
86                    let bucket_duration = bucket_durations[bucket_duration_i];
87                    let callback_result = on_bucket_close(bucket_time_millis, bucket_duration, payload).await?;
88                    if let RecursiveBucketVisitorCloseCallbackResult::Stop = callback_result {
89                        return Ok(false);
90                    };
91                }
92
93                Frame::Open(FrameOpen {
94                    bucket_time_millis,
95                    bucket_duration_i,
96                    oldest_bucket_time_millis_allowed,
97                }) => {
98                    let bucket_duration = bucket_durations[bucket_duration_i];
99
100                    // Push our next sibling onto the stack
101                    {
102                        let next_bucket_duration_i = bucket_duration_i;
103                        let next_bucket_time_millis = bucket_time_millis - bucket_duration;
104                        let next_oldest_bucket_time_millis_allowed = oldest_bucket_time_millis_allowed;
105
106                        if next_bucket_time_millis >= oldest_bucket_time_millis_allowed {
107                            frames.push(Frame::Open(FrameOpen {
108                                bucket_time_millis: next_bucket_time_millis,
109                                bucket_duration_i: next_bucket_duration_i,
110                                oldest_bucket_time_millis_allowed: next_oldest_bucket_time_millis_allowed,
111                            }));
112                        }
113                    }
114
115                    // Is this bucket too recent to be allowed? (sometimes they request exactly the start of the next bucket - e.g. 1M - we dont want to include that month)
116                    if bucket_time_millis >= time_millis_max_exclusive {
117                        continue;
118                    }
119
120                    let (payload, continue_with_children) = match on_bucket_open(bucket_time_millis, bucket_duration).await? {
121                        RecursiveBucketVisitorOpenCallbackResult::ContinueWithChildren(payload) => (payload, true),
122                        RecursiveBucketVisitorOpenCallbackResult::ContinueWithoutChildren(payload) => (payload, false),
123                        // If the user has elected to bail early
124                        RecursiveBucketVisitorOpenCallbackResult::Stop => return Ok(false),
125                    };
126
127                    // Push our close onto the stack - carrying the open's payload to it
128                    frames.push(Frame::Close(FrameClose { bucket_time_millis, bucket_duration_i, payload }));
129
130                    // If the user wants to go more granular
131                    if continue_with_children {
132                        let next_bucket_duration_i = bucket_duration_i + 1;
133                        if next_bucket_duration_i < bucket_durations.len() {
134                            let next_bucket_duration = bucket_durations[next_bucket_duration_i];
135                            let time_millis_max_bucket = BucketLocation::round_down_to_bucket_start(time_millis_max_exclusive, next_bucket_duration);
136
137                            let next_bucket_time_millis = time_millis_max_bucket.min(bucket_time_millis + bucket_duration - next_bucket_duration);
138                            let next_oldest_bucket_time_millis_allowed = bucket_time_millis;
139
140                            frames.push(Frame::Open(FrameOpen {
141                                bucket_time_millis: next_bucket_time_millis,
142                                bucket_duration_i: next_bucket_duration_i,
143                                oldest_bucket_time_millis_allowed: next_oldest_bucket_time_millis_allowed,
144                            }));
145                        }
146                    }
147                }
148            }
149        }
150
151        Ok(true)
152    }
153}
154
155#[cfg(test)]
156pub mod tests {
157    use crate::client::timeline::recursive_bucket_visitor::{RecursiveBucketVisitor, RecursiveBucketVisitorCloseCallbackResult, RecursiveBucketVisitorOpenCallbackResult};
158    use crate::tools::buckets::BUCKET_DURATIONS;
159    use crate::tools::time::{DurationMillis, MILLIS_IN_DAY, MILLIS_IN_MILLISECOND, MILLIS_IN_MONTH, MILLIS_IN_WEEK, TimeMillis};
160    use log::info;
161
162    #[tokio::test]
163    async fn visitor_test() -> anyhow::Result<()> {
164        // configure_logging();
165
166        {
167            info!("Just at a new month (so only one month in view)");
168            let mut total_buckets = 0;
169
170            RecursiveBucketVisitor::visit(
171                TimeMillis::zero() + MILLIS_IN_MONTH,
172                TimeMillis::zero(),
173                &BUCKET_DURATIONS[1..3],
174                &mut async |time_millis, duration_millis| {
175                    info!("time_millis: {}, duration_millis: {}", time_millis, duration_millis);
176                    total_buckets += 1;
177                    Ok(RecursiveBucketVisitorOpenCallbackResult::ContinueWithChildren(()))
178                },
179                &mut async |_time_millis: TimeMillis, _duration_millis: DurationMillis, _payload: ()| Ok(RecursiveBucketVisitorCloseCallbackResult::Continue),
180            )
181            .await?;
182
183            assert_eq!(total_buckets, 5);
184        }
185
186        {
187            info!("Just after a new month (one entire month and the beginning of one month in view)");
188            let mut total_buckets = 0;
189
190            RecursiveBucketVisitor::visit(
191                TimeMillis::zero() + MILLIS_IN_MONTH + MILLIS_IN_MILLISECOND,
192                TimeMillis::zero(),
193                &BUCKET_DURATIONS[1..3],
194                &mut async |time_millis: TimeMillis, duration_millis: DurationMillis| {
195                    info!("time_millis: {}, duration_millis: {}", time_millis, duration_millis);
196                    total_buckets += 1;
197                    Ok(RecursiveBucketVisitorOpenCallbackResult::ContinueWithChildren(()))
198                },
199                &mut async |_time_millis: TimeMillis, _duration_millis: DurationMillis, _payload: ()| Ok(RecursiveBucketVisitorCloseCallbackResult::Continue),
200            )
201            .await?;
202
203            assert_eq!(total_buckets, 7);
204        }
205
206        {
207            info!("Just at a new month plus a 2 weeks plus 3 days");
208            let mut total_buckets = 0;
209
210            RecursiveBucketVisitor::visit(
211                TimeMillis::zero() + MILLIS_IN_MONTH + MILLIS_IN_WEEK.const_mul(2) + MILLIS_IN_DAY.const_mul(3),
212                TimeMillis::zero(),
213                &BUCKET_DURATIONS[1..4],
214                &mut async |time_millis, duration_millis| {
215                    info!("time_millis: {}, duration_millis: {}", time_millis, duration_millis);
216                    total_buckets += 1;
217                    Ok(RecursiveBucketVisitorOpenCallbackResult::ContinueWithChildren(()))
218                },
219                &mut async |_time_millis: TimeMillis, _duration_millis: DurationMillis, _payload: ()| Ok(RecursiveBucketVisitorCloseCallbackResult::Continue),
220            )
221            .await?;
222
223            assert_eq!(total_buckets, 54);
224        }
225
226        Ok(())
227    }
228
229    #[tokio::test]
230    async fn visitor_granular_test() -> anyhow::Result<()> {
231        // configure_logging();
232
233        {
234            info!("Just before the beginning of a new month (so only one month in view) - but mega granular");
235            let mut total_buckets = 0;
236
237            RecursiveBucketVisitor::visit(
238                TimeMillis::zero() + MILLIS_IN_MONTH,
239                TimeMillis::zero(),
240                &BUCKET_DURATIONS[1..],
241                &mut async |time_millis: TimeMillis, duration_millis: DurationMillis| {
242                    info!("time_millis: {}, duration_millis: {}", time_millis, duration_millis);
243                    total_buckets += 1;
244                    Ok(RecursiveBucketVisitorOpenCallbackResult::ContinueWithChildren(()))
245                },
246                &mut async |_time_millis: TimeMillis, _duration_millis: DurationMillis, _payload: ()| Ok(RecursiveBucketVisitorCloseCallbackResult::Continue),
247            )
248            .await?;
249
250            let total_expected = 1 + 4 * (1 + 7 * (1 + 4 * (1 + 6 * (1 + 4 * (1 + 3 * (1 + 5))))));
251            assert_eq!(total_buckets, total_expected);
252        }
253
254        Ok(())
255    }
256
257    #[tokio::test]
258    async fn visitor_granular_with_recurse_sibling_test() -> anyhow::Result<()> {
259        // configure_logging();
260
261        {
262            info!("Limit the depth we want to go based on a condition");
263            let mut total_buckets = 0;
264
265            RecursiveBucketVisitor::visit(
266                TimeMillis::zero() + MILLIS_IN_MONTH,
267                TimeMillis::zero(),
268                &BUCKET_DURATIONS[1..],
269                &mut async |time_millis: TimeMillis, duration_millis: DurationMillis| {
270                    info!("time_millis: {}, duration_millis: {}", time_millis, duration_millis);
271                    total_buckets += 1;
272
273                    if duration_millis > MILLIS_IN_DAY {
274                        Ok(RecursiveBucketVisitorOpenCallbackResult::ContinueWithChildren(()))
275                    }
276                    else {
277                        Ok(RecursiveBucketVisitorOpenCallbackResult::ContinueWithoutChildren(()))
278                    }
279                },
280                &mut async |_time_millis: TimeMillis, _duration_millis: DurationMillis, _payload: ()| Ok(RecursiveBucketVisitorCloseCallbackResult::Continue),
281            )
282            .await?;
283
284            let total_expected = 1 + 4 * (1 + 7);
285            assert_eq!(total_buckets, total_expected);
286        }
287
288        Ok(())
289    }
290
291    #[tokio::test]
292    async fn visitor_granular_with_abort_test() -> anyhow::Result<()> {
293        // configure_logging();
294
295        {
296            info!("Abort after a few visits");
297            let mut total_buckets = 0;
298
299            RecursiveBucketVisitor::visit(
300                TimeMillis::zero() + MILLIS_IN_MONTH,
301                TimeMillis::zero(),
302                &BUCKET_DURATIONS[1..],
303                &mut async |bucket_time_millis: TimeMillis, bucket_duration_millis: DurationMillis| {
304                    info!("bucket_time_millis: {}, duration_millis: {}", bucket_time_millis, bucket_duration_millis);
305                    total_buckets += 1;
306
307                    if total_buckets >= 9 {
308                        Ok(RecursiveBucketVisitorOpenCallbackResult::Stop)
309                    }
310                    else {
311                        Ok(RecursiveBucketVisitorOpenCallbackResult::ContinueWithChildren(()))
312                    }
313                },
314                &mut async |_time_millis: TimeMillis, _duration_millis: DurationMillis, _payload: ()| Ok(RecursiveBucketVisitorCloseCallbackResult::Continue),
315            )
316            .await?;
317
318            assert_eq!(total_buckets, 9);
319        }
320
321        Ok(())
322    }
323
324    #[tokio::test]
325    async fn visitor_delivers_open_payload_to_matching_close() -> anyhow::Result<()> {
326        let mut total_opens = 0;
327        let mut total_closes = 0;
328
329        RecursiveBucketVisitor::visit(
330            TimeMillis::zero() + MILLIS_IN_MONTH + MILLIS_IN_WEEK.const_mul(2) + MILLIS_IN_DAY.const_mul(3),
331            TimeMillis::zero(),
332            &BUCKET_DURATIONS[1..4],
333            &mut async |bucket_time_millis: TimeMillis, bucket_duration_millis: DurationMillis| {
334                total_opens += 1;
335                Ok(RecursiveBucketVisitorOpenCallbackResult::ContinueWithChildren((bucket_time_millis, bucket_duration_millis)))
336            },
337            &mut async |bucket_time_millis: TimeMillis, bucket_duration_millis: DurationMillis, payload: (TimeMillis, DurationMillis)| {
338                total_closes += 1;
339                assert_eq!(payload, (bucket_time_millis, bucket_duration_millis), "a close must receive the payload of its own open");
340                Ok(RecursiveBucketVisitorCloseCallbackResult::Continue)
341            },
342        )
343        .await?;
344
345        assert_eq!(total_opens, 54);
346        assert_eq!(total_closes, total_opens);
347
348        Ok(())
349    }
350}