ocpi_kit/client/
resync.rs1use core::time::Duration;
21
22use crate::transport::PageQuery;
23use crate::types::DateTime;
24
25pub const DEFAULT_OVERLAP: Duration = Duration::from_mins(15);
32
33pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_hours(4);
37
38#[derive(Clone, Copy, Debug)]
56pub struct Resync {
57 overlap: Duration,
58 poll_interval: Duration,
59 splay_fraction: f32,
60 page_limit: Option<u64>,
61}
62
63impl Default for Resync {
64 fn default() -> Self {
65 Self {
66 overlap: DEFAULT_OVERLAP,
67 poll_interval: DEFAULT_POLL_INTERVAL,
68 splay_fraction: 0.2,
69 page_limit: None,
70 }
71 }
72}
73
74impl Resync {
75 #[must_use]
77 pub fn new() -> Self {
78 Self::default()
79 }
80
81 #[must_use]
83 pub const fn with_overlap(mut self, overlap: Duration) -> Self {
84 self.overlap = overlap;
85 self
86 }
87
88 #[must_use]
90 pub const fn with_poll_interval(mut self, interval: Duration) -> Self {
91 self.poll_interval = interval;
92 self
93 }
94
95 #[must_use]
97 pub fn with_splay(mut self, fraction: f32) -> Self {
98 self.splay_fraction = fraction.clamp(0.0, 1.0);
99 self
100 }
101
102 #[must_use]
104 pub const fn with_page_limit(mut self, limit: u64) -> Self {
105 self.page_limit = Some(limit);
106 self
107 }
108
109 #[must_use]
111 pub fn plan(&self, last_success: DateTime, now: DateTime) -> ResyncPlan {
112 let overlap_seconds = i64::try_from(self.overlap.as_secs()).unwrap_or(i64::MAX);
113 let from =
114 DateTime::from_unix_timestamp(last_success.unix_timestamp().saturating_sub(overlap_seconds))
115 .unwrap_or(last_success);
116 let mut query = PageQuery::between(from, now);
117 if let Some(limit) = self.page_limit {
118 query = query.with_limit(limit);
119 }
120 ResyncPlan { query, next_poll_after: self.splayed_interval(now) }
121 }
122
123 #[must_use]
128 pub fn incremental(&self, last_success: DateTime) -> PageQuery {
129 let overlap_seconds = i64::try_from(self.overlap.as_secs()).unwrap_or(i64::MAX);
130 let from =
131 DateTime::from_unix_timestamp(last_success.unix_timestamp().saturating_sub(overlap_seconds))
132 .unwrap_or(last_success);
133 let mut query = PageQuery::since(from);
134 if let Some(limit) = self.page_limit {
135 query = query.with_limit(limit);
136 }
137 query
138 }
139
140 #[must_use]
145 pub fn splayed_interval(&self, seed: DateTime) -> Duration {
146 if self.splay_fraction <= f32::EPSILON {
147 return self.poll_interval;
148 }
149 let span = self.poll_interval.mul_f32(self.splay_fraction);
150 let modulus = span.as_secs().saturating_mul(2).max(1);
152 let offset = seed.unix_timestamp().unsigned_abs() % modulus;
153 self.poll_interval.saturating_add(Duration::from_secs(offset)).saturating_sub(span)
154 }
155}
156
157#[derive(Clone, Debug, PartialEq, Eq)]
159pub struct ResyncPlan {
160 pub query: PageQuery,
162 pub next_poll_after: Duration,
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 fn dt(s: &str) -> DateTime {
171 s.parse().unwrap()
172 }
173
174 #[test]
175 fn the_window_overlaps_the_last_success() {
176 let plan = Resync::new().plan(dt("2024-03-01T10:00:00Z"), dt("2024-03-01T14:00:00Z"));
177 assert_eq!(plan.query.date_from, Some(dt("2024-03-01T09:45:00Z")));
178 assert_eq!(plan.query.date_to, Some(dt("2024-03-01T14:00:00Z")));
179 }
180
181 #[test]
182 fn an_incremental_poll_has_no_end_bound() {
183 let query =
184 Resync::new().with_overlap(Duration::from_secs(60)).incremental(dt("2024-03-01T10:00:00Z"));
185 assert_eq!(query.date_from, Some(dt("2024-03-01T09:59:00Z")));
186 assert_eq!(query.date_to, None);
187 }
188
189 #[test]
190 fn the_default_interval_is_measured_in_hours_not_minutes() {
191 assert!(DEFAULT_POLL_INTERVAL >= Duration::from_hours(1));
193 }
194
195 #[test]
196 fn splay_stays_within_the_configured_fraction() {
197 let resync = Resync::new().with_splay(0.2);
198 let span = DEFAULT_POLL_INTERVAL.mul_f32(0.2);
199 for seed in
200 ["2024-03-01T14:00:00Z", "2024-03-01T14:00:01Z", "2024-06-17T03:41:59Z", "1970-01-01T00:00:00Z"]
201 {
202 let interval = resync.splayed_interval(dt(seed));
203 assert!(interval >= DEFAULT_POLL_INTERVAL.checked_sub(span).unwrap(), "{seed}: {interval:?}");
204 assert!(interval <= DEFAULT_POLL_INTERVAL + span, "{seed}: {interval:?}");
205 }
206 }
207
208 #[test]
209 fn splay_is_deterministic_and_can_be_switched_off() {
210 let resync = Resync::new();
211 let seed = dt("2024-03-01T14:00:00Z");
212 assert_eq!(resync.splayed_interval(seed), resync.splayed_interval(seed));
213 assert_eq!(Resync::new().with_splay(0.0).splayed_interval(seed), DEFAULT_POLL_INTERVAL);
214 }
215
216 #[test]
217 fn two_clients_starting_at_different_moments_do_not_align() {
218 let resync = Resync::new();
219 let a = resync.splayed_interval(dt("2024-03-01T14:00:00Z"));
220 let b = resync.splayed_interval(dt("2024-03-01T14:07:13Z"));
221 assert_ne!(a, b);
222 }
223}