1use super::{CachedArticle, ttl};
10use crate::io_util::atomic_replace_file;
11use crate::types::{BackendId, MessageId};
12use anyhow::{Context, Result};
13use std::fs;
14use std::hash::Hasher;
15use std::mem::size_of;
16use std::path::Path;
17use std::sync::Mutex;
18use std::sync::atomic::AtomicBool;
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::time::Duration;
21use twox_hash::XxHash64;
22
23const DEFAULT_GENERATIONS: usize = 2;
24const BLOCK_SLOTS: usize = 2;
25const FIXED_ARTICLE_CAPACITY: usize = 256 * 1024;
26const ALL_BACKEND_BITS: usize = usize::MAX;
27const PERSISTENCE_MAGIC: &[u8; 8] = b"ANEGSIM4";
28const LEGACY_PERSISTENCE_MAGIC_V1: &[u8; 8] = b"ANEGIDX1";
29const LEGACY_PERSISTENCE_MAGIC_V2: &[u8; 8] = b"ANEGIDX2";
30const LEGACY_PERSISTENCE_MAGIC_V3: &[u8; 8] = b"ANEGSIM1";
31const LEGACY_PERSISTENCE_MAGIC_V4: &[u8; 8] = b"ANEGSIM2";
32const LEGACY_PERSISTENCE_MAGIC_V5: &[u8; 8] = b"ANEGSIM3";
33
34static SAVE_LOCK: Mutex<()> = Mutex::new(());
35static SAVE_SEQ: AtomicU64 = AtomicU64::new(0);
36
37#[derive(Clone, Copy, Debug, Default)]
38struct Block {
39 hashes: [u64; BLOCK_SLOTS],
40 tags: [u16; BLOCK_SLOTS],
41 missing: [usize; BLOCK_SLOTS],
42}
43
44type SlotMatchMask = u8;
45const FIXED_TOTAL_BLOCKS: usize = FIXED_ARTICLE_CAPACITY / BLOCK_SLOTS;
46const FIXED_CAPACITY_BYTES: u64 = (FIXED_TOTAL_BLOCKS * size_of::<Block>()) as u64;
47
48impl Block {
49 fn missing_bits(&self, hash: u64, tag: u16) -> usize {
50 let mut matched = matching_slots(&self.hashes, &self.tags, hash, tag);
51 let mut missing_bits = 0usize;
52
53 while matched != 0 {
54 let slot = matched.trailing_zeros() as usize;
55 missing_bits |= self.missing[slot];
56 matched &= matched - 1;
57 }
58
59 missing_bits
60 }
61
62 fn insert(&mut self, hash: u64, tag: u16, missing_bits: usize, victim: usize) -> InsertOutcome {
63 debug_assert_ne!(hash, 0, "fingerprint slots use 0 as the empty sentinel");
64
65 let existing = matching_slots(&self.hashes, &self.tags, hash, tag);
66 if existing != 0 {
67 let slot = existing.trailing_zeros() as usize;
68 self.missing[slot] |= missing_bits;
69 return InsertOutcome::Updated;
70 }
71
72 if let Some(empty_slot) = self.hashes.iter().position(|&value| value == 0) {
73 self.hashes[empty_slot] = hash;
74 self.tags[empty_slot] = tag;
75 self.missing[empty_slot] = missing_bits;
76 return InsertOutcome::Inserted;
77 }
78
79 self.hashes[victim] = hash;
80 self.tags[victim] = tag;
81 self.missing[victim] = missing_bits;
82 InsertOutcome::Replaced
83 }
84
85 fn clear(&mut self) -> usize {
86 let occupied = self.hashes.iter().filter(|&&hash| hash != 0).count();
87 self.hashes = [0; BLOCK_SLOTS];
88 self.tags = [0; BLOCK_SLOTS];
89 self.missing = [0; BLOCK_SLOTS];
90 occupied
91 }
92}
93
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95enum InsertOutcome {
96 Inserted,
97 Updated,
98 Replaced,
99}
100
101#[derive(Clone, Debug)]
102struct Generation {
103 started_at: u64,
104 occupied: usize,
105 blocks: Box<[Block]>,
106}
107
108impl Generation {
109 fn new(blocks_per_generation: usize) -> Self {
110 Self {
111 started_at: 0,
112 occupied: 0,
113 blocks: vec![Block::default(); blocks_per_generation].into_boxed_slice(),
114 }
115 }
116
117 fn clear(&mut self) -> usize {
118 let evicted = self.occupied;
119 for block in &mut self.blocks {
120 block.clear();
121 }
122 self.started_at = 0;
123 self.occupied = 0;
124 evicted
125 }
126}
127
128#[derive(Clone, Copy, Debug)]
129struct PersistedEntry {
130 hash: u64,
131 tag: u16,
132 missing: usize,
133 inserted_at: u64,
134}
135
136#[derive(Debug)]
137struct FilterState {
138 generations: Box<[Generation]>,
139 current_generation: usize,
140 blocks_per_generation: usize,
141 live_generations: usize,
142 rotation_interval_millis: u64,
143 next_rotation_at: u64,
144}
145
146impl FilterState {
147 fn new(
148 blocks_per_generation: usize,
149 generation_count: usize,
150 rotation_interval_millis: u64,
151 ) -> Self {
152 let generation_count = generation_count.max(1);
153 Self {
154 generations: (0..generation_count)
155 .map(|_| Generation::new(blocks_per_generation))
156 .collect::<Vec<_>>()
157 .into_boxed_slice(),
158 current_generation: 0,
159 blocks_per_generation,
160 live_generations: 0,
161 rotation_interval_millis,
162 next_rotation_at: 0,
163 }
164 }
165
166 #[cfg(test)]
167 fn blocks_per_generation(&self) -> usize {
168 self.blocks_per_generation
169 }
170
171 #[cfg(test)]
172 fn generation_count(&self) -> usize {
173 self.generations.len()
174 }
175
176 fn active_generation_indices(&self) -> impl Iterator<Item = usize> + '_ {
177 (0..self.live_generations).map(|offset| {
178 (self.current_generation + self.generations.len() - offset) % self.generations.len()
179 })
180 }
181
182 fn occupied_slots(&self) -> usize {
183 self.active_generation_indices()
184 .map(|index| self.generations[index].occupied)
185 .sum()
186 }
187
188 fn reset(&mut self) {
189 for generation in &mut self.generations {
190 generation.clear();
191 }
192 self.current_generation = 0;
193 self.live_generations = 0;
194 self.next_rotation_at = 0;
195 }
196
197 fn ensure_current_generation_started(&mut self, now: u64) {
198 let generation = &mut self.generations[self.current_generation];
199 if generation.started_at != 0 {
200 return;
201 }
202
203 generation.started_at = now;
204 self.live_generations = self.live_generations.max(1);
205 self.next_rotation_at = match self.rotation_interval_millis {
206 0 | u64::MAX => 0,
207 interval => now.saturating_add(interval),
208 };
209 }
210
211 fn refresh_next_rotation_at(&mut self) {
212 self.next_rotation_at = match self.rotation_interval_millis {
213 0 | u64::MAX => 0,
214 interval => self.generations[self.current_generation]
215 .started_at
216 .saturating_add(interval),
217 };
218 }
219
220 fn reanchor_current_generation(&mut self) {
221 let Some((index, _)) = self
222 .generations
223 .iter()
224 .enumerate()
225 .filter(|(_, generation)| generation.started_at != 0)
226 .max_by_key(|(_, generation)| generation.started_at)
227 else {
228 return;
229 };
230
231 self.current_generation = index;
232 self.refresh_next_rotation_at();
233 }
234
235 fn rotate_if_needed(&mut self, now: u64) -> usize {
236 if self.blocks_per_generation == 0 || self.generations.is_empty() {
237 return 0;
238 }
239
240 let interval = self.rotation_interval_millis;
241 if interval == 0 {
242 let mut evicted = 0usize;
243 for generation in &mut self.generations {
244 evicted += generation.clear();
245 }
246 self.current_generation = 0;
247 self.live_generations = 0;
248 self.next_rotation_at = 0;
249 return evicted;
250 }
251 if interval == u64::MAX {
252 return 0;
253 }
254 if self.live_generations == 0 || self.next_rotation_at == 0 || now < self.next_rotation_at {
255 return 0;
256 }
257
258 let scheduled_at = self.next_rotation_at;
259 let rotations = (1 + ((now.saturating_sub(self.next_rotation_at)) / interval) as usize)
260 .min(self.generations.len());
261 let mut evicted = 0usize;
262 for _ in 0..rotations {
263 self.current_generation = (self.current_generation + 1) % self.generations.len();
264 let generation = &mut self.generations[self.current_generation];
265 evicted += generation.clear();
266 }
267 self.live_generations = (self.live_generations + rotations).min(self.generations.len());
268 self.next_rotation_at =
269 scheduled_at.saturating_add((rotations as u64).saturating_mul(interval));
270 evicted
271 }
272
273 fn lookup_missing_bits(
274 &mut self,
275 hash: u64,
276 tag: u16,
277 block_index: usize,
278 now: u64,
279 ) -> LookupResult {
280 let evicted = self.rotate_if_needed(now);
281 if self.blocks_per_generation == 0 || self.generations.is_empty() {
282 return LookupResult {
283 missing_bits: 0,
284 evicted,
285 empty_after: true,
286 };
287 }
288
289 let mut missing_bits = 0usize;
290 for generation_index in self.active_generation_indices() {
291 missing_bits |=
292 self.generations[generation_index].blocks[block_index].missing_bits(hash, tag);
293 if missing_bits == ALL_BACKEND_BITS {
294 break;
295 }
296 }
297
298 LookupResult {
299 missing_bits,
300 evicted,
301 empty_after: self.occupied_slots() == 0,
302 }
303 }
304
305 fn insert_missing_bits(
306 &mut self,
307 hash: u64,
308 tag: u16,
309 missing_bits: usize,
310 block_index: usize,
311 victim: usize,
312 now: u64,
313 ) -> StateInsertOutcome {
314 let evicted = self.rotate_if_needed(now);
315 if self.blocks_per_generation == 0
316 || missing_bits == 0
317 || self.rotation_interval_millis == 0
318 {
319 return StateInsertOutcome {
320 changed: false,
321 evicted,
322 };
323 }
324
325 self.ensure_current_generation_started(now);
326 let generation = &mut self.generations[self.current_generation];
327 let block = &mut generation.blocks[block_index];
328 let outcome = block.insert(hash, tag, missing_bits, victim);
329 if matches!(outcome, InsertOutcome::Inserted) {
330 generation.occupied += 1;
331 }
332
333 StateInsertOutcome {
334 changed: !matches!(outcome, InsertOutcome::Updated),
335 evicted: match outcome {
336 InsertOutcome::Replaced => evicted.saturating_add(1),
337 _ => evicted,
338 },
339 }
340 }
341
342 fn restore_entry(
343 &mut self,
344 entry: PersistedEntry,
345 now: u64,
346 ttl_millis: ttl::CacheTtlMillis,
347 ) -> usize {
348 if self.blocks_per_generation == 0
349 || entry.hash == 0
350 || entry.missing == 0
351 || ttl::is_expired(
352 ttl::CacheTimestampMillis::new(entry.inserted_at),
353 ttl_millis,
354 ttl::CacheTier::new(0),
355 )
356 {
357 return 0;
358 }
359
360 let interval = self.rotation_interval_millis;
361 let offset = if interval == 0 || interval == u64::MAX {
362 0
363 } else {
364 (now.saturating_sub(entry.inserted_at) / interval) as usize
365 }
366 .min(self.generations.len().saturating_sub(1));
367 let generation_index =
368 (self.current_generation + self.generations.len() - offset) % self.generations.len();
369 let generation = &mut self.generations[generation_index];
370 let started_at = if interval == 0 || interval == u64::MAX {
371 entry.inserted_at
372 } else {
373 now.saturating_sub((offset as u64).saturating_mul(interval))
374 };
375
376 if generation.started_at == 0 {
377 generation.started_at = started_at;
378 } else {
379 generation.started_at = generation.started_at.min(started_at);
380 }
381 self.live_generations = self.live_generations.max(offset + 1);
382
383 let block = &mut generation.blocks[block_index(entry.hash, self.blocks_per_generation)];
384 match block.insert(
385 entry.hash,
386 entry.tag,
387 entry.missing,
388 victim_slot(entry.hash),
389 ) {
390 InsertOutcome::Inserted => {
391 generation.occupied += 1;
392 0
393 }
394 InsertOutcome::Updated => 0,
395 InsertOutcome::Replaced => 1,
396 }
397 }
398
399 fn snapshot_entries(&mut self, now: u64) -> SnapshotResult {
400 let evicted = self.rotate_if_needed(now);
401 let mut entries = Vec::with_capacity(self.occupied_slots());
402
403 for generation_index in self.active_generation_indices() {
404 let generation = &self.generations[generation_index];
405
406 for block in &generation.blocks {
407 for slot in 0..BLOCK_SLOTS {
408 let hash = block.hashes[slot];
409 let missing = block.missing[slot];
410 if hash == 0 || missing == 0 {
411 continue;
412 }
413 entries.push(PersistedEntry {
414 hash,
415 tag: block.tags[slot],
416 missing,
417 inserted_at: generation.started_at,
418 });
419 }
420 }
421 }
422
423 SnapshotResult { entries, evicted }
424 }
425}
426
427#[derive(Clone, Copy, Debug, Default)]
428struct LookupResult {
429 missing_bits: usize,
430 evicted: usize,
431 empty_after: bool,
432}
433
434#[derive(Clone, Copy, Debug, Default)]
435struct StateInsertOutcome {
436 changed: bool,
437 evicted: usize,
438}
439
440#[derive(Debug, Default)]
441struct SnapshotResult {
442 entries: Vec<PersistedEntry>,
443 evicted: usize,
444}
445
446#[derive(Debug)]
447pub struct AvailabilityIndex {
448 state: Mutex<FilterState>,
449 capacity_bytes: u64,
450 has_entries: AtomicBool,
451 hits: AtomicU64,
452 misses: AtomicU64,
453 inserts: AtomicU64,
454 dropped: AtomicU64,
455 evictions: AtomicU64,
456 ttl: ttl::CacheTtlMillis,
457}
458
459impl Default for AvailabilityIndex {
460 fn default() -> Self {
461 Self::new()
462 }
463}
464
465impl AvailabilityIndex {
466 #[must_use]
467 pub const fn fixed_capacity_bytes() -> u64 {
468 FIXED_CAPACITY_BYTES
469 }
470
471 #[must_use]
472 pub fn new() -> Self {
473 Self::with_ttl(Duration::MAX)
474 }
475
476 #[must_use]
477 pub fn with_ttl(ttl: Duration) -> Self {
478 Self::with_capacity_and_generation_count(FIXED_CAPACITY_BYTES, ttl, DEFAULT_GENERATIONS)
479 }
480
481 #[must_use]
482 #[cfg(test)]
483 fn with_test_capacity(capacity_bytes: u64) -> Self {
484 Self::with_capacity_and_generation_count(capacity_bytes, Duration::MAX, DEFAULT_GENERATIONS)
485 }
486
487 #[must_use]
488 #[cfg(test)]
489 fn with_generation_count(capacity_bytes: u64, generation_count: usize) -> Self {
490 Self::with_capacity_and_generation_count(capacity_bytes, Duration::MAX, generation_count)
491 }
492
493 #[must_use]
494 fn with_capacity_and_generation_count(
495 capacity_bytes: u64,
496 ttl: Duration,
497 generation_count: usize,
498 ) -> Self {
499 let ttl = ttl::CacheTtlMillis::from_duration(ttl);
500 let total_blocks = (capacity_bytes as usize) / size_of::<Block>();
501 let generation_count = generation_count.max(1).min(total_blocks.max(1));
502 let blocks_per_generation = if total_blocks == 0 {
503 0
504 } else {
505 total_blocks / generation_count
506 };
507 let rotation_interval_millis = match ttl.get() {
508 0 => 0,
509 u64::MAX => u64::MAX,
510 ttl_millis => (ttl_millis / generation_count as u64).max(1),
511 };
512
513 Self {
514 state: Mutex::new(FilterState::new(
515 blocks_per_generation,
516 generation_count,
517 rotation_interval_millis,
518 )),
519 capacity_bytes,
520 has_entries: AtomicBool::new(false),
521 hits: AtomicU64::new(0),
522 misses: AtomicU64::new(0),
523 inserts: AtomicU64::new(0),
524 dropped: AtomicU64::new(0),
525 evictions: AtomicU64::new(0),
526 ttl,
527 }
528 }
529
530 #[must_use]
531 pub fn get(&self, message_id: &MessageId<'_>) -> Option<CachedArticle> {
532 self.lookup_by_key(message_id.without_brackets())
533 }
534
535 #[must_use]
536 pub fn get_request_message_id(&self, message_id: &str) -> Option<CachedArticle> {
537 let key = message_id.strip_prefix('<')?.strip_suffix('>')?;
538 self.lookup_by_key(key)
539 }
540
541 pub fn record_backend_missing(&self, message_id: &MessageId<'_>, backend_id: BackendId) {
542 self.insert_missing_bits(message_id.without_brackets(), backend_id.availability_bit());
543 }
544
545 pub fn load_from_path(&self, path: &Path) -> Result<bool> {
546 if !path.exists() {
547 return Ok(false);
548 }
549
550 let data = fs::read(path).with_context(|| {
551 format!("Failed to read availability index from {}", path.display())
552 })?;
553 let mut entries = parse_entries(&data)?;
554 entries.sort_by_key(|entry| entry.inserted_at);
555
556 let now = ttl::now_millis();
557 let mut state = self
558 .state
559 .lock()
560 .unwrap_or_else(std::sync::PoisonError::into_inner);
561 state.reset();
562 let mut evicted = 0usize;
563 for entry in entries {
564 evicted += state.restore_entry(entry, now, self.ttl);
565 }
566 if state.live_generations != 0 {
567 state.reanchor_current_generation();
568 }
569 let has_entries = state.occupied_slots() != 0;
570 drop(state);
571
572 self.has_entries.store(has_entries, Ordering::Relaxed);
573 self.hits.store(0, Ordering::Relaxed);
574 self.misses.store(0, Ordering::Relaxed);
575 self.inserts.store(0, Ordering::Relaxed);
576 self.dropped.store(0, Ordering::Relaxed);
577 self.evictions.store(evicted as u64, Ordering::Relaxed);
578 Ok(true)
579 }
580
581 pub fn save_to_path(&self, path: &Path) -> Result<()> {
582 let _save_guard = SAVE_LOCK
583 .lock()
584 .map_err(|_| anyhow::anyhow!("availability save lock poisoned"))?;
585
586 if let Some(parent) = path.parent()
587 && !parent.as_os_str().is_empty()
588 {
589 fs::create_dir_all(parent).with_context(|| {
590 format!(
591 "Failed to create availability directory {}",
592 parent.display()
593 )
594 })?;
595 }
596
597 let now = ttl::now_millis();
598 let snapshot = {
599 let mut state = self
600 .state
601 .lock()
602 .unwrap_or_else(std::sync::PoisonError::into_inner);
603 state.snapshot_entries(now)
604 };
605 if snapshot.evicted != 0 {
606 self.evictions
607 .fetch_add(snapshot.evicted as u64, Ordering::Relaxed);
608 }
609
610 let mut bytes = Vec::with_capacity(
611 16 + snapshot.entries.len()
612 * (size_of::<u64>() + size_of::<u16>() + size_of::<u64>() + size_of::<u64>()),
613 );
614 bytes.extend_from_slice(PERSISTENCE_MAGIC);
615 bytes.extend_from_slice(&(snapshot.entries.len() as u64).to_le_bytes());
616 for entry in snapshot.entries {
617 bytes.extend_from_slice(&entry.hash.to_le_bytes());
618 bytes.extend_from_slice(&entry.tag.to_le_bytes());
619 bytes.extend_from_slice(&availability_bits_to_wire(entry.missing)?.to_le_bytes());
620 bytes.extend_from_slice(&entry.inserted_at.to_le_bytes());
621 }
622
623 let seq = SAVE_SEQ.fetch_add(1, Ordering::Relaxed);
624 let tmp_filename = format!(
625 "{}.{}.tmp",
626 path.file_name().unwrap_or_default().to_string_lossy(),
627 seq
628 );
629 let tmp_path = path.with_file_name(tmp_filename);
630 fs::write(&tmp_path, bytes).with_context(|| {
631 format!(
632 "Failed to write availability index to {}",
633 tmp_path.display()
634 )
635 })?;
636
637 if let Err(e) = atomic_replace_file(&tmp_path, path) {
638 let _ = fs::remove_file(&tmp_path);
639 return Err(e);
640 }
641
642 Ok(())
643 }
644
645 #[must_use]
646 pub const fn capacity_bytes(&self) -> u64 {
647 self.capacity_bytes
648 }
649
650 #[must_use]
651 pub fn entry_count(&self) -> u64 {
652 if !self.has_entries.load(Ordering::Relaxed) {
653 return 0;
654 }
655 let result = {
656 let mut state = self
657 .state
658 .lock()
659 .unwrap_or_else(std::sync::PoisonError::into_inner);
660 let evicted = state.rotate_if_needed(ttl::now_millis());
661 (state.occupied_slots() as u64, evicted)
662 };
663 self.has_entries.store(result.0 != 0, Ordering::Relaxed);
664 if result.1 != 0 {
665 self.evictions.fetch_add(result.1 as u64, Ordering::Relaxed);
666 }
667 result.0
668 }
669
670 #[must_use]
671 pub fn used_bytes(&self) -> u64 {
672 self.capacity_bytes
673 }
674
675 #[must_use]
676 pub fn hit_rate(&self) -> f64 {
677 let hits = self.hits.load(Ordering::Relaxed);
678 let misses = self.misses.load(Ordering::Relaxed);
679 let total = hits + misses;
680 if total == 0 {
681 0.0
682 } else {
683 (hits as f64 / total as f64) * 100.0
684 }
685 }
686
687 #[must_use]
688 pub fn evictions(&self) -> u64 {
689 self.evictions.load(Ordering::Relaxed)
690 }
691
692 fn lookup_by_key(&self, key: &str) -> Option<CachedArticle> {
693 if !self.has_entries.load(Ordering::Relaxed) {
694 self.misses.fetch_add(1, Ordering::Relaxed);
695 return None;
696 }
697
698 let (hash, tag) = hash_key(key.as_bytes());
699 let now = ttl::now_millis();
700 let lookup = {
701 let mut state = self
702 .state
703 .lock()
704 .unwrap_or_else(std::sync::PoisonError::into_inner);
705 if state.blocks_per_generation == 0 {
706 LookupResult {
707 missing_bits: 0,
708 evicted: 0,
709 empty_after: true,
710 }
711 } else {
712 let block_index = block_index(hash, state.blocks_per_generation);
713 let mut lookup = state.lookup_missing_bits(hash, tag, block_index, now);
714 lookup.empty_after = state.occupied_slots() == 0;
715 lookup
716 }
717 };
718 if lookup.empty_after {
719 self.has_entries.store(false, Ordering::Relaxed);
720 }
721 if lookup.evicted != 0 {
722 self.evictions
723 .fetch_add(lookup.evicted as u64, Ordering::Relaxed);
724 }
725
726 if lookup.missing_bits != 0 {
727 self.hits.fetch_add(1, Ordering::Relaxed);
728 Some(CachedArticle::negative_only(lookup.missing_bits))
729 } else {
730 self.misses.fetch_add(1, Ordering::Relaxed);
731 None
732 }
733 }
734
735 fn insert_missing_bits(&self, key: &str, missing_bits: usize) {
736 if key.is_empty() || missing_bits == 0 || self.ttl.get() == 0 {
737 return;
738 }
739
740 let (hash, tag) = hash_key(key.as_bytes());
741 let outcome = {
742 let mut state = self
743 .state
744 .lock()
745 .unwrap_or_else(std::sync::PoisonError::into_inner);
746 if state.blocks_per_generation == 0 {
747 StateInsertOutcome::default()
748 } else {
749 let block_index = block_index(hash, state.blocks_per_generation);
750 let victim = victim_slot(hash);
751 let now = ttl::now_millis();
752 state.insert_missing_bits(hash, tag, missing_bits, block_index, victim, now)
753 }
754 };
755
756 if outcome.evicted != 0 {
757 self.evictions
758 .fetch_add(outcome.evicted as u64, Ordering::Relaxed);
759 }
760 if outcome.changed {
761 self.has_entries.store(true, Ordering::Relaxed);
762 self.inserts.fetch_add(1, Ordering::Relaxed);
763 } else if self.capacity_bytes == 0 {
764 self.dropped.fetch_add(1, Ordering::Relaxed);
765 }
766 }
767}
768
769fn parse_entries(data: &[u8]) -> Result<Vec<PersistedEntry>> {
770 if data.len() < PERSISTENCE_MAGIC.len() + size_of::<u64>() {
771 anyhow::bail!("availability index file too short");
772 }
773
774 let magic = &data[..PERSISTENCE_MAGIC.len()];
775 if magic == LEGACY_PERSISTENCE_MAGIC_V1
776 || magic == LEGACY_PERSISTENCE_MAGIC_V2
777 || magic == LEGACY_PERSISTENCE_MAGIC_V3
778 || magic == LEGACY_PERSISTENCE_MAGIC_V4
779 || magic == LEGACY_PERSISTENCE_MAGIC_V5
780 {
781 return Ok(Vec::new());
782 }
783 if magic != PERSISTENCE_MAGIC {
784 anyhow::bail!("unknown availability index format");
785 }
786
787 let mut cursor = PERSISTENCE_MAGIC.len();
788 let entry_count = read_u64(data, &mut cursor)? as usize;
789 let mut entries = Vec::with_capacity(entry_count);
790
791 for _ in 0..entry_count {
792 let hash = read_u64(data, &mut cursor)?;
793 let tag = read_u16(data, &mut cursor)?;
794 let missing = availability_bits_from_wire(read_u64(data, &mut cursor)?)?;
795 let inserted_at = read_u64(data, &mut cursor)?;
796 entries.push(PersistedEntry {
797 hash,
798 tag,
799 missing,
800 inserted_at,
801 });
802 }
803
804 Ok(entries)
805}
806
807fn read_u64(data: &[u8], cursor: &mut usize) -> Result<u64> {
808 let bytes = data
809 .get(*cursor..*cursor + size_of::<u64>())
810 .ok_or_else(|| anyhow::anyhow!("truncated u64 field"))?;
811 *cursor += size_of::<u64>();
812 Ok(u64::from_le_bytes(bytes.try_into().unwrap()))
813}
814
815fn read_u16(data: &[u8], cursor: &mut usize) -> Result<u16> {
816 let bytes = data
817 .get(*cursor..*cursor + size_of::<u16>())
818 .ok_or_else(|| anyhow::anyhow!("truncated u16 field"))?;
819 *cursor += size_of::<u16>();
820 Ok(u16::from_le_bytes(bytes.try_into().unwrap()))
821}
822
823fn availability_bits_to_wire(bits: usize) -> Result<u64> {
824 u64::try_from(bits).context("availability bitmap exceeds u64 wire format")
825}
826
827fn availability_bits_from_wire(bits: u64) -> Result<usize> {
828 usize::try_from(bits).context("availability bitmap exceeds usize on this target")
829}
830
831fn hash_key(bytes: &[u8]) -> (u64, u16) {
832 let mut primary = XxHash64::default();
833 primary.write(bytes);
834
835 let mut tag = XxHash64::with_seed(0x9E37_79B9_7F4A_7C15);
836 tag.write(bytes);
837
838 (
839 normalize_hash(primary.finish()),
840 (tag.finish() & u16::MAX as u64) as u16,
841 )
842}
843
844fn normalize_hash(hash: u64) -> u64 {
845 if hash == 0 { 1 } else { hash }
846}
847
848fn block_index(hash: u64, block_count: usize) -> usize {
849 debug_assert!(block_count > 0);
850 (((hash >> 32) as usize) ^ (hash as usize)) % block_count
851}
852
853fn victim_slot(hash: u64) -> usize {
854 ((hash >> 48) as usize) & (BLOCK_SLOTS - 1)
855}
856
857fn matching_slots(
858 hashes: &[u64; BLOCK_SLOTS],
859 tags: &[u16; BLOCK_SLOTS],
860 needle_hash: u64,
861 needle_tag: u16,
862) -> SlotMatchMask {
863 let mut mask = 0;
864 for (index, &value) in hashes.iter().enumerate() {
865 if value == needle_hash && tags[index] == needle_tag {
866 mask |= 1u8 << index;
867 }
868 }
869 mask
870}
871
872#[cfg(test)]
873mod tests {
874 use super::super::availability::MAX_BACKENDS;
875 use super::*;
876 use tempfile::TempDir;
877
878 fn test_capacity_for(blocks: usize, generations: usize) -> u64 {
879 (blocks * generations * size_of::<Block>()) as u64
880 }
881
882 fn rewrite_persisted_inserted_at(path: &std::path::Path, inserted_at: u64) {
883 let data = std::fs::read(path).unwrap();
884 let mut entries = parse_entries(&data).unwrap();
885 assert_eq!(
886 entries.len(),
887 1,
888 "test helper expects exactly one persisted entry"
889 );
890 entries[0].inserted_at = inserted_at;
891
892 let mut bytes = Vec::with_capacity(data.len());
893 bytes.extend_from_slice(PERSISTENCE_MAGIC);
894 bytes.extend_from_slice(&(entries.len() as u64).to_le_bytes());
895 for entry in entries {
896 bytes.extend_from_slice(&entry.hash.to_le_bytes());
897 bytes.extend_from_slice(&entry.tag.to_le_bytes());
898 bytes.extend_from_slice(
899 &availability_bits_to_wire(entry.missing)
900 .unwrap()
901 .to_le_bytes(),
902 );
903 bytes.extend_from_slice(&entry.inserted_at.to_le_bytes());
904 }
905
906 std::fs::write(path, bytes).unwrap();
907 }
908
909 #[test]
910 fn lookup_miss_returns_none() {
911 let index = AvailabilityIndex::with_test_capacity(test_capacity_for(16, 2));
912 let msg_id = MessageId::from_borrowed("<miss@example.com>").unwrap();
913 assert!(index.get(&msg_id).is_none());
914 }
915
916 #[test]
917 fn slot_match_requires_confirmation_tag() {
918 let mut block = Block::default();
919 block.hashes[0] = 42;
920 block.tags[0] = 7;
921 block.missing[0] = 0b0000_0001;
922
923 assert_eq!(block.missing_bits(42, 7), 0b0000_0001);
924 assert_eq!(block.missing_bits(42, 8), 0);
925 }
926
927 #[test]
928 fn record_missing_round_trips_as_negative_cached_article() {
929 let index = AvailabilityIndex::with_test_capacity(test_capacity_for(32, 2));
930 let msg_id = MessageId::from_borrowed("<gone@example.com>").unwrap();
931 let backend_id = BackendId::from_index(2);
932
933 index.record_backend_missing(&msg_id, backend_id);
934
935 let cached = index.get(&msg_id).expect("negative entry");
936 assert!(cached.has_availability_info());
937 assert!(!cached.should_try_backend(backend_id));
938 assert_eq!(
939 cached.availability().missing_bits(),
940 backend_id.availability_bit()
941 );
942 }
943
944 #[test]
945 fn request_message_id_lookup_requires_brackets() {
946 let index = AvailabilityIndex::with_test_capacity(test_capacity_for(8, 2));
947 let msg_id = MessageId::from_borrowed("<request@example.com>").unwrap();
948
949 index.record_backend_missing(&msg_id, BackendId::from_index(0));
950
951 assert!(
952 index
953 .get_request_message_id("request@example.com")
954 .is_none()
955 );
956 assert!(
957 index
958 .get_request_message_id("<request@example.com>")
959 .is_some()
960 );
961 }
962
963 #[test]
964 fn record_missing_expires_after_configured_ttl() {
965 let index = AvailabilityIndex::with_capacity_and_generation_count(
966 test_capacity_for(8, 2),
967 std::time::Duration::from_millis(5),
968 DEFAULT_GENERATIONS,
969 );
970 let msg_id = MessageId::from_borrowed("<expires@example.com>").unwrap();
971
972 index.record_backend_missing(&msg_id, BackendId::from_index(0));
973 assert!(index.get(&msg_id).is_some());
974
975 std::thread::sleep(std::time::Duration::from_millis(15));
976
977 assert!(index.get(&msg_id).is_none());
978 }
979
980 #[test]
981 fn record_missing_supports_backend_eight_bit() {
982 let index = AvailabilityIndex::with_test_capacity(test_capacity_for(16, 2));
983 let msg_id = MessageId::from_borrowed("<highest@example.com>").unwrap();
984 let backend_id = BackendId::from_index(8);
985
986 index.record_backend_missing(&msg_id, backend_id);
987
988 let cached = index.get(&msg_id).expect("negative entry");
989 assert_eq!(cached.availability().missing_bits(), 0b1_0000_0000);
990 assert!(!cached.should_try_backend(backend_id));
991 }
992
993 #[test]
994 #[cfg(debug_assertions)]
995 fn record_missing_cannot_receive_out_of_range_backend() {
996 assert!(BackendId::try_from_index(usize::BITS as usize).is_none());
997 }
998
999 #[test]
1000 fn zero_capacity_index_never_records_entries() {
1001 let index = AvailabilityIndex::with_test_capacity(0);
1002 let msg_id = MessageId::from_borrowed("<nocap@example.com>").unwrap();
1003 index.record_backend_missing(&msg_id, BackendId::from_index(0));
1004 assert!(index.get(&msg_id).is_none());
1005 }
1006
1007 #[test]
1008 fn bounded_filter_stays_within_capacity() {
1009 let capacity = test_capacity_for(4, 2);
1010 let index = AvailabilityIndex::with_test_capacity(capacity);
1011
1012 for idx in 0..128 {
1013 let msg_id = MessageId::new(format!("<bounded-{idx}@example.com>")).unwrap();
1014 index.record_backend_missing(&msg_id, BackendId::from_index(idx % MAX_BACKENDS));
1015 }
1016
1017 assert!(index.used_bytes() <= capacity);
1018 let slot_bytes = size_of::<u64>() + size_of::<u16>() + size_of::<u8>();
1019 assert!(index.entry_count() <= (capacity as usize / slot_bytes) as u64);
1020 }
1021
1022 #[test]
1023 fn used_bytes_reports_preallocated_capacity() {
1024 let capacity = test_capacity_for(4, 2);
1025 let index = AvailabilityIndex::with_test_capacity(capacity);
1026 let msg_id = MessageId::from_borrowed("<allocated@example.com>").unwrap();
1027
1028 assert_eq!(index.used_bytes(), capacity);
1029
1030 index.record_backend_missing(&msg_id, BackendId::from_index(0));
1031
1032 assert_eq!(index.used_bytes(), capacity);
1033 }
1034
1035 #[test]
1036 fn saturated_block_evicts_old_fingerprints() {
1037 let capacity = test_capacity_for(1, 1);
1038 let index = AvailabilityIndex::with_generation_count(capacity, 1);
1039
1040 for idx in 0..(BLOCK_SLOTS + 4) {
1041 let msg_id = MessageId::new(format!("<evict-{idx}@example.com>")).unwrap();
1042 index.record_backend_missing(&msg_id, BackendId::from_index(idx % MAX_BACKENDS));
1043 }
1044
1045 let latest = MessageId::new(format!("<evict-{}@example.com>", BLOCK_SLOTS + 3)).unwrap();
1046 assert!(
1047 index.get(&latest).is_some(),
1048 "latest insert should still be resident"
1049 );
1050 assert!(
1051 index.evictions() >= 1,
1052 "full blocks should overwrite old fingerprints"
1053 );
1054 assert!(index.entry_count() <= BLOCK_SLOTS as u64);
1055 }
1056
1057 #[test]
1058 fn save_and_load_roundtrip_restores_entries() {
1059 let temp_dir = TempDir::new().unwrap();
1060 let path = temp_dir.path().join("availability.idx");
1061 let index = AvailabilityIndex::with_test_capacity(test_capacity_for(16, 2));
1062 let first = MessageId::from_borrowed("<first@example.com>").unwrap();
1063 let second = MessageId::from_borrowed("<second@example.com>").unwrap();
1064
1065 index.record_backend_missing(&first, BackendId::from_index(0));
1066 index.record_backend_missing(&second, BackendId::from_index(2));
1067 index.save_to_path(&path).unwrap();
1068
1069 let restored = AvailabilityIndex::with_test_capacity(test_capacity_for(16, 2));
1070 assert!(restored.load_from_path(&path).unwrap());
1071
1072 assert!(
1073 !restored
1074 .get(&first)
1075 .expect("restored first entry")
1076 .should_try_backend(BackendId::from_index(0))
1077 );
1078 assert!(
1079 !restored
1080 .get(&second)
1081 .expect("restored second entry")
1082 .should_try_backend(BackendId::from_index(2))
1083 );
1084 }
1085
1086 #[test]
1087 fn save_and_load_roundtrip_restores_multiple_backend_bits() {
1088 let temp_dir = TempDir::new().unwrap();
1089 let path = temp_dir.path().join("availability-multi.idx");
1090 let index = AvailabilityIndex::with_test_capacity(test_capacity_for(16, 2));
1091 let msg_id = MessageId::from_borrowed("<multi@example.com>").unwrap();
1092
1093 index.record_backend_missing(&msg_id, BackendId::from_index(1));
1094 index.record_backend_missing(&msg_id, BackendId::from_index(3));
1095 index.save_to_path(&path).unwrap();
1096
1097 let restored = AvailabilityIndex::with_test_capacity(test_capacity_for(16, 2));
1098 assert!(restored.load_from_path(&path).unwrap());
1099
1100 let cached = restored.get(&msg_id).expect("restored negative");
1101 assert!(!cached.should_try_backend(BackendId::from_index(1)));
1102 assert!(!cached.should_try_backend(BackendId::from_index(3)));
1103 assert!(cached.should_try_backend(BackendId::from_index(0)));
1104 }
1105
1106 #[test]
1107 fn load_from_path_skips_expired_entries() {
1108 let temp_dir = TempDir::new().unwrap();
1109 let path = temp_dir.path().join("availability-expired.idx");
1110 let index = AvailabilityIndex::with_capacity_and_generation_count(
1111 test_capacity_for(8, 2),
1112 std::time::Duration::from_millis(5),
1113 DEFAULT_GENERATIONS,
1114 );
1115 let msg_id = MessageId::from_borrowed("<persisted-expired@example.com>").unwrap();
1116
1117 index.record_backend_missing(&msg_id, BackendId::from_index(0));
1118 index.save_to_path(&path).unwrap();
1119 std::thread::sleep(std::time::Duration::from_millis(15));
1120
1121 let restored = AvailabilityIndex::with_capacity_and_generation_count(
1122 test_capacity_for(8, 2),
1123 std::time::Duration::from_millis(5),
1124 DEFAULT_GENERATIONS,
1125 );
1126 assert!(restored.load_from_path(&path).unwrap());
1127 assert!(restored.get(&msg_id).is_none());
1128 }
1129
1130 #[test]
1131 fn load_from_path_keeps_older_generation_as_rotation_anchor() {
1132 let temp_dir = TempDir::new().unwrap();
1133 let path = temp_dir.path().join("availability-older-generation.idx");
1134 let ttl = std::time::Duration::from_millis(200);
1135 let index = AvailabilityIndex::with_capacity_and_generation_count(
1136 test_capacity_for(8, 2),
1137 ttl,
1138 DEFAULT_GENERATIONS,
1139 );
1140 let msg_id = MessageId::from_borrowed("<persisted-older@example.com>").unwrap();
1141
1142 index.record_backend_missing(&msg_id, BackendId::from_index(0));
1143 index.save_to_path(&path).unwrap();
1144 rewrite_persisted_inserted_at(
1145 &path,
1146 ttl::now_millis().saturating_sub((ttl.as_millis() as u64 / 2) + 20),
1147 );
1148
1149 let restored = AvailabilityIndex::with_capacity_and_generation_count(
1150 test_capacity_for(8, 2),
1151 ttl,
1152 DEFAULT_GENERATIONS,
1153 );
1154 assert!(restored.load_from_path(&path).unwrap());
1155 assert!(
1156 restored.get(&msg_id).is_some(),
1157 "restored older-generation entry should survive the first lookup"
1158 );
1159 }
1160
1161 #[test]
1162 fn rotated_generation_starts_when_it_receives_a_new_insert() {
1163 let ttl = std::time::Duration::from_millis(200);
1164 let index = AvailabilityIndex::with_capacity_and_generation_count(
1165 test_capacity_for(8, 2),
1166 ttl,
1167 DEFAULT_GENERATIONS,
1168 );
1169 let first = MessageId::from_borrowed("<before-rotate@example.com>").unwrap();
1170 let second = MessageId::from_borrowed("<after-rotate@example.com>").unwrap();
1171 let forced_old_started_at = ttl::now_millis().saturating_sub(50);
1172
1173 index.record_backend_missing(&first, BackendId::from_index(0));
1174 {
1175 let mut state = index.state.lock().unwrap_or_else(|e| e.into_inner());
1176 let current_generation = state.current_generation;
1177 state.generations[current_generation].started_at = forced_old_started_at;
1178 state.next_rotation_at = ttl::now_millis().saturating_sub(1);
1179 }
1180 let before_second_insert = ttl::now_millis();
1181 index.record_backend_missing(&second, BackendId::from_index(1));
1182
1183 let state = index.state.lock().unwrap_or_else(|e| e.into_inner());
1184 let current_generation = &state.generations[state.current_generation];
1185 assert!(
1186 current_generation.started_at >= before_second_insert,
1187 "rotated generation should start when the new insert arrives"
1188 );
1189 assert!(
1190 current_generation.started_at > forced_old_started_at,
1191 "rotated generation should not keep the historical rotation timestamp"
1192 );
1193 }
1194
1195 #[test]
1196 fn hit_rate_tracks_hits_and_misses() {
1197 let index = AvailabilityIndex::with_test_capacity(test_capacity_for(8, 2));
1198 let hit = MessageId::from_borrowed("<hit-rate@example.com>").unwrap();
1199 let miss = MessageId::from_borrowed("<miss-rate@example.com>").unwrap();
1200
1201 index.record_backend_missing(&hit, BackendId::from_index(0));
1202 assert!(index.get(&hit).is_some());
1203 assert!(index.get(&miss).is_none());
1204
1205 assert_eq!(index.hit_rate(), 50.0);
1206 }
1207
1208 #[test]
1209 fn state_uses_expected_geometry() {
1210 let capacity = test_capacity_for(6, 3);
1211 let index = AvailabilityIndex::with_generation_count(capacity, 3);
1212 let state = index.state.lock().unwrap_or_else(|e| e.into_inner());
1213 assert_eq!(state.generation_count(), 3);
1214 assert_eq!(state.blocks_per_generation(), 6);
1215 }
1216}