invocation_counter/lib.rs
1#![doc = include_str!("../README.md")]
2
3use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
4
5#[derive(Debug)]
6struct Slot {
7 interval_start: AtomicU64,
8 counter: AtomicU32,
9}
10
11impl Slot {
12 fn new() -> Self {
13 Self {
14 interval_start: AtomicU64::new(0),
15 counter: AtomicU32::new(0),
16 }
17 }
18}
19
20/// A structure for tracking invocation counts over sliding time windows.
21///
22/// `InvocationCounter` implements a ring buffer-based algorithm that efficiently answers the question:
23/// "How many times has a function been called in the last X time units?" The structure divides time
24/// into fixed-size intervals and uses a circular array of slots to track counts within each interval.
25///
26/// # Time Units
27///
28/// Time units are abstract and can represent any consistent time measurement (milliseconds,
29/// seconds, ticks, etc.). The counter treats time as unsigned 64-bit integers, where larger
30/// values represent later points in time.
31///
32/// # Count Approximation
33///
34/// The counter provides an **approximate** count due to its interval-based design:
35/// - Invocations are grouped into discrete time intervals (slots)
36/// - The sliding window moves in interval-sized steps, not continuously
37/// - Recent invocations may be counted even if they fall slightly outside the exact window
38/// - Very old invocations may be excluded if their slots have been reused
39///
40/// This approximation trades perfect accuracy for significant performance and memory efficiency.
41///
42/// # Architecture
43///
44/// The counter uses a ring buffer with 2^`slot_count_exp` slots, where each slot represents a time
45/// interval of 2^`slot_size_exp` time units. The total sliding window size is therefore:
46/// `2^slot_count_exp × 2^slot_size_exp` time units.
47///
48/// Each slot contains:
49/// - `interval_start`: The start timestamp of the time interval this slot represents
50/// - `counter`: The number of invocations registered in this interval
51///
52/// As time progresses, slots are reused in a circular fashion. When a new time interval begins
53/// that maps to an already-occupied slot, the slot is reset and begins tracking the new interval.
54///
55/// # Example
56///
57/// ```rust
58/// # use invocation_counter::InvocationCounter;
59///
60/// // Create counter: 8 slots × 16 time units = 128 time unit sliding window
61/// let counter = InvocationCounter::new(3, 4); // 2^3 slots, 2^4 time units per slot
62///
63/// counter.register(10); // Register invocation at time 10
64/// counter.register(25); // Register invocation at time 25
65///
66/// assert_eq!(counter.count_in(0, 26), 2); // both in range [0, 26)
67/// assert_eq!(counter.count_in(0, 16), 1); // only first (slot 0: times 0-15)
68/// assert_eq!(counter.count_in(16, 32), 1); // only second (slot 1: times 16-31)
69/// assert_eq!(counter.count_in(50, 60), 0); // out of range
70///
71/// counter.register(200);
72/// assert_eq!(counter.count_in(200 - 128, 201), 1); // Only the last in 128-unit window
73/// ```
74#[derive(Debug)]
75pub struct InvocationCounter {
76 slots: Box<[Slot]>,
77 slot_count_exp: u8,
78 slot_size_exp: u8,
79 max_current_time: AtomicU64,
80}
81
82impl InvocationCounter {
83 /// Creates a new `InvocationCounter` with the specified configuration.
84 ///
85 /// # Arguments
86 ///
87 /// * `slot_count_exp` - Exponent for the number of slots (2^slot_count_exp slots total)
88 /// * `slot_size_exp` - Exponent for the size of each time interval (2^slot_size_exp time units per slot)
89 ///
90 /// The total sliding window size will be: `2^slot_count_exp × 2^slot_size_exp` time units.
91 ///
92 /// # Examples
93 ///
94 /// ```rust
95 /// # use invocation_counter::InvocationCounter;
96 /// // Create a counter with 8 slots (2^3), each covering 16 time units (2^4)
97 /// // Total window: 8 × 16 = 128 time units
98 /// let counter = InvocationCounter::new(3, 4);
99 /// ```
100 pub fn new(slot_count_exp: u8, slot_size_exp: u8) -> Self {
101 let slots = (0..(1 << slot_count_exp))
102 .map(|_| Slot::new())
103 .collect::<Vec<_>>()
104 .into_boxed_slice();
105
106 Self {
107 slots,
108 slot_count_exp,
109 slot_size_exp,
110 max_current_time: AtomicU64::new(0),
111 }
112 }
113
114 /// Registers an invocation at the specified time.
115 ///
116 /// This method is thread-safe. Multiple threads can call this method
117 /// concurrently without external synchronization.
118 ///
119 /// # Arguments
120 ///
121 /// * `current_time` - The timestamp when the invocation occurred
122 ///
123 /// # Examples
124 ///
125 /// ```rust
126 /// # use invocation_counter::InvocationCounter;
127 /// let counter = InvocationCounter::new(3, 4); // 8 slots × 16 units = 128-unit window
128 ///
129 /// counter.register(10);
130 /// counter.register(10); // Same interval, increments counter
131 /// counter.register(25); // Different interval, uses different slot
132 /// ```
133 pub fn register(&self, current_time: u64) {
134 let interval_start = current_time >> self.slot_size_exp;
135
136 let slot_index = interval_start % (1 << self.slot_count_exp);
137
138 let interval_start = interval_start << self.slot_size_exp;
139
140 let slot = &self.slots[slot_index as usize];
141
142 let time_in_slot = slot.interval_start.load(Ordering::Acquire);
143 if time_in_slot == interval_start {
144 slot.counter.fetch_add(1, Ordering::Relaxed);
145 } else {
146 slot.interval_start.store(interval_start, Ordering::Release);
147 slot.counter.store(1, Ordering::Release);
148 }
149
150 let current_max_time = self.max_current_time.load(Ordering::Acquire);
151 if current_max_time < current_time {
152 self.max_current_time
153 .compare_exchange_weak(
154 current_max_time,
155 current_time,
156 Ordering::Release,
157 Ordering::Relaxed,
158 )
159 .ok();
160 }
161 }
162
163 /// Returns the slot count exponent used to create this counter.
164 ///
165 /// The actual number of slots is `2^slot_count_exp()`.
166 ///
167 /// # Examples
168 ///
169 /// ```rust
170 /// # use invocation_counter::InvocationCounter;
171 /// let counter = InvocationCounter::new(3, 4); // 8 slots, each covering 16 time units
172 /// assert_eq!(counter.slot_count_exp(), 3);
173 /// assert_eq!(1 << counter.slot_count_exp(), 8); // 2^3 = 8 slots
174 /// ```
175 pub fn slot_count_exp(&self) -> u8 {
176 self.slot_count_exp
177 }
178
179 /// Returns the slot size exponent used to create this counter.
180 ///
181 /// The actual size of each time interval is `2^slot_size_exp()` time units.
182 ///
183 /// # Examples
184 ///
185 /// ```rust
186 /// # use invocation_counter::InvocationCounter;
187 /// let counter = InvocationCounter::new(3, 4); // 8 slots, each covering 16 time units
188 /// assert_eq!(counter.slot_size_exp(), 4);
189 /// assert_eq!(1 << counter.slot_size_exp(), 16); // 2^4 = 16 time units per slot
190 /// ```
191 pub fn slot_size_exp(&self) -> u8 {
192 self.slot_size_exp
193 }
194
195 /// Returns the total number of invocations within the specified time range.
196 ///
197 /// Unlike `count()` which uses a fixed sliding window, this method allows querying
198 /// invocations within any arbitrary time range defined by `start_time` and `end_time`.
199 /// The method still respects the ring buffer's current valid data range.
200 ///
201 /// # Arguments
202 ///
203 /// * `start_time` - The start time of the range to query (inclusive)
204 /// * `end_time` - The end time of the range to query (exclusive)
205 ///
206 /// # Returns
207 ///
208 /// The total number of invocations that occurred within the specified time range,
209 /// limited by the data currently available in the ring buffer.
210 ///
211 /// # Examples
212 ///
213 /// ```rust
214 /// # use invocation_counter::InvocationCounter;
215 /// let counter = InvocationCounter::new(3, 4); // 8 slots × 16 units = 128-unit window
216 ///
217 /// counter.register(10);
218 /// counter.register(25);
219 /// counter.register(100);
220 ///
221 /// assert_eq!(counter.count_in(0, 50), 2); // First two invocations
222 /// assert_eq!(counter.count_in(20, 120), 2); // Second and third invocations
223 /// assert_eq!(counter.count_in(0, 200), 3); // All invocations (if within ring buffer range)
224 /// ```
225 pub fn count_in(&self, start_time: u64, end_time: u64) -> u32 {
226 if start_time >= end_time {
227 return 0;
228 }
229
230 let current_max_time = self.max_current_time.load(Ordering::Acquire);
231
232 // Calculate the ring buffer's valid range (same as count method)
233 let ring_end = ((current_max_time >> self.slot_size_exp) + 1) << self.slot_size_exp;
234 let ring_start =
235 ring_end.saturating_sub((1 << self.slot_size_exp) * (1 << self.slot_count_exp));
236 let ring_buffer_range = ring_start..ring_end;
237
238 // Calculate the requested range, aligning to slot boundaries
239 // start_time is inclusive: include the slot that contains start_time
240 let asked_start = start_time >> self.slot_size_exp << self.slot_size_exp;
241 // end_time is exclusive: find the slot that would contain end_time and use its start as boundary
242 // If end_time is exactly at a slot boundary, use that boundary
243 // Otherwise, use the start of the next slot after the slot containing end_time
244 let asked_end = if end_time & ((1 << self.slot_size_exp) - 1) == 0 {
245 // end_time is exactly at slot boundary
246 end_time
247 } else {
248 // end_time is within a slot, use start of next slot
249 ((end_time >> self.slot_size_exp) + 1) << self.slot_size_exp
250 };
251 let asked_range = asked_start..asked_end;
252
253 // Find the intersection of ring buffer range and requested range
254 let valid_range = ring_buffer_range.start.max(asked_range.start)
255 ..ring_buffer_range.end.min(asked_range.end);
256
257 let mut count = 0;
258 for slot in &self.slots {
259 let time_in_slot = slot.interval_start.load(Ordering::Acquire);
260 if valid_range.contains(&time_in_slot) {
261 count += slot.counter.load(Ordering::Acquire);
262 }
263 }
264
265 count
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use std::sync::Arc;
273 use std::thread;
274
275 #[test]
276 fn test_basic_functionality() {
277 // 4 slots (2^2) * 8 time units (2^3) = 32 time units window
278 let counter = InvocationCounter::new(2, 3);
279
280 counter.register(0);
281 counter.register(1);
282 counter.register(8);
283 counter.register(16);
284
285 // All within 32-unit window from time 16
286 assert_eq!(counter.count_in(0, 16 + 1), 4);
287
288 // All outside window from time 100
289 assert_eq!(counter.count_in(100 - 32, 100 + 1), 0);
290 }
291
292 #[test]
293 fn test_count_in() {
294 // 2 slots (2^1) * 4 time units (2^2) = 8 time units window
295 // Slot 0: times 0-3 (interval_start = 0)
296 // Slot 1: times 4-7 (interval_start = 4)
297 let counter = InvocationCounter::new(1, 2);
298
299 counter.register(0);
300
301 // All registrations 0,1,2,3 go to the same slot (interval_start = 0)
302 // count_in works on slot boundaries, so any range that includes slot 0 gets all counts
303 assert_eq!(counter.count_in(0, 1), 1); // Includes slot 0
304 assert_eq!(counter.count_in(0, 4), 1); // Includes slot 0
305 assert_eq!(counter.count_in(0, 8), 1); // Includes slot 0
306 assert_eq!(counter.count_in(4, 8), 0); // Only includes slot 1 (empty)
307
308 counter.register(1);
309 // Still all in slot 0
310 assert_eq!(counter.count_in(0, 4), 2); // Includes slot 0
311 assert_eq!(counter.count_in(0, 8), 2); // Includes slot 0
312 assert_eq!(counter.count_in(4, 8), 0); // Only slot 1
313
314 counter.register(2);
315 assert_eq!(counter.count_in(0, 4), 3); // Slot 0
316 assert_eq!(counter.count_in(4, 8), 0); // Slot 1
317
318 counter.register(3);
319 assert_eq!(counter.count_in(0, 4), 4); // Slot 0
320 assert_eq!(counter.count_in(4, 8), 0); // Slot 1
321
322 // Register 4 in slot 1 (interval_start = 4)
323 counter.register(4);
324 assert_eq!(counter.count_in(0, 4), 4); // Slot 0 only (end_time=4 excludes slot 1)
325 assert_eq!(counter.count_in(4, 8), 1); // Slot 1 only
326 assert_eq!(counter.count_in(0, 8), 5); // Both slots
327
328 counter.register(5);
329 assert_eq!(counter.count_in(0, 4), 4); // Slot 0 only
330 assert_eq!(counter.count_in(4, 8), 2); // Slot 1 only
331 assert_eq!(counter.count_in(0, 8), 6); // Both slots
332
333 counter.register(6);
334 assert_eq!(counter.count_in(0, 4), 4); // Slot 0 only
335 assert_eq!(counter.count_in(4, 8), 3); // Slot 1 only
336 assert_eq!(counter.count_in(0, 8), 7); // Both slots
337
338 counter.register(7);
339 assert_eq!(counter.count_in(0, 4), 4); // Slot 0 only
340 assert_eq!(counter.count_in(4, 8), 4); // Slot 1 only
341 assert_eq!(counter.count_in(0, 8), 8); // Both slots
342 assert_eq!(counter.count_in(4, 12), 4); // Slot 1 only
343 assert_eq!(counter.count_in(12, 16), 0); // Out of range
344
345 // Register 8 - wraps to slot 0, resets it (interval_start = 8)
346 counter.register(8);
347 assert_eq!(counter.count_in(0, 4), 0); // Old slot 0 data gone
348 assert_eq!(counter.count_in(4, 8), 4); // Slot 1 unchanged
349 assert_eq!(counter.count_in(8, 12), 1); // New slot 0
350 assert_eq!(counter.count_in(4, 12), 5); // Slot 1 + new slot 0
351 assert_eq!(counter.count_in(8, 9), 1); // Within new slot 0
352 assert_eq!(counter.count_in(0, 12), 5); // Full range
353
354 counter.register(9);
355 assert_eq!(counter.count_in(0, 4), 0); // Old slot 0 still gone
356 assert_eq!(counter.count_in(4, 8), 4); // Slot 1 unchanged
357 assert_eq!(counter.count_in(8, 12), 2); // New slot 0 has 2 entries
358 assert_eq!(counter.count_in(4, 12), 6); // Slot 1 + new slot 0
359 assert_eq!(counter.count_in(8, 10), 2); // Within new slot 0
360 assert_eq!(counter.count_in(0, 16), 6); // Full range
361
362 counter.register(10);
363 assert_eq!(counter.count_in(4, 8), 4); // Slot 1 unchanged
364 assert_eq!(counter.count_in(8, 12), 3); // New slot 0 has 3 entries
365 assert_eq!(counter.count_in(4, 12), 7); // Slot 1 + new slot 0
366 assert_eq!(counter.count_in(8, 11), 3); // Within new slot 0
367 assert_eq!(counter.count_in(0, 16), 7); // Full range
368
369 counter.register(11);
370 assert_eq!(counter.count_in(4, 8), 4); // Slot 1 unchanged
371 assert_eq!(counter.count_in(8, 12), 4); // New slot 0 has 4 entries
372 assert_eq!(counter.count_in(4, 12), 8); // Slot 1 + new slot 0
373 assert_eq!(counter.count_in(8, 12), 4); // Full new slot 0
374 assert_eq!(counter.count_in(10, 12), 4); // Includes full slot 0 (slot-boundary aligned)
375 assert_eq!(counter.count_in(0, 16), 8); // Full range
376
377 // Register 12 - wraps to slot 1, resets it (interval_start = 12)
378 counter.register(12);
379 assert_eq!(counter.count_in(4, 8), 0); // Old slot 1 data gone
380 assert_eq!(counter.count_in(8, 12), 4); // Slot 0 unchanged
381 assert_eq!(counter.count_in(12, 16), 1); // New slot 1
382 assert_eq!(counter.count_in(8, 16), 5); // Slot 0 + new slot 1
383 assert_eq!(counter.count_in(0, 16), 5); // Full range
384 assert_eq!(counter.count_in(12, 13), 1); // Within new slot 1
385
386 counter.register(13);
387 assert_eq!(counter.count_in(8, 12), 4); // Slot 0 unchanged
388 assert_eq!(counter.count_in(12, 16), 2); // New slot 1 has 2 entries
389 assert_eq!(counter.count_in(8, 16), 6); // Slot 0 + new slot 1
390 assert_eq!(counter.count_in(0, 16), 6); // Full range
391 assert_eq!(counter.count_in(12, 14), 2); // Within new slot 1
392
393 counter.register(14);
394 assert_eq!(counter.count_in(8, 12), 4); // Slot 0 unchanged
395 assert_eq!(counter.count_in(12, 16), 3); // New slot 1 has 3 entries
396 assert_eq!(counter.count_in(8, 16), 7); // Slot 0 + new slot 1
397 assert_eq!(counter.count_in(0, 16), 7); // Full range
398 assert_eq!(counter.count_in(12, 15), 3); // Within new slot 1
399
400 counter.register(15);
401 assert_eq!(counter.count_in(8, 12), 4); // Slot 0 unchanged
402 assert_eq!(counter.count_in(12, 16), 4); // New slot 1 has 4 entries
403 assert_eq!(counter.count_in(8, 16), 8); // Slot 0 + new slot 1
404 assert_eq!(counter.count_in(0, 16), 8); // Full range
405 assert_eq!(counter.count_in(12, 16), 4); // Full new slot 1
406
407 // Register 16 - wraps to slot 0 again, resets it (interval_start = 16)
408 counter.register(16);
409 assert_eq!(counter.count_in(8, 12), 0); // Old slot 0 data gone
410 assert_eq!(counter.count_in(12, 16), 4); // Slot 1 unchanged
411 assert_eq!(counter.count_in(16, 20), 1); // New slot 0
412 assert_eq!(counter.count_in(12, 20), 5); // Slot 1 + new slot 0
413 assert_eq!(counter.count_in(0, 20), 5); // Full range
414 assert_eq!(counter.count_in(16, 17), 1); // Within new slot 0
415 }
416
417 #[test]
418 fn test_slot_reuse() {
419 // 4 slots (2^2) * 4 time units (2^2) = 16 time units window
420 let counter = InvocationCounter::new(2, 2);
421
422 counter.register(0); // slot 0
423 counter.register(16); // wraps to slot 0, resets counter
424
425 assert_eq!(counter.count_in(0, 17), 1); // Only recent registration
426 }
427
428 #[test]
429 fn test_concurrent_access() {
430 let num_threads = 4;
431 let registrations_per_thread = 100;
432
433 // 8 slots (2^3) * 64 time units (2^6) = 512 time units window
434 let counter = Arc::new(InvocationCounter::new(3, 6));
435
436 let handles: Vec<_> = (0..num_threads)
437 .map(|thread_id| {
438 let counter_clone = Arc::clone(&counter);
439 thread::spawn(move || {
440 for i in 0..registrations_per_thread {
441 counter_clone.register(thread_id as u64 * 10 + i as u64);
442 }
443 })
444 })
445 .collect();
446
447 for handle in handles {
448 handle.join().unwrap();
449 }
450
451 let count = counter.count_in(0, 399); // Query at end of all registrations
452 assert!(count > 0);
453 assert!(count <= num_threads * registrations_per_thread);
454 }
455
456 #[test]
457 fn test_edge_cases() {
458 // 4 slots (2^2) * 8 time units (2^3) = 32 time units window
459 let counter = InvocationCounter::new(2, 3);
460
461 // Empty counter
462 assert_eq!(counter.count_in(0, 0), 0);
463
464 // Zero time
465 counter.register(0);
466 assert_eq!(counter.count_in(0, 1), 1);
467
468 // Large time values
469 let large_time = 1_000_000u64;
470 counter.register(large_time);
471 assert_eq!(counter.count_in(large_time, large_time + 1), 1);
472 }
473}