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