Skip to main content

graph_r/
snapshot.rs

1// Author: spicyzhug@gmail.com
2// Copyright: lituus-io, all rights reserved
3//
4//! Read views. A [`Snapshot`] pins one base generation plus the overlay ops
5//! committed before it was taken; everything it returns — node refs, edge
6//! refs, hits, due items — borrows either the mmap or the overlay arena, so
7//! reads are zero-copy and never invalidated by concurrent commits.
8//!
9//! Structural results (neighbors, ranks, communities, the lexicon behind
10//! query seeds) reflect the last **compaction**; point lookups, freshness
11//! and due-lists additionally see every committed op. The link-r bridge
12//! compacts after ingest, so in that flow the distinction disappears.
13//!
14//! Cost model: taking a snapshot is O(1); the first read that consults the
15//! overlay folds the pending ops (bounded by the compaction threshold) into
16//! a per-snapshot view, cached for the snapshot's lifetime — so batch many
17//! reads per snapshot rather than one snapshot per read when a writer is
18//! active.
19
20use crate::format::base::{self, nflags, NodeRec, SegRec};
21use crate::key::{NodeId, SegKey, UrlKey};
22use crate::overlay::{OverlayNode, OverlayView};
23use crate::store::{GenInner, Store};
24use crate::traverse::{Neighbors, NeighborsInner};
25use crate::ttl::TtlConfig;
26use std::cell::OnceCell;
27use std::sync::RwLockReadGuard;
28
29/// A pinned, immutable read view of the store.
30pub struct Snapshot<'s> {
31    store: &'s Store,
32    guard: RwLockReadGuard<'s, Option<GenInner>>,
33    frontier: usize,
34    view: OnceCell<OverlayView>,
35    /// Overlay edges reversed (destination → sources), built on first use.
36    inbound: OnceCell<std::collections::HashMap<u64, Vec<crate::format::wal::OwnedEdge>>>,
37    hub_threshold: OnceCell<u32>,
38    live: OnceCell<usize>,
39}
40
41impl std::fmt::Debug for Snapshot<'_> {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct("Snapshot")
44            .field("generation", &self.generation())
45            .field("overlay_ops", &self.frontier)
46            .finish()
47    }
48}
49
50/// A document node resolved through a snapshot. All strings are borrowed.
51#[derive(Clone, Copy, Debug)]
52pub struct NodeRef<'s> {
53    /// Durable key.
54    pub key: UrlKey,
55    /// Dense id in the current base generation; `None` for nodes that only
56    /// exist in the overlay (committed after the last compaction).
57    pub id: Option<NodeId>,
58    /// Canonical URL ("" for stubs).
59    pub url: &'s str,
60    /// Title, if known.
61    pub title: Option<&'s str>,
62    /// Distilled snippet, if known.
63    pub snippet: Option<&'s str>,
64    /// Last seen entity tag, if any (needed for conditional revalidation).
65    pub etag: Option<&'s str>,
66    /// xxh3 of the last-seen body.
67    pub content_hash: u64,
68    /// Last successful fetch/revalidation stamp.
69    pub fetched_at_ms: u64,
70    /// Last observed content change stamp.
71    pub last_change_ms: u64,
72    /// Current adaptive revalidation interval, seconds.
73    pub interval_s: u32,
74    /// Revalidations observed (saturating).
75    pub checks: u16,
76    /// Content changes observed (saturating).
77    pub changes: u16,
78    /// Node flags (`format::base::nflags`).
79    pub flags: u16,
80    /// Rank percentile in permille (0 for overlay-fresh nodes).
81    pub rank_permille: u16,
82}
83
84impl NodeRef<'_> {
85    /// Pinned nodes are exempt from eviction sweeps.
86    #[must_use]
87    pub fn pinned(&self) -> bool {
88        self.flags & nflags::PINNED != 0
89    }
90    /// True for edge-target stubs never ingested themselves.
91    #[must_use]
92    pub fn is_stub(&self) -> bool {
93        self.flags & nflags::STUB != 0
94    }
95    /// True when the source was observed gone.
96    #[must_use]
97    pub fn is_tombstone(&self) -> bool {
98        self.flags & nflags::TOMBSTONE != 0
99    }
100}
101
102/// A sub-document segment reference: an anchor, never content.
103#[derive(Clone, Copy, Debug)]
104pub struct SegRef<'s> {
105    /// Durable segment key.
106    pub key: SegKey,
107    /// Heading-path label.
108    pub label: &'s str,
109    /// Byte range in the source document, when known.
110    pub byte_range: Option<(u32, u32)>,
111    /// Heading depth (1 = H1 …).
112    pub depth: u8,
113    /// Importance in 1/65535 units.
114    pub importance: u16,
115    /// Raw segment flags (`format::base::sflags`).
116    pub flags: u8,
117}
118
119/// One entry of a due-for-revalidation work list.
120#[derive(Clone, Copy, Debug)]
121pub struct DueItem<'s> {
122    /// Canonical URL to revalidate.
123    pub url: &'s str,
124    /// Durable key.
125    pub key: UrlKey,
126    /// Last seen entity tag to send as `If-None-Match`.
127    pub etag: Option<&'s str>,
128    /// How far past due, in milliseconds.
129    pub overdue_ms: u64,
130    /// Rank percentile (higher = revalidate first).
131    pub rank_permille: u16,
132}
133
134impl<'s> Snapshot<'s> {
135    pub(crate) fn new(store: &'s Store, guard: RwLockReadGuard<'s, Option<GenInner>>) -> Self {
136        let frontier = guard.as_ref().map_or(0, |g| g.arena.len());
137        Self {
138            store,
139            guard,
140            frontier,
141            view: OnceCell::new(),
142            inbound: OnceCell::new(),
143            hub_threshold: OnceCell::new(),
144            live: OnceCell::new(),
145        }
146    }
147
148    fn inner(&self) -> &GenInner {
149        self.guard.as_ref().expect("snapshot pins a populated slot")
150    }
151
152    pub(crate) fn ttl(&self) -> &TtlConfig {
153        &self.store.cfg.ttl
154    }
155
156    pub(crate) fn overlay(&self) -> &OverlayView {
157        self.view.get_or_init(|| OverlayView::build(self.inner().arena.committed(self.frontier)))
158    }
159
160    /// The base generation this snapshot pins.
161    #[must_use]
162    pub fn generation(&self) -> u64 {
163        self.inner().dir.header.generation
164    }
165
166    // ---- raw section access -------------------------------------------------
167
168    pub(crate) fn bytes(&self) -> &[u8] {
169        self.inner().bytes()
170    }
171    pub(crate) fn nodes_bytes(&self) -> &[u8] {
172        self.inner().dir.section(self.bytes(), base::SectionKind::Nodes)
173    }
174    pub(crate) fn labels(&self) -> &[u8] {
175        self.inner().dir.section(self.bytes(), base::SectionKind::Labels)
176    }
177    pub(crate) fn segs_bytes(&self) -> &[u8] {
178        self.inner().dir.section(self.bytes(), base::SectionKind::Segs)
179    }
180    pub(crate) fn edges_bytes(&self) -> &[u8] {
181        self.inner().dir.section(self.bytes(), base::SectionKind::Edges)
182    }
183    pub(crate) fn edge_index(&self) -> &[u8] {
184        self.inner().dir.section(self.bytes(), base::SectionKind::EdgeIndex)
185    }
186    pub(crate) fn section_bytes(&self, kind: base::SectionKind) -> &[u8] {
187        self.section(kind)
188    }
189    fn section(&self, kind: base::SectionKind) -> &[u8] {
190        self.inner().dir.section(self.bytes(), kind)
191    }
192    pub(crate) fn ranks_bytes(&self) -> &[u8] {
193        self.inner().dir.section(self.bytes(), base::SectionKind::Ranks)
194    }
195    pub(crate) fn lexicon(&self) -> &[u8] {
196        self.inner().dir.section(self.bytes(), base::SectionKind::Lexicon)
197    }
198    pub(crate) fn postings(&self) -> &[u8] {
199        self.inner().dir.section(self.bytes(), base::SectionKind::Postings)
200    }
201
202    /// Node count in the base (including stubs/tombstones).
203    #[must_use]
204    pub fn base_len(&self) -> usize {
205        self.inner().dir.header.node_count as usize
206    }
207
208    pub(crate) fn rec(&self, id: NodeId) -> NodeRec<'_> {
209        let i = id.0 as usize;
210        NodeRec(&self.nodes_bytes()[i * base::NODE_LEN..(i + 1) * base::NODE_LEN])
211    }
212
213    pub(crate) fn base_node_id(&self, key: UrlKey) -> Option<NodeId> {
214        base::find_node(self.nodes_bytes(), key).map(NodeId)
215    }
216
217    /// (key, url) of a base node — used by edge resolution.
218    pub(crate) fn node_identity(&self, id: NodeId) -> (UrlKey, Option<&str>) {
219        let rec = self.rec(id);
220        (rec.url_key(), base::label_at(self.labels(), rec.url_off()))
221    }
222
223    pub(crate) fn rank_permille_of(&self, id: NodeId) -> u16 {
224        let ranks = self.ranks_bytes();
225        let off = id.0 as usize * base::RANK_LEN;
226        if off + base::RANK_LEN <= ranks.len() {
227            crate::bytesio::get_u16(ranks, off)
228        } else {
229            0
230        }
231    }
232
233    // ---- merged lookups -----------------------------------------------------
234
235    /// Look up a node by durable key, merging base and overlay state.
236    #[must_use]
237    pub fn node(&self, key: UrlKey) -> Option<NodeRef<'_>> {
238        let patch = self.overlay().map.get(&key.0);
239        if let Some(p) = patch {
240            if p.removed {
241                return None;
242            }
243        }
244        let base_id = self.base_node_id(key);
245        match (base_id, patch.and_then(|p| p.core.as_ref())) {
246            (_, Some(core)) => {
247                // Overlay core wins wholesale (it is the newest fetch).
248                let p = patch.expect("core implies patch");
249                let mut flags = core.flags;
250                // Pin resolution: an explicit overlay pin state (from
251                // SetPinned or folded upsert history) is authoritative;
252                // otherwise pins are sticky across the base generation —
253                // an unpinned re-ingest never silently unpins.
254                match p.pinned {
255                    Some(pinned) => {
256                        flags =
257                            if pinned { flags | nflags::PINNED } else { flags & !nflags::PINNED };
258                    }
259                    None => {
260                        if let (Some(id), false) = (base_id, p.severed) {
261                            if self.rec(id).flags() & nflags::PINNED != 0 {
262                                flags |= nflags::PINNED;
263                            }
264                        }
265                    }
266                }
267                let (mut fetched, mut interval, mut checks, mut changes, mut last_change) =
268                    (core.fetched_at_ms, self.ttl().base_s, 0u16, 0u16, 0u64);
269                let mut content_hash = core.content_hash;
270                let mut etag: Option<&str> = core.etag.as_deref();
271                if let Some(t) = &p.touch {
272                    fetched = t.checked_at_ms;
273                    interval = t.interval_s;
274                    checks = t.checks;
275                    changes = t.changes;
276                    last_change = t.last_change_ms;
277                    if let Some(h) = t.content_hash {
278                        content_hash = h;
279                    }
280                    if let Some(e) = t.etag.as_deref() {
281                        etag = Some(e);
282                    }
283                    if t.tombstone {
284                        flags |= nflags::TOMBSTONE;
285                    }
286                } else {
287                    // No touch since the upsert: counters (history) always
288                    // carry forward — from overlay history first, else from
289                    // the base record; the interval keeps its learned value
290                    // only when the content is in fact unchanged (a re-absorb
291                    // of an identical page must not reset backoff).
292                    if let Some((c, ch, lc)) = p.carried {
293                        checks = c;
294                        changes = ch;
295                        last_change = lc;
296                    } else if let (Some(id), false) = (base_id, p.severed) {
297                        let rec = self.rec(id);
298                        checks = rec.checks();
299                        changes = rec.changes();
300                        last_change = rec.last_change_ms();
301                    }
302                    if let (Some(id), false) = (base_id, p.severed) {
303                        let rec = self.rec(id);
304                        if rec.content_hash() == content_hash {
305                            interval = rec.interval_s().max(1);
306                        }
307                    }
308                }
309                Some(NodeRef {
310                    key,
311                    id: base_id,
312                    url: core.url.as_str(),
313                    title: core.title.as_deref(),
314                    snippet: core.snippet.as_deref(),
315                    etag,
316                    content_hash,
317                    fetched_at_ms: fetched,
318                    last_change_ms: last_change,
319                    interval_s: interval,
320                    checks,
321                    changes,
322                    flags,
323                    rank_permille: base_id.map_or(0, |id| self.rank_permille_of(id)),
324                })
325            }
326            (Some(id), None) => Some(self.base_node_ref(id, patch)),
327            (None, None) => None,
328        }
329    }
330
331    pub(crate) fn base_node_ref<'a>(
332        &'a self,
333        id: NodeId,
334        patch: Option<&'a OverlayNode>,
335    ) -> NodeRef<'a> {
336        let rec = self.rec(id);
337        let labels = self.labels();
338        let mut flags = rec.flags();
339        let (mut fetched, mut interval, mut checks, mut changes, mut last_change) = (
340            rec.fetched_at_ms(),
341            rec.interval_s(),
342            rec.checks(),
343            rec.changes(),
344            rec.last_change_ms(),
345        );
346        let mut content_hash = rec.content_hash();
347        let mut etag = base::label_at(labels, rec.etag_off());
348        if let Some(p) = patch {
349            if let Some(pinned) = p.pinned {
350                flags = if pinned { flags | nflags::PINNED } else { flags & !nflags::PINNED };
351            }
352            if let Some(t) = &p.touch {
353                fetched = t.checked_at_ms;
354                interval = t.interval_s;
355                checks = t.checks;
356                changes = t.changes;
357                last_change = t.last_change_ms;
358                if let Some(h) = t.content_hash {
359                    content_hash = h;
360                }
361                if let Some(e) = t.etag.as_deref() {
362                    etag = Some(e);
363                }
364                if t.tombstone {
365                    flags |= nflags::TOMBSTONE;
366                }
367            }
368        }
369        NodeRef {
370            key: rec.url_key(),
371            id: Some(id),
372            url: base::label_at(labels, rec.url_off()).unwrap_or(""),
373            title: base::label_at(labels, rec.title_off()),
374            snippet: base::label_at(labels, rec.snippet_off()),
375            etag,
376            content_hash,
377            fetched_at_ms: fetched,
378            last_change_ms: last_change,
379            interval_s: interval,
380            checks,
381            changes,
382            flags,
383            rank_permille: self.rank_permille_of(id),
384        }
385    }
386
387    /// Live (non-stub, non-tombstone, non-removed) document count.
388    #[must_use]
389    pub fn len(&self) -> usize {
390        *self.live.get_or_init(|| {
391            let mut n = 0usize;
392            self.for_each_live(|_| n += 1);
393            n
394        })
395    }
396
397    /// True when no live documents exist.
398    #[must_use]
399    pub fn is_empty(&self) -> bool {
400        self.len() == 0
401    }
402
403    /// Visit every live node (base then overlay-fresh, both in key order for
404    /// determinism where it matters to callers).
405    pub fn for_each_live<'a>(&'a self, mut f: impl FnMut(NodeRef<'a>)) {
406        let view = self.overlay();
407        for i in 0..self.base_len() {
408            let id = NodeId(i as u32);
409            let key = self.rec(id).url_key();
410            let patch = view.map.get(&key.0);
411            if patch.is_some_and(|p| p.removed) {
412                continue;
413            }
414            let nref = match patch.and_then(|p| p.core.as_ref()) {
415                Some(_) => self.node(key).expect("core implies live"),
416                None => self.base_node_ref(id, patch),
417            };
418            if nref.is_stub() || nref.is_tombstone() {
419                continue;
420            }
421            f(nref);
422        }
423        // Overlay-fresh nodes (no base record), in ascending key order.
424        let mut fresh: Vec<u64> = view
425            .map
426            .iter()
427            .filter(|(k, p)| {
428                !p.removed && p.core.is_some() && self.base_node_id(UrlKey(**k)).is_none()
429            })
430            .map(|(k, _)| *k)
431            .collect();
432        fresh.sort_unstable();
433        for k in fresh {
434            if let Some(nref) = self.node(UrlKey(k)) {
435                if !nref.is_tombstone() {
436                    f(nref);
437                }
438            }
439        }
440    }
441
442    /// Outbound edges of `key`. Overlay whole-set replacements win over the
443    /// base CSR run.
444    #[must_use]
445    pub fn neighbors(&self, key: UrlKey) -> Neighbors<'_> {
446        if let Some(p) = self.overlay().map.get(&key.0) {
447            if p.removed {
448                return Neighbors { inner: NeighborsInner::Empty };
449            }
450            if let Some(edges) = &p.edges {
451                return Neighbors { inner: NeighborsInner::Overlay { snap: self, edges, idx: 0 } };
452            }
453            if p.severed {
454                // Resurrected after a remove: the base record's old edges are
455                // history the remove already discarded.
456                return Neighbors { inner: NeighborsInner::Empty };
457            }
458        }
459        let Some(id) = self.base_node_id(key) else {
460            return Neighbors { inner: NeighborsInner::Empty };
461        };
462        let idx = self.edge_index();
463        if idx.is_empty() {
464            return Neighbors { inner: NeighborsInner::Empty };
465        }
466        let start = crate::bytesio::get_u32(idx, id.0 as usize * 4) as usize;
467        let end = crate::bytesio::get_u32(idx, (id.0 as usize + 1) * 4) as usize;
468        Neighbors {
469            inner: NeighborsInner::Base { snap: self, edges: self.edges_bytes(), idx: start, end },
470        }
471    }
472
473    /// Inbound edges of `key`, as `(source, type, flags, weight)`: the
474    /// sources whose edge sets point at it. Base runs come from the reverse
475    /// index written at compaction; sources whose edges the overlay replaced
476    /// or removed are skipped there and reported from the overlay instead.
477    pub fn in_edges(&self, key: UrlKey, mut f: impl FnMut(UrlKey, crate::EdgeType, u8, u16)) {
478        let overlay = &self.overlay().map;
479        if let Some(id) = self.base_node_id(key) {
480            let idx = self.section(base::SectionKind::InEdgeIndex);
481            if !idx.is_empty() {
482                let at = |i: usize| crate::bytesio::get_u32(idx, i * 4) as usize;
483                let recs = self.section(base::SectionKind::InEdges);
484                for i in at(id.0 as usize)..at(id.0 as usize + 1) {
485                    let rec = &recs[i * base::EDGE_LEN..(i + 1) * base::EDGE_LEN];
486                    let (src, _) = self.node_identity(NodeId(crate::bytesio::get_u32(rec, 0)));
487                    let replaced = overlay
488                        .get(&src.0)
489                        .is_some_and(|p| p.edges.is_some() || p.removed || p.severed);
490                    if !replaced {
491                        f(
492                            src,
493                            crate::EdgeType::from_tag(rec[4]),
494                            rec[5],
495                            crate::bytesio::get_u16(rec, 6),
496                        );
497                    }
498                }
499            }
500        }
501        let inbound = self.inbound.get_or_init(|| {
502            let mut m: std::collections::HashMap<u64, Vec<crate::format::wal::OwnedEdge>> =
503                std::collections::HashMap::new();
504            for (&src, p) in overlay {
505                if p.removed {
506                    continue;
507                }
508                for &(dst, etype, flags, weight) in p.edges.iter().flatten() {
509                    m.entry(dst.0).or_default().push((UrlKey(src), etype, flags, weight));
510                }
511            }
512            for v in m.values_mut() {
513                v.sort_unstable_by_key(|e| e.0);
514            }
515            m
516        });
517        for &(src, etype, flags, weight) in inbound.get(&key.0).into_iter().flatten() {
518            f(src, crate::EdgeType::from_tag(etype), flags, weight);
519        }
520    }
521
522    /// Value of attribute `k` of `key` (see [`crate::Writer::set_attrs`]).
523    #[must_use]
524    pub fn attr(&self, key: UrlKey, k: u8) -> Option<&str> {
525        let mut out = None;
526        self.attrs(key, |ak, v| {
527            if ak == k {
528                out = Some(v);
529            }
530        });
531        out
532    }
533
534    /// Every attribute of `key`, in key order. An overlay whole-set
535    /// replacement wins over the base.
536    pub fn attrs<'a>(&'a self, key: UrlKey, mut f: impl FnMut(u8, &'a str)) {
537        if let Some(p) = self.overlay().map.get(&key.0) {
538            if p.removed || (p.attrs.is_none() && p.severed) {
539                return;
540            }
541            if let Some(attrs) = &p.attrs {
542                for (k, v) in attrs {
543                    f(*k, v.as_str());
544                }
545                return;
546            }
547        }
548        let Some(id) = self.base_node_id(key) else { return };
549        let idx = self.section(base::SectionKind::AttrIndex);
550        if idx.is_empty() {
551            return;
552        }
553        let at = |i: usize| crate::bytesio::get_u32(idx, i * 4) as usize;
554        let (recs, blobs) =
555            (self.section(base::SectionKind::Attrs), self.section(base::SectionKind::Blobs));
556        for i in at(id.0 as usize)..at(id.0 as usize + 1) {
557            let rec = &recs[i * base::ATTR_LEN..(i + 1) * base::ATTR_LEN];
558            let (o, l) = (
559                crate::bytesio::get_u32(rec, 4) as usize,
560                crate::bytesio::get_u32(rec, 8) as usize,
561            );
562            // Validated at open: in bounds and UTF-8.
563            f(rec[0], std::str::from_utf8(&blobs[o..o + l]).unwrap_or(""));
564        }
565    }
566
567    /// Every named attribute of `key` (see [`crate::Writer::set_named_attrs`])
568    /// in name order, as `(name, value)` borrowed from the mmap or overlay.
569    /// Records written without a name ([`crate::Writer::set_attrs`]) carry
570    /// no NUL and are skipped.
571    pub fn named_attrs<'a>(&'a self, key: UrlKey, mut f: impl FnMut(&'a str, &'a str)) {
572        self.attrs(key, |_, rec| {
573            if let Some((name, value)) = rec.split_once('\0') {
574                if !name.is_empty() {
575                    f(name, value);
576                }
577            }
578        });
579    }
580
581    /// The node's segments (anchors). Overlay whole-set replacement wins.
582    #[must_use]
583    pub fn segments(&self, key: UrlKey) -> Vec<SegRef<'_>> {
584        if let Some(p) = self.overlay().map.get(&key.0) {
585            if p.removed {
586                return Vec::new();
587            }
588            if p.segs.is_none() && p.severed {
589                return Vec::new();
590            }
591            if let Some(segs) = &p.segs {
592                let out: Vec<SegRef<'_>> = segs
593                    .iter()
594                    .map(|s| SegRef {
595                        key: SegKey::derive(key, &s.label),
596                        label: s.label.as_str(),
597                        byte_range: (s.flags & base::sflags::NO_RANGE == 0)
598                            .then_some((s.byte_start, s.byte_len)),
599                        depth: s.depth,
600                        importance: s.importance,
601                        flags: s.flags,
602                    })
603                    .collect();
604                return out;
605            }
606        }
607        let Some(id) = self.base_node_id(key) else { return Vec::new() };
608        let rec = self.rec(id);
609        let segs = self.segs_bytes();
610        let labels = self.labels();
611        let start = rec.seg_start() as usize;
612        let mut out = Vec::with_capacity(usize::from(rec.seg_count()));
613        for i in start..start + usize::from(rec.seg_count()) {
614            let s = SegRec(&segs[i * base::SEG_LEN..(i + 1) * base::SEG_LEN]);
615            out.push(SegRef {
616                key: SegKey(s.seg_key()),
617                label: base::label_at(labels, s.label_off()).unwrap_or(""),
618                byte_range: (s.flags() & base::sflags::NO_RANGE == 0)
619                    .then(|| (s.byte_start(), s.byte_len())),
620                depth: s.depth(),
621                importance: s.importance(),
622                flags: s.flags(),
623            });
624        }
625        out
626    }
627
628    /// The p99 total-degree hub threshold for this generation (BFS refuses to
629    /// expand *through* nodes at or above it).
630    #[must_use]
631    pub(crate) fn hub_threshold(&self) -> u32 {
632        *self.hub_threshold.get_or_init(|| {
633            let n = self.base_len();
634            if n == 0 {
635                return u32::MAX;
636            }
637            let idx = self.edge_index();
638            let mut degrees: Vec<u32> = (0..n)
639                .map(|i| {
640                    let out = if idx.is_empty() {
641                        0
642                    } else {
643                        crate::bytesio::get_u32(idx, (i + 1) * 4)
644                            - crate::bytesio::get_u32(idx, i * 4)
645                    };
646                    out + self.rec(NodeId(i as u32)).in_degree()
647                })
648                .collect();
649            degrees.sort_unstable();
650            let p99 = degrees[(n * 99 / 100).min(n - 1)];
651            p99.max(8)
652        })
653    }
654
655    /// Everything due for revalidation at `now_ms`, most important first.
656    #[must_use]
657    pub fn due(&self, now_ms: u64, max: usize) -> Vec<DueItem<'_>> {
658        let cfg = self.ttl();
659        let mut out: Vec<DueItem<'_>> = Vec::new();
660        // Pinned nodes still revalidate: pinning guards eviction, not TTL.
661        self.for_each_live(|n| {
662            let next = cfg.next_check_at_ms(n.fetched_at_ms, n.interval_s.max(1), n.rank_permille);
663            if next <= now_ms && !n.url.is_empty() {
664                out.push(DueItem {
665                    url: n.url,
666                    key: n.key,
667                    etag: n.etag,
668                    overdue_ms: now_ms - next,
669                    rank_permille: n.rank_permille,
670                });
671            }
672        });
673        let order = |a: &DueItem<'_>, b: &DueItem<'_>| {
674            b.rank_permille
675                .cmp(&a.rank_permille)
676                .then(b.overdue_ms.cmp(&a.overdue_ms))
677                .then(a.key.0.cmp(&b.key.0))
678        };
679        // Select the first `max`, then order only those.
680        if max < out.len() {
681            out.select_nth_unstable_by(max, order);
682            out.truncate(max);
683        }
684        out.sort_by(order);
685        out
686    }
687
688    /// Keys touched by the overlay visible to this snapshot (unsorted).
689    pub(crate) fn overlay_keys(&self) -> Vec<u64> {
690        self.overlay().map.keys().copied().collect()
691    }
692}