Skip to main content

linkrs_storage/edge/mutable_csr/
persistence.rs

1use super::super::{ColdStamps, EdgeId, HotNbr, INVALID_EDGE_ID};
2use super::live_set::LiveKeySet;
3use super::overflow::OverflowStorage;
4use super::serialization::{
5    decode_overflow_chunk, decode_topology_i64_column, decode_topology_u32_column,
6    decode_topology_u64_column, encode_overflow_chunk, encode_topology_i64_column,
7    encode_topology_u32_column, encode_topology_u64_column, TopologyColumnEncoding,
8    MUTABLE_CSR_FORMAT_VERSION,
9};
10use super::write::EdgePosition;
11use super::MutableCsr;
12use crate::persistence::{read_u32_le, read_u64_le};
13use linkrs_core::{StorageError, StorageResult};
14
15/// Reusable neighbor-column buffers for checkpoint dumps.
16///
17/// One scratch serves a whole checkpoint: each group clears and refills the
18/// buffers instead of allocating four temporary columns, so repeated dumps
19/// keep peak allocation to one column set.
20#[derive(Debug, Default)]
21pub struct CsrDumpScratch {
22    endpoints: Vec<u32>,
23    ranks: Vec<i64>,
24    edge_ids: Vec<u64>,
25    deletes: Vec<u64>,
26}
27
28impl CsrDumpScratch {
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    pub(crate) fn endpoints(&self) -> &[u32] {
34        &self.endpoints
35    }
36
37    pub(crate) fn ranks(&self) -> &[i64] {
38        &self.ranks
39    }
40
41    pub(crate) fn edge_ids(&self) -> &[u64] {
42        &self.edge_ids
43    }
44
45    pub(crate) fn deletes(&self) -> &[u64] {
46        &self.deletes
47    }
48
49    pub(crate) fn fill_from_split(&mut self, hot: &[HotNbr], cold: &[ColdStamps]) {
50        debug_assert_eq!(hot.len(), cold.len());
51        self.endpoints.clear();
52        self.ranks.clear();
53        self.edge_ids.clear();
54        self.deletes.clear();
55        self.endpoints.reserve(hot.len());
56        self.ranks.reserve(hot.len());
57        self.edge_ids.reserve(hot.len());
58        self.deletes.reserve(hot.len());
59        for slot in hot {
60            self.endpoints.push(slot.endpoint);
61            self.ranks.push(slot.rank);
62            self.edge_ids.push(slot.edge_id.0);
63        }
64        for stamp in cold {
65            self.deletes.push(stamp.delete_ts);
66        }
67    }
68}
69
70impl MutableCsr {
71    /// Dump to bytes, current encoded version.
72    ///
73    /// Header columns (offsets, degrees, capacities) and primary neighbor
74    /// columns (endpoints, ranks, edge ids, stamps) persist through the
75    /// integer column path with per-column bit-packing or run-length
76    /// encoding and a narrow plain fallback. Overflow chunks use the same
77    /// column path per chunk instead of plain neighbor records.
78    ///
79    /// Format:
80    /// - format marker (u32 = encoded or raw mode, see serialization)
81    /// - vertex_capacity (u64)
82    /// - edge_count (u64)
83    /// - primary_len (u64)
84    /// - overflow_chunk_edges (u64)
85    /// - tombstone_reuse_cutoff (u64, MAX disables reuse)
86    /// - encoded offsets column
87    /// - encoded degrees column
88    /// - encoded capacities column
89    /// - encoded endpoints column
90    /// - encoded ranks column
91    /// - encoded edge ids column
92    /// - encoded create stamps column
93    /// - encoded delete stamps column
94    /// - per-vertex overflow chunks (column-encoded per chunk)
95    /// - payload CRC32 (u32, little-endian, over all preceding bytes)
96    pub fn dump(&self) -> Vec<u8> {
97        let mut result = Vec::new();
98        self.dump_into(&mut result);
99        result
100    }
101
102    /// Borrow-based dump into `out` without an intermediate owned buffer.
103    /// Byte-identical to `dump`; checkpoint writes use this so no whole-group
104    /// clone or temporary dump buffer is needed.
105    pub fn dump_into(&self, out: &mut Vec<u8>) {
106        let mut scratch = CsrDumpScratch::new();
107        self.dump_into_with_scratch(out, &mut scratch);
108    }
109
110    /// Dump reusing caller-owned column buffers.
111    ///
112    /// Same bytes as [`Self::dump_into`] but the four neighbor-column
113    /// buffers are cleared and refilled instead of reallocated, so a
114    /// checkpoint over many groups pays one allocation per column instead
115    /// of one per group. The scratch holds no state between calls.
116    pub fn dump_into_with_scratch(&self, out: &mut Vec<u8>, scratch: &mut CsrDumpScratch) {
117        let start = out.len();
118        out.extend_from_slice(&MUTABLE_CSR_FORMAT_VERSION.to_le_bytes());
119        out.extend_from_slice(&(self.rows.adj_offsets.len() as u64).to_le_bytes());
120        out.extend_from_slice(&self.edge_count.to_le_bytes());
121        out.extend_from_slice(&(self.hot_list.len() as u64).to_le_bytes());
122        out.extend_from_slice(&(self.overflow_chunk_edges as u64).to_le_bytes());
123        out.extend_from_slice(&self.tombstone_reuse_cutoff.to_le_bytes());
124
125        let (_, offsets_payload) = encode_topology_u32_column(&self.rows.adj_offsets);
126        out.extend_from_slice(&offsets_payload);
127        let (_, degrees_payload) = encode_topology_u32_column(&self.rows.degrees);
128        out.extend_from_slice(&degrees_payload);
129        let (_, caps_payload) = encode_topology_u32_column(&self.rows.primary_capacities);
130        out.extend_from_slice(&caps_payload);
131
132        scratch.fill_from_split(&self.hot_list, &self.cold_list);
133        let (_, endpoints_payload) = encode_topology_u32_column(&scratch.endpoints);
134        out.extend_from_slice(&endpoints_payload);
135        let (_, ranks_payload) = encode_topology_i64_column(&scratch.ranks);
136        out.extend_from_slice(&ranks_payload);
137        let (_, edge_ids_payload) = encode_topology_u64_column(&scratch.edge_ids);
138        out.extend_from_slice(&edge_ids_payload);
139        let (_, delete_payload) = encode_topology_u64_column(&scratch.deletes);
140        out.extend_from_slice(&delete_payload);
141
142        for vid in 0..self.rows.adj_offsets.len() {
143            let chunks = self.overflow_chunks.get(vid as u32);
144            out.extend_from_slice(&(chunks.map_or(0, Vec::len) as u32).to_le_bytes());
145            if let Some(chunks) = chunks {
146                for chunk in chunks {
147                    encode_overflow_chunk(chunk, out);
148                }
149            }
150        }
151        let crc = crc32fast::hash(&out[start..]);
152        out.extend_from_slice(&crc.to_le_bytes());
153    }
154
155    /// Encoding report for the persisted topology columns.
156    ///
157    /// Measures the winning encoding per column without changing in-memory
158    /// state. Neighbor and edge-id columns are reported first, offset and
159    /// length columns follow; all use the integer column path only.
160    pub fn topology_encoding_report(&self) -> Vec<(String, TopologyColumnEncoding, usize, usize)> {
161        let endpoints: Vec<u32> = self.hot_list.iter().map(|hot| hot.endpoint).collect();
162        let edge_ids: Vec<u64> = self.hot_list.iter().map(|hot| hot.edge_id.0).collect();
163        let (endpoint_choice, _) = encode_topology_u32_column(&endpoints);
164        let (edge_id_choice, _) = encode_topology_u64_column(&edge_ids);
165        let (offsets_choice, _) = encode_topology_u32_column(&self.rows.adj_offsets);
166        let (degrees_choice, _) = encode_topology_u32_column(&self.rows.degrees);
167        let (caps_choice, _) = encode_topology_u32_column(&self.rows.primary_capacities);
168        vec![
169            (
170                "neighbor".to_string(),
171                endpoint_choice.encoding,
172                endpoint_choice.plain_bytes,
173                endpoint_choice.encoded_bytes,
174            ),
175            (
176                "edge_id".to_string(),
177                edge_id_choice.encoding,
178                edge_id_choice.plain_bytes,
179                edge_id_choice.encoded_bytes,
180            ),
181            (
182                "offsets".to_string(),
183                offsets_choice.encoding,
184                offsets_choice.plain_bytes,
185                offsets_choice.encoded_bytes,
186            ),
187            (
188                "lengths".to_string(),
189                degrees_choice.encoding,
190                degrees_choice.plain_bytes,
191                degrees_choice.encoded_bytes,
192            ),
193            (
194                "capacities".to_string(),
195                caps_choice.encoding,
196                caps_choice.plain_bytes,
197                caps_choice.encoded_bytes,
198            ),
199        ]
200    }
201
202    /// Load from bytes. Only the single format marker is accepted; any
203    /// other marker is corrupt. Strict validation below is shared by every
204    /// payload that passes the marker gate.
205    /// The trailing CRC32 is verified before any parsing so a truncated or
206    /// bit-rotted checkpoint fails fast instead of decoding garbage.
207    pub fn load(&mut self, data: &[u8]) -> StorageResult<()> {
208        if data.len() < 40 {
209            return Err(StorageError::deserialize_error(
210                "CSR data too short for header",
211            ));
212        }
213        let (body, trailer) = data.split_at(data.len() - 4);
214        let mut stored_bytes = [0u8; 4];
215        stored_bytes.copy_from_slice(trailer);
216        let stored = u32::from_le_bytes(stored_bytes);
217        let computed = crc32fast::hash(body);
218        if stored != computed {
219            return Err(StorageError::deserialize_error(format!(
220                "CSR dump CRC mismatch: stored={:#x} computed={:#x}",
221                stored, computed
222            )));
223        }
224        let data = body;
225
226        self.reset_baseline_counters();
227
228        let mut offset = 0usize;
229
230        let format_version = read_u32_le(data, &mut offset)?;
231        if format_version != MUTABLE_CSR_FORMAT_VERSION {
232            return Err(StorageError::deserialize_error(format!(
233                "Unsupported mutable CSR format version: {format_version}"
234            )));
235        }
236        let vertex_capacity = read_u64_le(data, &mut offset)? as usize;
237        let edge_count = read_u64_le(data, &mut offset)?;
238        let primary_len = read_u64_le(data, &mut offset)? as usize;
239        let overflow_chunk_edges = read_u64_le(data, &mut offset)? as usize;
240        if overflow_chunk_edges == 0 {
241            return Err(StorageError::deserialize_error(
242                "Mutable CSR overflow chunk size must be greater than zero",
243            ));
244        }
245        let tombstone_reuse_cutoff = read_u64_le(data, &mut offset)?;
246
247        let (adj_offsets, degrees, primary_capacities) = (
248            decode_topology_u32_column(data, &mut offset)?,
249            decode_topology_u32_column(data, &mut offset)?,
250            decode_topology_u32_column(data, &mut offset)?,
251        );
252        if adj_offsets.len() != vertex_capacity
253            || degrees.len() != vertex_capacity
254            || primary_capacities.len() != vertex_capacity
255        {
256            return Err(StorageError::deserialize_error(
257                "Mutable CSR header column length mismatch",
258            ));
259        }
260        let (endpoints, ranks, edge_ids, delete_stamps) = (
261            decode_topology_u32_column(data, &mut offset)?,
262            decode_topology_i64_column(data, &mut offset)?,
263            decode_topology_u64_column(data, &mut offset)?,
264            decode_topology_u64_column(data, &mut offset)?,
265        );
266        if endpoints.len() != primary_len
267            || ranks.len() != primary_len
268            || edge_ids.len() != primary_len
269            || delete_stamps.len() != primary_len
270        {
271            return Err(StorageError::deserialize_error(
272                "Mutable CSR neighbor column length mismatch",
273            ));
274        }
275        let mut hot_list = Vec::with_capacity(primary_len);
276        let mut cold_list = Vec::with_capacity(primary_len);
277        for index in 0..primary_len {
278            hot_list.push(HotNbr {
279                endpoint: endpoints[index],
280                rank: ranks[index],
281                edge_id: EdgeId(edge_ids[index]),
282            });
283            cold_list.push(ColdStamps {
284                delete_ts: delete_stamps[index],
285            });
286        }
287
288        // Addressing consistency: every row window must land inside the
289        // neighbor list and the degree must fit the reserved capacity. A
290        // length-aligned but out-of-range payload is damage, rejected here
291        // instead of panicking on the hot path later.
292        for vid in 0..vertex_capacity {
293            let offset = adj_offsets[vid] as usize;
294            let degree = degrees[vid] as usize;
295            let capacity = primary_capacities[vid] as usize;
296            if degree > capacity
297                || offset.saturating_add(degree) > primary_len
298                || offset.saturating_add(capacity) > primary_len
299            {
300                return Err(StorageError::deserialize_error(format!(
301                    "Mutable CSR row {} out of range: offset={} degree={} capacity={} primary_len={}",
302                    vid, offset, degree, capacity, primary_len
303                )));
304            }
305        }
306
307        let mut overflow_chunks = OverflowStorage::new();
308        let mut overflow_capacity = 0usize;
309        let mut live_keys: Vec<Vec<((u32, i64), EdgePosition)>> = vec![Vec::new(); vertex_capacity];
310        let mut recomputed: u64 = 0;
311        for (hot, cold) in hot_list.iter().zip(cold_list.iter()) {
312            if hot.edge_id != INVALID_EDGE_ID && cold.is_live() {
313                recomputed += 1;
314            }
315        }
316        for (vid, keys) in live_keys.iter_mut().enumerate() {
317            let row_offset = adj_offsets[vid] as usize;
318            let degree = degrees[vid] as usize;
319            for i in 0..degree {
320                match (hot_list.get(row_offset + i), cold_list.get(row_offset + i)) {
321                    (Some(hot), Some(cold)) if hot.edge_id != INVALID_EDGE_ID && cold.is_live() => {
322                        keys.push((
323                            (hot.endpoint, hot.rank),
324                            EdgePosition::Primary { slot: i as u32 },
325                        ));
326                    }
327                    _ => {}
328                }
329            }
330        }
331        for (vid, row_keys) in live_keys.iter_mut().enumerate() {
332            let chunk_count = read_u32_le(data, &mut offset)? as usize;
333            let mut chunks = Vec::with_capacity(chunk_count);
334            for chunk_idx in 0..chunk_count {
335                let chunk = decode_overflow_chunk(data, &mut offset)?;
336                if chunk.len() > overflow_chunk_edges {
337                    return Err(StorageError::deserialize_error(
338                        "Mutable CSR overflow chunk exceeds configured chunk size",
339                    ));
340                }
341                for (slot_idx, (hot, cold)) in
342                    chunk.hot_slice().iter().zip(chunk.cold_slice()).enumerate()
343                {
344                    if hot.edge_id != INVALID_EDGE_ID && cold.is_live() {
345                        recomputed += 1;
346                        row_keys.push((
347                            (hot.endpoint, hot.rank),
348                            EdgePosition::Overflow {
349                                chunk: chunk_idx as u32,
350                                slot: slot_idx as u32,
351                            },
352                        ));
353                    }
354                }
355                overflow_capacity = overflow_capacity.saturating_add(chunk.capacity().max(1));
356                chunks.push(chunk);
357            }
358            if !chunks.is_empty() {
359                overflow_chunks.insert(vid as u32, chunks);
360            }
361        }
362
363        if recomputed != edge_count {
364            return Err(StorageError::deserialize_error(format!(
365                "Mutable CSR edge count mismatch: stored={}, recomputed={}",
366                edge_count, recomputed
367            )));
368        }
369
370        self.total_edge_capacity = hot_list.len().saturating_add(overflow_capacity);
371        self.rows.adj_offsets = adj_offsets;
372        self.rows.degrees = degrees;
373        self.rows.primary_capacities = primary_capacities;
374        self.overflow_chunks = overflow_chunks;
375        self.overflow_chunk_edges = overflow_chunk_edges;
376        self.hot_list = hot_list;
377        self.cold_list = cold_list;
378        self.edge_count = edge_count;
379        // The reuse hint is a watermark-derived cache, not authority:
380        // persisting it only avoids the cold-start overflow cliff. Stale
381        // values stay safe because refreshes only narrow through the
382        // cutoff setter.
383        self.tombstone_reuse_cutoff = tombstone_reuse_cutoff;
384        self.reuse_hint = vec![super::core::REUSE_HINT_UNKNOWN; vertex_capacity];
385        self.live_counts = vec![0; vertex_capacity];
386        self.tombstone_counts = vec![0; vertex_capacity];
387        // Row order is memory-only and never persists: every load starts
388        // unsorted so later sorts re-observe the order. Plan caches must not
389        // reuse this flag across restarts.
390        self.primary_sorted = vec![false; vertex_capacity];
391        self.live_sets.clear();
392        self.live_sets.ensure_capacity(vertex_capacity);
393        for (vid, keys) in live_keys.into_iter().enumerate() {
394            // Narrow rows stay set-free; only wide rows pay for the index.
395            if keys.len() > super::live_set::LIVE_SET_WIDTH_BOUND {
396                self.live_sets
397                    .insert(vid as u32, LiveKeySet::from_positions(keys));
398            }
399        }
400        // Rebuild incremental live/tombstone counts from loaded data.
401        for vid in 0..vertex_capacity {
402            let degree = self.rows.degrees[vid] as usize;
403            let offset = self.rows.adj_offsets[vid] as usize;
404            let mut live = 0u32;
405            let mut tombstones = 0u32;
406            for i in 0..degree {
407                if let Some(cold) = self.cold_list.get(offset + i) {
408                    if cold.is_live() {
409                        live += 1;
410                    } else {
411                        tombstones += 1;
412                    }
413                }
414            }
415            if let Some(chunks) = self.overflow_chunks.get(vid as u32) {
416                for chunk in chunks {
417                    for cold in chunk.cold_slice() {
418                        if cold.is_live() {
419                            live += 1;
420                        } else {
421                            tombstones += 1;
422                        }
423                    }
424                }
425            }
426            self.live_counts[vid] = live;
427            self.tombstone_counts[vid] = tombstones;
428        }
429        if offset != data.len() {
430            return Err(StorageError::deserialize_error(
431                "unexpected trailing data in mutable CSR payload",
432            ));
433        }
434
435        Ok(())
436    }
437}