Skip to main content

linkrs_storage/edge/node_group/
append_log.rs

1//! Per-group write-through append log and its sidecar codec.
2//!
3//! Each group holds the committed delta (inserts plus tombstone markers)
4//! since its last base rewrite. Insert-only groups checkpoint by persisting
5//! the append log alone; the first delete dirt forces a base rewrite that
6//! discards the log. Append row indexes are memory only: dropped after the
7//! flush that persists them and rebuilt from later writes.
8
9use linkrs_core::types::{EdgeId, Timestamp, VertexId};
10use linkrs_core::{StorageError, StorageResult};
11
12use super::super::{MutableCsrTrait, Nbr};
13use super::{CsrShardSet, TableShardManifest};
14
15/// One committed append-log insert: the row plus the stored neighbor.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub(crate) struct AppendInsert {
18    pub local: u32,
19    pub nbr: Nbr,
20}
21
22/// One committed append-log delete marker: the row plus the tombstone stamp.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub(crate) struct AppendDelete {
25    pub local: u32,
26    pub edge_id: EdgeId,
27    pub delete_ts: Timestamp,
28}
29
30/// Write-through delta of one group since its last base rewrite.
31///
32/// Every committed topology write lands in the base variant for reads and is
33/// also recorded here. Insert-only groups checkpoint by persisting this log
34/// alone; the first delete dirt in the group forces a base rewrite that
35/// discards the log. The log is a row index only: it is dropped after the
36/// flush that persists it and rebuilt from later writes.
37#[derive(Debug, Clone, Default)]
38pub(crate) struct ShardAppendLog {
39    pub inserts: Vec<AppendInsert>,
40    pub deletes: Vec<AppendDelete>,
41}
42
43impl ShardAppendLog {
44    pub fn is_empty(&self) -> bool {
45        self.inserts.is_empty() && self.deletes.is_empty()
46    }
47
48    pub fn clear(&mut self) {
49        self.inserts.clear();
50        self.deletes.clear();
51    }
52
53    pub fn op_count(&self) -> usize {
54        self.inserts.len() + self.deletes.len()
55    }
56}
57
58/// Encode one append-op sequence for a sidecar. Carries only the address
59/// width so group-set growth never invalidates clean groups' sidecars; a
60/// width mismatch still fails closed on load.
61pub(crate) fn encode_append_ops(
62    manifest: &TableShardManifest,
63    inserts: &[AppendInsert],
64    deletes: &[AppendDelete],
65) -> Vec<u8> {
66    let mut out = Vec::new();
67    out.extend_from_slice(&manifest.group_bits.to_le_bytes());
68    out.extend_from_slice(&(inserts.len() as u64).to_le_bytes());
69    for insert in inserts {
70        out.extend_from_slice(&insert.local.to_le_bytes());
71        out.extend_from_slice(&insert.nbr.endpoint.to_le_bytes());
72        out.extend_from_slice(&insert.nbr.rank.to_le_bytes());
73        out.extend_from_slice(&insert.nbr.edge_id.0.to_le_bytes());
74        out.extend_from_slice(&insert.nbr.delete_ts.to_le_bytes());
75    }
76    out.extend_from_slice(&(deletes.len() as u64).to_le_bytes());
77    for delete in deletes {
78        out.extend_from_slice(&delete.local.to_le_bytes());
79        out.extend_from_slice(&delete.edge_id.0.to_le_bytes());
80        out.extend_from_slice(&delete.delete_ts.to_le_bytes());
81    }
82    out
83}
84
85/// Decode one append-op sequence. Fails closed on address-width,
86/// section-size or trailing-byte mismatches.
87pub(crate) fn decode_append_ops(
88    data: &[u8],
89    manifest: &TableShardManifest,
90) -> StorageResult<(Vec<AppendInsert>, Vec<AppendDelete>)> {
91    let mut cursor = 0usize;
92    let take = |data: &[u8], cursor: &mut usize, len: usize| -> StorageResult<Vec<u8>> {
93        if data.len() - *cursor < len {
94            return Err(StorageError::deserialize_error(
95                "append log payload too short",
96            ));
97        }
98        let slice = data[*cursor..*cursor + len].to_vec();
99        *cursor += len;
100        Ok(slice)
101    };
102    let carried_bits = u32::from_le_bytes(
103        take(data, &mut cursor, 4)?
104            .try_into()
105            .map_err(|_| StorageError::deserialize_error("append log width too short"))?,
106    );
107    if carried_bits != manifest.group_bits {
108        return Err(StorageError::deserialize_error(format!(
109            "append log width mismatch: log carries {}, table holds {}",
110            carried_bits, manifest.group_bits
111        )));
112    }
113    let insert_count = u64::from_le_bytes(
114        take(data, &mut cursor, 8)?
115            .try_into()
116            .map_err(|_| StorageError::deserialize_error("append log insert count too short"))?,
117    ) as usize;
118    let mut inserts = Vec::with_capacity(insert_count);
119    for _ in 0..insert_count {
120        let local = u32::from_le_bytes(
121            take(data, &mut cursor, 4)?
122                .try_into()
123                .map_err(|_| StorageError::deserialize_error("append log insert row too short"))?,
124        );
125        let endpoint = u32::from_le_bytes(
126            take(data, &mut cursor, 4)?
127                .try_into()
128                .map_err(|_| StorageError::deserialize_error("append log endpoint too short"))?,
129        );
130        let rank = u64::from_le_bytes(
131            take(data, &mut cursor, 8)?
132                .try_into()
133                .map_err(|_| StorageError::deserialize_error("append log rank too short"))?,
134        ) as i64;
135        let edge_id = EdgeId(u64::from_le_bytes(
136            take(data, &mut cursor, 8)?
137                .try_into()
138                .map_err(|_| StorageError::deserialize_error("append log edge id too short"))?,
139        ));
140        let delete_ts =
141            u64::from_le_bytes(take(data, &mut cursor, 8)?.try_into().map_err(|_| {
142                StorageError::deserialize_error("append log delete stamp too short")
143            })?);
144        let nbr = Nbr::with_timestamps(endpoint, rank, edge_id, delete_ts);
145        inserts.push(AppendInsert { local, nbr });
146    }
147    let delete_count = u64::from_le_bytes(
148        take(data, &mut cursor, 8)?
149            .try_into()
150            .map_err(|_| StorageError::deserialize_error("append log delete count too short"))?,
151    ) as usize;
152    let mut deletes = Vec::with_capacity(delete_count);
153    for _ in 0..delete_count {
154        let local = u32::from_le_bytes(
155            take(data, &mut cursor, 4)?
156                .try_into()
157                .map_err(|_| StorageError::deserialize_error("append log delete row too short"))?,
158        );
159        let edge_id = EdgeId(u64::from_le_bytes(
160            take(data, &mut cursor, 8)?.try_into().map_err(|_| {
161                StorageError::deserialize_error("append log delete edge id too short")
162            })?,
163        ));
164        let delete_ts =
165            u64::from_le_bytes(take(data, &mut cursor, 8)?.try_into().map_err(|_| {
166                StorageError::deserialize_error("append log delete stamp too short")
167            })?);
168        deletes.push(AppendDelete {
169            local,
170            edge_id,
171            delete_ts,
172        });
173    }
174    if cursor != data.len() {
175        return Err(StorageError::deserialize_error(
176            "unexpected trailing data in append log".to_string(),
177        ));
178    }
179    Ok((inserts, deletes))
180}
181
182impl CsrShardSet {
183    /// Record a committed insert in the group append log.
184    pub(crate) fn record_append_insert(&mut self, gid: usize, local: u32, nbr: Nbr) {
185        if let Some(shard) = self.shards.get_mut(&gid) {
186            shard.append.inserts.push(AppendInsert { local, nbr });
187        }
188    }
189
190    /// Record a committed tombstone in the group append log.
191    pub(crate) fn record_append_delete(
192        &mut self,
193        gid: usize,
194        local: u32,
195        edge_id: EdgeId,
196        delete_ts: Timestamp,
197    ) {
198        if let Some(shard) = self.shards.get_mut(&gid) {
199            shard.append.deletes.push(AppendDelete {
200                local,
201                edge_id,
202                delete_ts,
203            });
204        }
205    }
206
207    /// Whether a group holds append-log deltas not yet merged into a base.
208    pub fn group_has_append_log(&self, gid: usize) -> bool {
209        self.shards
210            .get(&gid)
211            .is_some_and(|shard| !shard.append.is_empty())
212    }
213
214    /// Committed op count held in one group append log.
215    pub fn group_append_op_count(&self, gid: usize) -> usize {
216        self.shards
217            .get(&gid)
218            .map_or(0, |shard| shard.append.op_count())
219    }
220
221    /// Committed ops held in one group append log, oldest first.
222    pub(crate) fn group_append_ops(&self, gid: usize) -> (Vec<AppendInsert>, Vec<AppendDelete>) {
223        self.shards
224            .get(&gid)
225            .map(|shard| (shard.append.inserts.clone(), shard.append.deletes.clone()))
226            .unwrap_or_default()
227    }
228
229    /// Drop one group append log after its states merged into a base.
230    /// The row index is memory only; base files plus later logs rebuild it.
231    pub fn clear_group_append_log(&mut self, gid: usize) {
232        if let Some(shard) = self.shards.get_mut(&gid) {
233            shard.append.clear();
234        }
235    }
236
237    /// Drop every group append log, e.g. after a topology-wide rebuild
238    /// whose base rewrite already carries all states.
239    pub fn clear_all_append_logs(&mut self) {
240        for shard in self.shards.values_mut() {
241            shard.append.clear();
242        }
243    }
244
245    /// Encode one group append log for an append-only checkpoint. Carries
246    /// only the address width so group-set growth never invalidates clean
247    /// groups' sidecars; a width mismatch is rejected on load instead of
248    /// replayed against the wrong base. Borrows the in-memory log directly
249    /// without cloning it into temporaries.
250    pub fn encode_group_append_log(&self, gid: usize, manifest: &TableShardManifest) -> Vec<u8> {
251        match self.shards.get(&gid) {
252            Some(shard) => {
253                encode_append_ops(manifest, &shard.append.inserts, &shard.append.deletes)
254            }
255            None => encode_append_ops(manifest, &[], &[]),
256        }
257    }
258
259    /// Replay one append-log payload into a group base. Fails closed on
260    /// version, address-width, section-size or trailing-byte mismatches.
261    pub fn replay_group_append_log(
262        &mut self,
263        gid: usize,
264        data: &[u8],
265        manifest: &TableShardManifest,
266    ) -> StorageResult<()> {
267        let (inserts, deletes) = decode_append_ops(data, manifest)?;
268        let shard = self.shards.get_mut(&gid).ok_or_else(|| {
269            StorageError::deserialize_error(format!("group {} out of range on append replay", gid))
270        })?;
271        // Frozen groups carry no deltas by construction: freeze clears the
272        // in-memory log and the base rewrite drops the sidecar. A sidecar for
273        // a frozen base is damage, rejected here instead of replayed.
274        if matches!(shard.variant, super::super::CsrVariant::Frozen(_)) {
275            return Err(StorageError::deserialize_error(format!(
276                "append sidecar for frozen group {}",
277                gid
278            )));
279        }
280        for insert in inserts {
281            shard
282                .variant
283                .insert_edge(
284                    insert.local,
285                    VertexId::edge_endpoint_key(insert.nbr.endpoint, insert.nbr.rank),
286                    insert.nbr.edge_id,
287                    Timestamp::MAX,
288                )
289                .map_err(|e| {
290                    StorageError::deserialize_error(format!(
291                        "append log insert replay failed in group {}: {}",
292                        gid, e
293                    ))
294                })?;
295        }
296        for delete in deletes {
297            shard
298                .variant
299                .delete_edge(delete.local, delete.edge_id, delete.delete_ts)
300                .map_err(|e| {
301                    StorageError::deserialize_error(format!(
302                        "append log delete replay failed in group {}: {}",
303                        gid, e
304                    ))
305                })?;
306        }
307        Ok(())
308    }
309}