1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum Outcome {
13 Unchanged,
15 Changed,
17 Error,
19 Gone,
21}
22
23impl Outcome {
24 #[must_use]
26 pub fn as_tag(self) -> u8 {
27 match self {
28 Self::Unchanged => 0,
29 Self::Changed => 1,
30 Self::Error => 2,
31 Self::Gone => 3,
32 }
33 }
34
35 #[must_use]
38 pub fn from_tag(tag: u8) -> Self {
39 match tag {
40 0 => Self::Unchanged,
41 1 => Self::Changed,
42 3 => Self::Gone,
43 _ => Self::Error,
44 }
45 }
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub struct TtlConfig {
52 pub base_s: u32,
54 pub min_s: u32,
56 pub max_s: u32,
58 pub grow_num: u32,
60 pub grow_den: u32,
62 pub cut_num: u32,
64 pub cut_den: u32,
66 pub importance_bias_permille: u32,
69}
70
71impl Default for TtlConfig {
72 fn default() -> Self {
73 Self {
74 base_s: 21_600, min_s: 900, max_s: 2_592_000, grow_num: 3,
78 grow_den: 2,
79 cut_num: 1,
80 cut_den: 4,
81 importance_bias_permille: 500,
82 }
83 }
84}
85
86impl TtlConfig {
87 #[must_use]
91 pub fn next_interval(&self, interval_s: u32, outcome: Outcome) -> u32 {
92 let clamp =
93 |v: u64| -> u32 { v.clamp(u64::from(self.min_s), u64::from(self.max_s)) as u32 };
94 match outcome {
95 Outcome::Unchanged => clamp(
96 u64::from(interval_s) * u64::from(self.grow_num) / u64::from(self.grow_den.max(1)),
97 ),
98 Outcome::Changed => clamp(
99 u64::from(interval_s) * u64::from(self.cut_num) / u64::from(self.cut_den.max(1)),
100 ),
101 Outcome::Error => self.min_s,
102 Outcome::Gone => interval_s,
103 }
104 }
105
106 #[must_use]
109 pub fn next_check_at_ms(&self, fetched_at_ms: u64, interval_s: u32, rank_permille: u16) -> u64 {
110 let bias = u64::from(self.importance_bias_permille.min(1000));
111 let mult = 1000 - bias * u64::from(rank_permille.min(1000)) / 1000;
112 fetched_at_ms.saturating_add(u64::from(interval_s) * 1000 * mult / 1000)
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn grows_on_unchanged_until_ceiling() {
122 let cfg = TtlConfig::default();
123 let mut iv = cfg.base_s;
124 let mut prev = 0;
125 for _ in 0..64 {
126 iv = cfg.next_interval(iv, Outcome::Unchanged);
127 assert!(iv >= prev, "monotone growth");
128 assert!(iv <= cfg.max_s);
129 prev = iv;
130 }
131 assert_eq!(iv, cfg.max_s, "parks at the ceiling");
132 }
133
134 #[test]
135 fn changed_cuts_and_error_floors() {
136 let cfg = TtlConfig::default();
137 let grown = cfg.next_interval(cfg.max_s, Outcome::Unchanged);
138 let cut = cfg.next_interval(grown, Outcome::Changed);
139 assert!(cut < grown);
140 assert!(cut >= cfg.min_s);
141 assert_eq!(cfg.next_interval(grown, Outcome::Error), cfg.min_s);
142 assert_eq!(cfg.next_interval(grown, Outcome::Gone), grown);
143 }
144
145 #[test]
146 fn importance_shortens_the_effective_interval() {
147 let cfg = TtlConfig::default();
148 let nobody = cfg.next_check_at_ms(0, 1000, 0);
149 let star = cfg.next_check_at_ms(0, 1000, 1000);
150 assert_eq!(nobody, 1_000_000);
151 assert_eq!(star, 500_000, "top rank halves the interval at bias 500");
152 assert!(cfg.next_check_at_ms(0, 1000, 500) < nobody);
153 }
154
155 #[test]
156 fn outcome_tags_round_trip() {
157 for o in [Outcome::Unchanged, Outcome::Changed, Outcome::Error, Outcome::Gone] {
158 assert_eq!(Outcome::from_tag(o.as_tag()), o);
159 }
160 assert_eq!(Outcome::from_tag(200), Outcome::Error);
161 }
162}