Skip to main content

linkrs_storage/edge/node_group/
freeze.rs

1//! Explicit freeze and unfreeze of single groups.
2//!
3//! Freezing packs one group's mutable variant into the frozen packed form:
4//! one contiguous neighbor segment plus a degree table, with no capacities,
5//! overflow chains, live index or locks. The packed rows preserve the logical
6//! content of the source group (tombstones verbatim, gap sentinels dropped)
7//! in sorted `(endpoint, rank, edge_id)` row order, so
8//! timestamp-filtered reads observe the same logical entries before and
9//! after. Unfreezing rebuilds a fresh mutable variant by replaying the packed
10//! rows through the regular insert and delete entries, restoring the same
11//! logical content in fresh physical order.
12//!
13//! Both operations are explicit. Freeze optionally reclaims first at a caller
14//! cutoff through the shared group compaction, reporting removals for the
15//! authority above. Frozen groups reject writes; freezing marks the group
16//! deleted-dirty so the next checkpoint rewrites the base file and drops the
17//! absorbed append sidecar.
18
19use linkrs_core::types::{EdgeId, Timestamp};
20use linkrs_core::{StorageError, StorageResult};
21
22use super::super::{
23    CsrBase, CsrVariant, ImmutableCsr, MutableCsrTrait, Nbr, INVALID_EDGE_ID, NO_EDGES_STORED_MSG,
24};
25use super::CsrShardSet;
26
27/// Why one group cannot freeze right now.
28///
29/// Every variant maps to the exact rejection `freeze_group` would return, so
30/// a caller can quote the outcome before paying the compaction plus pack.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum FreezeBlockReason {
33    /// No such group exists; nothing to freeze.
34    MissingGroup,
35    /// The group stores no edges for this edge type.
36    NoEdgesStored,
37    /// The group already holds the packed frozen form.
38    AlreadyFrozen,
39}
40
41impl FreezeBlockReason {
42    /// Human-readable rejection matching the `freeze_group` error text.
43    pub fn message(self, gid: usize) -> String {
44        match self {
45            FreezeBlockReason::MissingGroup => {
46                format!("group {} missing, nothing to freeze", gid)
47            }
48            FreezeBlockReason::NoEdgesStored => NO_EDGES_STORED_MSG.to_string(),
49            FreezeBlockReason::AlreadyFrozen => {
50                format!("group {} is already frozen", gid)
51            }
52        }
53    }
54}
55
56/// Read-only freeze outcome for one group.
57///
58/// Computed without compaction, packing or authority traffic: calling it
59/// never changes what a later `freeze_group` would do.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum FreezeFeasibility {
62    /// The group would freeze; carries the physical entries the packer
63    /// would carry over.
64    Ready {
65        /// Physical entries in the group, tombstones included.
66        edges: u64,
67    },
68    /// The group would be rejected, with the reason.
69    Blocked { reason: FreezeBlockReason },
70}
71
72impl FreezeFeasibility {
73    /// Whether `freeze_group` would succeed on this group right now.
74    pub fn is_ready(self) -> bool {
75        matches!(self, FreezeFeasibility::Ready { .. })
76    }
77}
78
79impl CsrShardSet {
80    /// Whether one existing group is frozen.
81    pub fn is_frozen(&self, gid: usize) -> bool {
82        self.shards
83            .get(&gid)
84            .is_some_and(|shard| matches!(shard.variant, CsrVariant::Frozen(_)))
85    }
86
87    /// Whether the group owning `vid` exists and is frozen.
88    pub fn is_group_frozen_for(&self, vid: u32) -> bool {
89        let Some((gid, _)) = self.route(vid) else {
90            return false;
91        };
92        self.is_frozen(gid)
93    }
94
95    /// Freeze one group in place, returning its packed live edge count.
96    ///
97    /// Reclaims first at `cutoff` through the shared group compaction when
98    /// the caller passes an active cutoff, reporting removals through
99    /// `on_edge_removed` for the authority above; a maximum cutoff packs
100    /// verbatim with no removals. Only `Multiple`, `Single`, `Pure` and
101    /// `Bundled` groups freeze; missing groups, `None` groups and
102    /// already-frozen groups are rejected.
103    pub fn freeze_group(
104        &mut self,
105        gid: usize,
106        cutoff: Timestamp,
107        reserve_ratio: f32,
108        on_edge_removed: &mut dyn FnMut(EdgeId, Timestamp),
109    ) -> StorageResult<u64> {
110        if let Err(reason) = self.check_freeze_gate(gid) {
111            return Err(StorageError::invalid_operation(reason.message(gid)));
112        }
113        self.compact_group_with_reporting(gid, cutoff, reserve_ratio, on_edge_removed);
114        let shard = self.shards.get_mut(&gid).ok_or_else(|| {
115            StorageError::invalid_operation(format!("group {} missing on freeze", gid))
116        })?;
117        let frozen = match &shard.variant {
118            CsrVariant::Multiple(csr) => ImmutableCsr::pack_from_mutable(csr),
119            CsrVariant::Single(csr) => ImmutableCsr::pack_single_from(csr),
120            // Pure and bundled groups pack directly into the frozen layout:
121            // no temporary mutable rebuild, no fabricated timestamps.
122            // Bundled values travel slot-parallel in row order, so valued
123            // groups freeze with their properties intact.
124            CsrVariant::Pure(csr) => ImmutableCsr::pack_from_pure(csr),
125            CsrVariant::Bundled(csr) => ImmutableCsr::pack_from_bundled(csr),
126            CsrVariant::Frozen(_) | CsrVariant::None { .. } => {
127                return Err(StorageError::invalid_operation(format!(
128                    "group {} changed under freeze",
129                    gid
130                )));
131            }
132        };
133        let packed_edges = frozen.edge_count();
134        shard.variant = CsrVariant::Frozen(Box::new(frozen));
135        shard.mapped = None;
136        shard.dirty.deleted = true;
137        for region in shard.regions.iter_mut() {
138            region.deleted = true;
139        }
140        // The pack above carries tombstones verbatim, so the cached count
141        // stays exact while the hint clears: frozen groups reject writes and
142        // re-enter reclaim through unfreeze or the flush-time scope walk.
143        shard.reclaim_hint = false;
144        self.clear_group_append_log(gid);
145        Ok(packed_edges)
146    }
147
148    /// Read-only freeze outcome for one group.
149    ///
150    /// Runs the same gate `freeze_group` enforces, without compaction,
151    /// packing or authority traffic. A `Ready` group freezes if nothing
152    /// changes in between; a `Blocked` group reports the exact rejection
153    /// reason, including whether migrating to the columnar form lifts it.
154    pub fn freeze_feasibility(&self, gid: usize) -> FreezeFeasibility {
155        match self.check_freeze_gate(gid) {
156            Ok(()) => {
157                let edges = self
158                    .shards
159                    .get(&gid)
160                    .map(|shard| shard.variant.edge_count())
161                    .unwrap_or(0);
162                FreezeFeasibility::Ready { edges }
163            }
164            Err(reason) => FreezeFeasibility::Blocked { reason },
165        }
166    }
167
168    /// Pure freeze gate shared by the precheck and the pack.
169    fn check_freeze_gate(&self, gid: usize) -> Result<(), FreezeBlockReason> {
170        let Some(shard) = self.shards.get(&gid) else {
171            return Err(FreezeBlockReason::MissingGroup);
172        };
173        // Single gate source: writable variants freeze, read-only views report
174        // already frozen, and the cleared placeholder reports no edges stored.
175        if shard.variant.is_writable() {
176            return Ok(());
177        }
178        if shard.variant.is_read_only() {
179            return Err(FreezeBlockReason::AlreadyFrozen);
180        }
181        Err(FreezeBlockReason::NoEdgesStored)
182    }
183
184    /// Unfreeze one group, rebuilding its mutable variant.
185    ///
186    /// Replays every packed row through the regular insert entry and restores
187    /// deletion stamps through the regular delete entry, so the rebuilt rows
188    /// carry the same logical content in fresh physical order. Gap sentinels
189    /// are not replayed: they are reserved-slot fillers, never edges. Bundled
190    /// groups additionally replay their carried values through the regular
191    /// valued insert entry, restoring the same words and validity bits. The
192    /// rebuilt shape follows the set strategy. Only frozen groups unfreeze;
193    /// anything else is rejected.
194    pub fn unfreeze_group(&mut self, gid: usize) -> StorageResult<u64> {
195        let frozen = match self.shards.get(&gid) {
196            Some(shard) => match &shard.variant {
197                CsrVariant::Frozen(csr) => csr.clone(),
198                _ => {
199                    return Err(StorageError::invalid_operation(format!(
200                        "group {} is not frozen",
201                        gid
202                    )));
203                }
204            },
205            None => {
206                return Err(StorageError::invalid_operation(format!(
207                    "group {} missing, nothing to unfreeze",
208                    gid
209                )));
210            }
211        };
212        let mut variant = self.fresh_variant()?;
213        let rows = frozen.vertex_capacity();
214        let mut restored_tombstones = 0usize;
215        if variant.is_bundled() {
216            // Valued replay: every packed slot restores its word and
217            // validity through the regular valued insert entry, so the
218            // rebuilt bundled group holds the same inline content in fresh
219            // physical order. Deletions replay through the regular delete
220            // entry afterwards, which clears validity but retains the word
221            // exactly like the live write path.
222            let mut valued_buf: Vec<(Nbr, Option<u64>)> = Vec::new();
223            for local in 0..rows {
224                valued_buf.clear();
225                frozen.visit_physical_with_values(local as u32, |nbr, value| {
226                    valued_buf.push((nbr, value));
227                    true
228                });
229                for (nbr, value) in &valued_buf {
230                    if nbr.edge_id == INVALID_EDGE_ID {
231                        continue;
232                    }
233                    variant.insert_edge_with_value(
234                        local as u32,
235                        nbr.to_vertex_id(),
236                        nbr.edge_id,
237                        *value,
238                    )?;
239                    if nbr.delete_ts != Timestamp::MAX {
240                        let deleted =
241                            variant.delete_edge(local as u32, nbr.edge_id, nbr.delete_ts)?;
242                        if !deleted {
243                            return Err(StorageError::invalid_operation(format!(
244                                "unfreeze replay missed edge {:?} in group {}",
245                                nbr.edge_id, gid
246                            )));
247                        }
248                        restored_tombstones += 1;
249                    }
250                }
251            }
252        } else {
253            let mut row_buf = Vec::new();
254            for local in 0..rows {
255                frozen.fill_physical_into(local as u32, &mut row_buf);
256                for nbr in &row_buf {
257                    if nbr.edge_id == INVALID_EDGE_ID {
258                        continue;
259                    }
260                    variant.insert_edge(
261                        local as u32,
262                        nbr.to_vertex_id(),
263                        nbr.edge_id,
264                        Timestamp::MAX,
265                    )?;
266                    if nbr.delete_ts != Timestamp::MAX {
267                        let deleted =
268                            variant.delete_edge(local as u32, nbr.edge_id, nbr.delete_ts)?;
269                        if !deleted {
270                            return Err(StorageError::invalid_operation(format!(
271                                "unfreeze replay missed edge {:?} in group {}",
272                                nbr.edge_id, gid
273                            )));
274                        }
275                        restored_tombstones += 1;
276                    }
277                }
278            }
279        }
280        let restored = frozen.edge_count();
281        let shard = self.shards.get_mut(&gid).ok_or_else(|| {
282            StorageError::invalid_operation(format!("group {} missing on unfreeze", gid))
283        })?;
284        shard.variant = variant;
285        shard.mapped = None;
286        shard.dirty.deleted = true;
287        for region in shard.regions.iter_mut() {
288            region.deleted = true;
289        }
290        // The replay above restores tombstones verbatim through fresh
291        // entries, so the counter is exact with no inherited drift.
292        shard.dead_entries = restored_tombstones;
293        shard.reclaim_hint = restored_tombstones > 0;
294        self.clear_group_append_log(gid);
295        Ok(restored)
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::edge::csr_trait::CsrBase;
303    use linkrs_core::types::{EdgeStrategy, VertexId};
304
305    use super::super::DEFAULT_NODE_GROUP_BITS;
306
307    use super::super::RecordForm;
308
309    fn sample_set() -> CsrShardSet {
310        let mut set = CsrShardSet::new(
311            EdgeStrategy::Multiple,
312            DEFAULT_NODE_GROUP_BITS,
313            64,
314            RecordForm::Columnar,
315        )
316        .unwrap();
317        for src in 0..32u32 {
318            for k in 0..3u32 {
319                let dst = VertexId::edge_endpoint_key(src + k + 1, 0);
320                set.insert_edge(src, dst, EdgeId((src * 10 + k) as u64), 1)
321                    .unwrap();
322            }
323        }
324        set.delete_edge(0, EdgeId(1), 5).unwrap();
325        set
326    }
327
328    #[test]
329    fn freeze_rejects_writes_and_unfreeze_restores() {
330        let mut set = sample_set();
331        let before: Vec<_> = (0..32u32).map(|src| set.physical_edges_of(src)).collect();
332        let packed = set
333            .freeze_group(0, Timestamp::MAX, 0.0, &mut |_, _| {})
334            .unwrap();
335        assert_eq!(packed, set.edge_count());
336        assert!(set.is_frozen(0));
337        assert!(set.is_group_frozen_for(3));
338        assert!(!set.is_group_frozen_for(1 << DEFAULT_NODE_GROUP_BITS));
339
340        for src in 0..32u32 {
341            assert_eq!(set.physical_edges_of(src), before[src as usize]);
342            let expected: Vec<_> = before[src as usize]
343                .iter()
344                .filter(|nbr| nbr.is_alive_at(6))
345                .copied()
346                .collect();
347            assert_eq!(set.edges_of(src, 6), expected);
348        }
349        assert!(set
350            .insert_edge(1, VertexId::edge_endpoint_key(99, 0), EdgeId(1000), 9)
351            .is_err());
352        assert!(set.delete_edge(1, EdgeId(10), 9).is_err());
353        assert_eq!(
354            set.delete_edge_by_dst(1, VertexId::edge_endpoint_key(11, 0), 9),
355            0
356        );
357
358        let restored = set.unfreeze_group(0).unwrap();
359        assert_eq!(restored, packed);
360        assert!(!set.is_frozen(0));
361        for src in 0..32u32 {
362            assert_eq!(set.physical_edges_of(src), before[src as usize]);
363        }
364        set.insert_edge(1, VertexId::edge_endpoint_key(99, 0), EdgeId(1000), 9)
365            .unwrap();
366        assert_eq!(set.edge_count(), packed + 1);
367    }
368
369    #[test]
370    fn freeze_guards_bad_states() {
371        let mut set = sample_set();
372        assert!(set
373            .freeze_group(77, Timestamp::MAX, 0.0, &mut |_, _| {})
374            .is_err());
375        set.freeze_group(0, Timestamp::MAX, 0.0, &mut |_, _| {})
376            .unwrap();
377        assert!(set
378            .freeze_group(0, Timestamp::MAX, 0.0, &mut |_, _| {})
379            .is_err());
380        assert!(set.unfreeze_group(77).is_err());
381
382        let mut none_set = CsrShardSet::new(
383            EdgeStrategy::None,
384            DEFAULT_NODE_GROUP_BITS,
385            64,
386            RecordForm::Columnar,
387        )
388        .unwrap();
389        assert!(none_set
390            .freeze_group(0, Timestamp::MAX, 0.0, &mut |_, _| {})
391            .is_err());
392
393        let mut single = CsrShardSet::new(
394            EdgeStrategy::Single,
395            DEFAULT_NODE_GROUP_BITS,
396            64,
397            RecordForm::Columnar,
398        )
399        .unwrap();
400        single
401            .insert_edge(5, VertexId::edge_endpoint_key(6, 0), EdgeId(1), 1)
402            .unwrap();
403        let packed = single
404            .freeze_group(0, Timestamp::MAX, 0.0, &mut |_, _| {})
405            .unwrap();
406        assert_eq!(packed, 1);
407        assert_eq!(
408            single.edges_of(5, 1),
409            vec![single.physical_edges_of(5).pop().unwrap()]
410        );
411        single.unfreeze_group(0).unwrap();
412        assert!(!single.is_frozen(0));
413        assert_eq!(single.edge_count(), 1);
414    }
415
416    #[test]
417    fn feasibility_matches_freeze_outcome() {
418        let mut set = sample_set();
419        let quoted = set.freeze_feasibility(0);
420        assert!(quoted.is_ready());
421        let packed = set
422            .freeze_group(0, Timestamp::MAX, 0.0, &mut |_, _| {})
423            .unwrap();
424        assert_eq!(quoted, FreezeFeasibility::Ready { edges: packed });
425
426        let blocked = set.freeze_feasibility(0);
427        assert_eq!(
428            blocked,
429            FreezeFeasibility::Blocked {
430                reason: FreezeBlockReason::AlreadyFrozen
431            }
432        );
433        assert!(!blocked.is_ready());
434        let err = set
435            .freeze_group(0, Timestamp::MAX, 0.0, &mut |_, _| {})
436            .unwrap_err();
437        assert!(err
438            .to_string()
439            .contains(&FreezeBlockReason::AlreadyFrozen.message(0)));
440
441        assert_eq!(
442            set.freeze_feasibility(77),
443            FreezeFeasibility::Blocked {
444                reason: FreezeBlockReason::MissingGroup
445            }
446        );
447        assert!(set
448            .freeze_group(77, Timestamp::MAX, 0.0, &mut |_, _| {})
449            .is_err());
450    }
451
452    #[test]
453    fn valued_bundled_groups_freeze_with_values_intact() {
454        let mut valued = CsrShardSet::new(
455            EdgeStrategy::Multiple,
456            DEFAULT_NODE_GROUP_BITS,
457            64,
458            RecordForm::Bundled,
459        )
460        .unwrap();
461        valued
462            .bundled_insert_with_value(0, VertexId::edge_endpoint_key(1, 0), EdgeId(0), 1, Some(7))
463            .unwrap();
464        valued
465            .bundled_insert_with_value(0, VertexId::edge_endpoint_key(2, 0), EdgeId(1), 1, None)
466            .unwrap();
467        assert!(valued.freeze_feasibility(0).is_ready());
468        valued
469            .freeze_group(0, Timestamp::MAX, 0.0, &mut |_, _| {})
470            .unwrap();
471        assert!(valued.is_frozen(0));
472        // Frozen reads serve the carried values in packed row order.
473        let frozen = valued.group_variant(0).expect("frozen group present");
474        assert_eq!(
475            frozen.bundled_value_by_edge_id(0, EdgeId(0)),
476            Some((7, true))
477        );
478        assert_eq!(
479            frozen.bundled_value_by_edge_id(0, EdgeId(1)),
480            Some((0, false))
481        );
482        let mut seen = Vec::new();
483        valued.visit_physical_with_values(0, |nbr, value| {
484            seen.push((nbr.edge_id, value));
485            true
486        });
487        assert_eq!(seen, vec![(EdgeId(0), Some(7)), (EdgeId(1), None)]);
488        // Unfreezing replays values back into a live bundled group.
489        valued.unfreeze_group(0).unwrap();
490        assert!(!valued.is_frozen(0));
491        let live = valued.group_variant(0).expect("live group present");
492        assert_eq!(live.bundled_value_by_edge_id(0, EdgeId(0)), Some((7, true)));
493        assert_eq!(
494            live.bundled_value_by_edge_id(0, EdgeId(1)),
495            Some((0, false))
496        );
497
498        // All-NULL bundled groups freeze exactly like before.
499        let mut nulls = CsrShardSet::new(
500            EdgeStrategy::Multiple,
501            DEFAULT_NODE_GROUP_BITS,
502            64,
503            RecordForm::Bundled,
504        )
505        .unwrap();
506        nulls
507            .bundled_insert_with_value(0, VertexId::edge_endpoint_key(1, 0), EdgeId(0), 1, None)
508            .unwrap();
509        assert!(nulls.freeze_feasibility(0).is_ready());
510        nulls
511            .freeze_group(0, Timestamp::MAX, 0.0, &mut |_, _| {})
512            .unwrap();
513    }
514}