Skip to main content

nntp_proxy/cache/
availability_index.rs

1//! Bounded availability-only blocked fingerprint index.
2//!
3//! Stores negative-only backend availability using a rotating blocked fingerprint
4//! filter. The filter is bounded by `capacity_bytes`, favors throughput, and
5//! accepts occasional false negatives from rotation/overwrites. False positives
6//! are pushed down by storing a keyed 64-bit fingerprint plus a keyed 16-bit
7//! confirmation tag per slot.
8
9use 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}