Skip to main content

linkrs_storage/edge/node_group/
dirt.rs

1//! Write-path dirt model and the set-level dirt surface.
2//!
3//! Leaf regions carry the three-kind dirt; group dirt is the OR over its
4//! regions. Checkpoints clear dirt at group or region granularity so clean
5//! scopes are never rewritten. `column_updated` traces property-only writes
6//! for precise property flushing without forcing topology work.
7
8use super::{group_id_for, local_vid, region_id_for_local, CsrShardSet};
9
10/// Write-path dirt of one leaf region. Cleared for regions a checkpoint
11/// persisted. Topology checkpoints rewrite or append-persist a region exactly
12/// when `inserted` or `deleted` is set; `column_updated` traces
13/// property-only writes for observability without forcing topology work.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
15pub struct RegionDirty {
16    pub inserted: bool,
17    pub deleted: bool,
18    pub column_updated: bool,
19}
20
21impl RegionDirty {
22    pub fn is_dirty(self) -> bool {
23        self.inserted || self.deleted
24    }
25
26    pub fn is_column_dirty(self) -> bool {
27        self.column_updated
28    }
29}
30
31/// Write-path dirt of one group: the OR over its leaf regions. Cleared for
32/// groups a checkpoint persisted.
33///
34/// Topology checkpoints rewrite a group exactly when `inserted` or `deleted`
35/// is set; `column_updated` precisely traces property-only writes to their
36/// owning groups without forcing a topology rewrite.
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
38pub struct GroupDirty {
39    pub inserted: bool,
40    pub deleted: bool,
41    pub column_updated: bool,
42}
43
44impl GroupDirty {
45    pub fn is_dirty(self) -> bool {
46        self.inserted || self.deleted
47    }
48
49    /// Sampled column-dirt caliber, not a correctness signal. Topology
50    /// checkpoints never consult it; property flushing consult the
51    /// table-level flag.
52    pub fn is_column_dirty(self) -> bool {
53        self.column_updated
54    }
55}
56
57/// Checkpoint class derived from group dirt before it is cleared.
58///
59/// Any delete dirt (including the full-dirty mark left by topology-wide
60/// rebuilds) makes the checkpoint a rebalance; insert-only or
61/// column-only dirt flushes the memory append layer alone.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum EdgeCheckpointKind {
64    AppendOnly,
65    Rebalance,
66}
67
68impl CsrShardSet {
69    /// Dirt of one group; missing groups report clean.
70    pub fn group_dirty(&self, gid: usize) -> GroupDirty {
71        self.shards
72            .get(&gid)
73            .map(|shard| shard.dirty)
74            .unwrap_or_default()
75    }
76
77    /// Ids of groups holding uncheckpointed writes.
78    pub fn dirty_group_ids(&self) -> Vec<usize> {
79        self.shards
80            .iter()
81            .filter_map(|(gid, shard)| shard.dirty.is_dirty().then_some(*gid))
82            .collect()
83    }
84
85    /// Ids of groups holding property-only write traces.
86    ///
87    /// Precise write-time marking: every property write marks its owning
88    /// group, so the flush can limit property shards to traced owners. The
89    /// table-level property flag remains as a correctness insurance that
90    /// rewrites all owners when no trace exists, but the regular path always
91    /// carries a trace.
92    pub fn column_dirty_group_ids(&self) -> Vec<usize> {
93        self.shards
94            .iter()
95            .filter_map(|(gid, shard)| shard.dirty.is_column_dirty().then_some(*gid))
96            .collect()
97    }
98
99    /// Checkpoint class for the current dirt without clearing it.
100    pub fn checkpoint_kind(&self) -> EdgeCheckpointKind {
101        let rebalance = self.shards.values().any(|shard| shard.dirty.deleted);
102        if rebalance {
103            EdgeCheckpointKind::Rebalance
104        } else {
105            EdgeCheckpointKind::AppendOnly
106        }
107    }
108
109    /// Trace a property-only write to the group owning `vid`.
110    ///
111    /// Precise write-time marking: the owning group is materialized when
112    /// missing (unless the direction stores nothing) so every property write
113    /// leaves a group trace. The table-level property flag remains as a
114    /// correctness insurance, but the regular path never needs it.
115    /// Trace a property-only write to a whole group by id.
116    ///
117    /// Group-granular counterpart of [`Self::mark_column_updated_for`] for
118    /// callers that already resolved the owner (schema fills, drops and
119    /// renames touch every row of every owner). Materializes the group when
120    /// missing so the trace is never silently dropped.
121    pub fn mark_column_updated_for_group(&mut self, gid: usize) {
122        if !self.shards.contains_key(&gid) {
123            let _ = self.ensure_group_id(gid);
124        }
125        if let Some(shard) = self.shards.get_mut(&gid) {
126            shard.dirty.column_updated = true;
127            for region in shard.regions.iter_mut() {
128                region.column_updated = true;
129            }
130        }
131    }
132
133    pub fn mark_column_updated_for(&mut self, vid: u32) {
134        let gid = group_id_for(vid, self.group_bits);
135        let rid = region_id_for_local(local_vid(vid, self.group_bits));
136        if !self.shards.contains_key(&gid) {
137            let _ = self.ensure_group_id(gid);
138        }
139        if let Some(shard) = self.shards.get_mut(&gid) {
140            shard.dirty.column_updated = true;
141            if let Some(region) = shard.regions.get_mut(rid) {
142                region.column_updated = true;
143            }
144        }
145    }
146
147    pub fn clear_group_dirty(&mut self, gid: usize) {
148        if let Some(shard) = self.shards.get_mut(&gid) {
149            shard.dirty = GroupDirty::default();
150            for region in shard.regions.iter_mut() {
151                *region = RegionDirty::default();
152            }
153        }
154    }
155
156    pub fn clear_all_dirty(&mut self) {
157        for shard in self.shards.values_mut() {
158            shard.dirty = GroupDirty::default();
159            for region in shard.regions.iter_mut() {
160                *region = RegionDirty::default();
161            }
162        }
163    }
164
165    /// Clear precise column-only traces after a flush. Insert and delete
166    /// dirt is owned by the per-group flush above and never touched here.
167    pub fn clear_all_column_dirty(&mut self) {
168        for shard in self.shards.values_mut() {
169            shard.dirty.column_updated = false;
170            for region in shard.regions.iter_mut() {
171                region.column_updated = false;
172            }
173        }
174    }
175
176    /// Mark every group dirty so the next checkpoint rewrites the full
177    /// direction. Used after topology-wide rebuilds such as vertex remapping,
178    /// where clean-group skipping would otherwise persist stale group files.
179    pub fn mark_all_dirty(&mut self) {
180        for shard in self.shards.values_mut() {
181            shard.dirty = GroupDirty {
182                inserted: true,
183                deleted: true,
184                column_updated: true,
185            };
186            for region in shard.regions.iter_mut() {
187                *region = RegionDirty {
188                    inserted: true,
189                    deleted: true,
190                    column_updated: true,
191                };
192            }
193        }
194    }
195
196    /// Whether a group holds uncheckpointed writes.
197    pub fn needs_checkpoint(&self, gid: usize) -> bool {
198        self.group_dirty(gid).is_dirty()
199    }
200
201    /// Whether one group carries delete dirt and needs a base merge rather
202    /// than an append-only persist.
203    pub fn group_needs_rebalance(&self, gid: usize) -> bool {
204        self.group_dirty(gid).deleted
205    }
206
207    /// Dirt of one leaf region; missing groups or regions report clean.
208    pub fn region_dirty(&self, gid: usize, region: usize) -> RegionDirty {
209        self.shards
210            .get(&gid)
211            .and_then(|shard| shard.regions.get(region))
212            .copied()
213            .unwrap_or_default()
214    }
215
216    /// Ids of dirty leaf regions inside one group.
217    pub fn dirty_region_ids(&self, gid: usize) -> Vec<usize> {
218        self.shards
219            .get(&gid)
220            .map(|shard| {
221                shard
222                    .regions
223                    .iter()
224                    .enumerate()
225                    .filter_map(|(rid, region)| region.is_dirty().then_some(rid))
226                    .collect()
227            })
228            .unwrap_or_default()
229    }
230
231    /// Whether one leaf region holds uncheckpointed topology writes.
232    pub fn region_needs_checkpoint(&self, gid: usize, region: usize) -> bool {
233        self.region_dirty(gid, region).is_dirty()
234    }
235
236    /// Whether one leaf region carries delete dirt and needs a base merge
237    /// rather than an append-only persist.
238    pub fn region_needs_rebalance(&self, gid: usize, region: usize) -> bool {
239        self.region_dirty(gid, region).deleted
240    }
241
242    pub fn clear_region_dirty(&mut self, gid: usize, region: usize) {
243        if let Some(shard) = self.shards.get_mut(&gid) {
244            if let Some(slot) = shard.regions.get_mut(region) {
245                *slot = RegionDirty::default();
246            }
247            if shard.regions.iter().all(|r| !r.is_dirty()) {
248                shard.dirty.inserted = false;
249                shard.dirty.deleted = false;
250            } else {
251                shard.dirty.inserted = shard.regions.iter().any(|r| r.inserted);
252                shard.dirty.deleted = shard.regions.iter().any(|r| r.deleted);
253            }
254        }
255    }
256
257    pub(super) fn mark_region_insert(&mut self, gid: usize, local: u32) {
258        if let Some(shard) = self.shards.get_mut(&gid) {
259            shard.dirty.inserted = true;
260            let rid = region_id_for_local(local);
261            if let Some(region) = shard.regions.get_mut(rid) {
262                region.inserted = true;
263            }
264        }
265    }
266
267    pub(super) fn mark_region_delete(&mut self, gid: usize, local: u32) {
268        if let Some(shard) = self.shards.get_mut(&gid) {
269            shard.dirty.deleted = true;
270            let rid = region_id_for_local(local);
271            if let Some(region) = shard.regions.get_mut(rid) {
272                region.deleted = true;
273            }
274        }
275    }
276}