Skip to main content

linkrs_storage/engine/graph_storage/
persistence_api.rs

1use std::sync::Arc;
2
3use linkrs_core::types::{CommitLsn, CompactConfig, LabelId, Timestamp};
4use linkrs_core::{StorageError, StorageResult, Value};
5
6use crate::vertex::ShardedVertexTable;
7use crate::StoragePersistenceOps;
8
9use super::persistence;
10use super::GraphStorage;
11
12/// Read-only redistribution preview: live rows, their distribution under the
13/// target shard count, and edge partitions that would need draining (edge-free
14/// entry) or translation (edge-aware entry). No rows are copied and the
15/// catalog is untouched.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct VertexReshardDryRun {
18    pub live_rows: usize,
19    pub new_num_shards: usize,
20    pub per_shard_rows: Vec<usize>,
21    pub edge_blockers: Vec<String>,
22}
23
24impl GraphStorage {
25    /// Offline inspection of the vertex store under `vertices_dir`.
26    ///
27    /// Read-only patrol entry for half-damaged stores: reuses the recovery
28    /// path manifest decoding per label and validates baseline plus
29    /// incremental epoch chain continuity. Returns a one-line summary;
30    /// cleanup stays with startup recovery.
31    pub fn offline_inspect_vertex_store(
32        &self,
33        vertices_dir: &std::path::Path,
34    ) -> StorageResult<String> {
35        let health = ShardedVertexTable::inspect_store_health(vertices_dir)?;
36        let summary = format!(
37            "vertex store health: tables={} chain_ok={} healthy={} issues={:?}",
38            health.tables.len(),
39            health.chain_ok,
40            health.is_healthy(),
41            health.issues,
42        );
43        log::info!("{}", summary);
44        Ok(summary)
45    }
46
47    /// Read-only redistribution preview for one vertex label.
48    ///
49    /// Runs the vertex dry-run precheck (target layout, pending schema fence,
50    /// key shapes, target capacity, live distribution) plus an edge-reference
51    /// census, without copying any row or switching the catalog. Edge
52    /// partitions holding live edges that reference the label are reported,
53    /// not refused, so the caller can choose the edge-free entry (drain
54    /// first) or the edge-aware entry (translate through the produced map).
55    pub fn dry_run_reshard_vertex_table(
56        &self,
57        label: LabelId,
58        new_num_shards: usize,
59    ) -> StorageResult<VertexReshardDryRun> {
60        let preview = self.ctx.data_store().with_vertex_tables(|tables| {
61            let table = tables.get(&label).ok_or_else(|| {
62                StorageError::label_not_found(format!("vertex label {} not found", label))
63            })?;
64            table.dry_run_reshard_to(new_num_shards)
65        })?;
66        let blockers: Vec<String> = self.ctx.data_store().with_edge_tables(|tables| {
67            let mut blockers = Vec::new();
68            for (key, table) in tables.iter() {
69                if key.src_label != label
70                    && key.dst_label != label
71                    && key.src_label != 0
72                    && key.dst_label != 0
73                {
74                    continue;
75                }
76                if table.read().edge_count() > 0 {
77                    blockers.push(format!(
78                        "edge partition ({},{},{}) holds live edges",
79                        key.src_label, key.dst_label, key.edge_label
80                    ));
81                }
82            }
83            blockers
84        });
85        Ok(VertexReshardDryRun {
86            live_rows: preview.live_rows,
87            new_num_shards: preview.new_num_shards,
88            per_shard_rows: preview.per_shard_rows,
89            edge_blockers: blockers,
90        })
91    }
92
93    /// Pre-switch baseline probe for an offline reshard: flushes the rebuilt
94    /// table into a scratch directory beside the live vertex store, strictly
95    /// health-checks the written checkpoint, and always removes the probe.
96    /// Runs while the old table is still authoritative, so a rebuild that
97    /// cannot durably flush a healthy baseline fails the reshard before any
98    /// catalog switch. An engine without persistent paths has no on-disk
99    /// baseline to protect, so the probe is a no-op there.
100    fn probe_rebuilt_vertex_baseline(
101        &self,
102        rebuilt: &ShardedVertexTable,
103        label: LabelId,
104    ) -> StorageResult<()> {
105        let Some(paths) = self.ctx.storage_paths() else {
106            return Ok(());
107        };
108        let probe_dir = paths
109            .vertices_dir()
110            .join(format!("label_{label}.reshard_probe.tmp"));
111        let _ = std::fs::remove_dir_all(&probe_dir);
112        let verdict = rebuilt
113            .flush(&probe_dir, self.ctx.flush_compression())
114            .and_then(|()| {
115                let report = ShardedVertexTable::inspect_commit_health(&probe_dir)?;
116                if report.is_healthy() {
117                    return Ok(());
118                }
119                let mut issues = report.missing_files.clone();
120                issues.extend(report.pk_issues.clone());
121                issues.extend(report.lineage_issues.clone());
122                if !report.manifest_present || !report.manifest_decodable {
123                    issues.push("commit manifest missing or undecodable".to_string());
124                }
125                Err(StorageError::invalid_operation(format!(
126                    "reshard probe step failed: rebuilt baseline for vertex label {} \
127                     is unhealthy at {}: {:?}",
128                    label,
129                    probe_dir.display(),
130                    issues
131                )))
132            });
133        // The probe is scratch content either way; a removal failure only
134        // leaves orphans that the tolerant orphan cleanup clears later.
135        let _ = std::fs::remove_dir_all(&probe_dir);
136        verdict
137    }
138
139    /// Strict health check of the freshly published checkpoint baseline for
140    /// one reshard label, run before old snapshots retire. Inspects the
141    /// label's vertex directory inside the published checkpoint itself;
142    /// failure refuses the reshard after naming the health step, keeping the
143    /// retirement of recoverable history for a verified baseline.
144    fn verify_resharded_baseline(&self, label: LabelId, checkpoint_seq: u64) -> StorageResult<()> {
145        let persistence = self.ctx.persistence().as_ref().ok_or_else(|| {
146            StorageError::invalid_operation(
147                "reshard health step failed: no persistence coordinator for the checkpoint",
148            )
149        })?;
150        let table_dir = persistence
151            .read()
152            .checkpoint_dir()
153            .join(format!("checkpoint_{checkpoint_seq}"))
154            .join("data")
155            .join("vertices")
156            .join(format!("label_{label}"));
157        let report = ShardedVertexTable::inspect_commit_health(&table_dir).map_err(|e| {
158            StorageError::db_error(format!(
159                "reshard health step failed: cannot inspect the published vertex baseline \
160                 for label {} at {}: {}",
161                label,
162                table_dir.display(),
163                e
164            ))
165        })?;
166        if !report.is_healthy() {
167            return Err(StorageError::invalid_operation(format!(
168                "reshard health step failed: published checkpoint {} for vertex label {} \
169                 is unhealthy at {}: missing={:?} pk={:?} lineage={:?} manifest_present={} \
170                 manifest_decodable={}. Old snapshots are kept; restore the previous \
171                 checkpoint before retrying.",
172                checkpoint_seq,
173                label,
174                table_dir.display(),
175                report.missing_files,
176                report.pk_issues,
177                report.lineage_issues,
178                report.manifest_present,
179                report.manifest_decodable,
180            )));
181        }
182        Ok(())
183    }
184
185    /// Offline redistribution of one vertex label to a new shard count.
186    ///
187    /// The edge-free adjustment outlet for the shard-count change, run as
188    /// five ordered steps: rebuild every live row into a fresh table, probe
189    /// the rebuilt baseline on disk and health-check it, switch the catalog,
190    /// force the checkpoint that makes the new state the durable baseline,
191    /// verify the published baseline, then retire old snapshots. Any failure
192    /// before the switch keeps the old table authoritative and names the
193    /// failing step; online shard count changes stay rejected by the table
194    /// manifest.
195    ///
196    /// The tool holds the offline maintenance barrier itself, so no concurrent
197    /// writes can tear the rebuild-swap sequence. Vertex IDs are rehashed and
198    /// re-encoded, while edge endpoint IDs are not rewritten here: any live
199    /// edge partition referencing this label (including wildcard label 0)
200    /// refuses the rebuild so a graph is never silently mis-linked. Drain or
201    /// migrate those edges first, or use
202    /// [`Self::offline_reshard_vertex_table_with_edge_translation`].
203    pub fn offline_reshard_vertex_table(
204        &self,
205        label: LabelId,
206        new_num_shards: usize,
207    ) -> StorageResult<usize> {
208        let _barrier = self.ctx.offline_maintenance_barrier();
209        let blockers: Vec<String> = self.ctx.data_store().with_edge_tables(|tables| {
210            let mut blockers = Vec::new();
211            for (key, table) in tables.iter() {
212                if key.src_label != label
213                    && key.dst_label != label
214                    && key.src_label != 0
215                    && key.dst_label != 0
216                {
217                    continue;
218                }
219                if table.read().edge_count() > 0 {
220                    blockers.push(format!(
221                        "edge partition ({},{},{}) holds live edges",
222                        key.src_label, key.dst_label, key.edge_label
223                    ));
224                }
225            }
226            blockers
227        });
228        if !blockers.is_empty() {
229            return Err(StorageError::invalid_operation(format!(
230                "reshard refused: vertex label {} is referenced by {}: \
231                 drain or migrate those edges before offline redistribution, \
232                 or use the edge-aware redistribution entry",
233                label,
234                blockers.join("; ")
235            )));
236        }
237        let (rebuilt, _) = self.ctx.data_store().with_vertex_tables(|tables| {
238            let table = tables.get(&label).ok_or_else(|| {
239                StorageError::label_not_found(format!("vertex label {} not found", label))
240            })?;
241            table.reshard_to(new_num_shards)
242        })?;
243        let rows = rebuilt.approximate_total_count();
244        let shards = rebuilt.num_shards();
245        // Persist and check the rebuilt baseline before switching anything:
246        // a rebuild that cannot flush healthy refuses the reshard here.
247        self.probe_rebuilt_vertex_baseline(&rebuilt, label)?;
248        self.ctx.data_store().with_vertex_tables_mut(|tables| {
249            tables.insert(label, Arc::new(rebuilt));
250            Ok::<(), StorageError>(())
251        })?;
252        self.ctx.invalidate_vertex_cache(label);
253        self.ctx.mark_vertex_modified(label);
254        self.ctx.bump_layout_version();
255        // Forced checkpoint fence: the swapped state must reach a durable
256        // baseline before this call reports success.
257        let stats = self.create_checkpoint()?.ok_or_else(|| {
258            StorageError::invalid_operation(format!(
259                "reshard finalize step failed: vertex label {} was rebuilt to {} shards \
260                 but no checkpoint baseline was written: refusing success; the next open \
261                 still sees the old baseline",
262                label, shards,
263            ))
264        })?;
265        // Check the published baseline before retiring old snapshots.
266        self.verify_resharded_baseline(label, stats.checkpoint_id)?;
267        log::info!(
268            "Resharded vertex label {} to {} shards, {} rows carried over, checkpoint {} published",
269            label,
270            shards,
271            rows,
272            stats.checkpoint_id
273        );
274        self.cleanup_snapshots()?;
275        Ok(rows)
276    }
277
278    /// Edge-aware offline redistribution of one vertex label.
279    ///
280    /// Same fence as the edge-free entry, but live edge references are
281    /// translated instead of refused: the vertex rebuild produces the
282    /// old-to-new internal id mapping, every edge partition referencing the
283    /// label (including wildcard label 0) is remapped through the edge
284    /// table's own remap machinery, and the vertex swap plus edge rewrite
285    /// commit as one unit under the held barrier while readers continue on
286    /// their pre-tool snapshots. A forced checkpoint baseline is written
287    /// before returning; an unwritten checkpoint counts as not done and the
288    /// next open still sees the old baseline. The new baseline is health
289    /// checked before the call reports success; any step failing keeps the
290    /// old catalog entry authoritative with the failing step named.
291    pub fn offline_reshard_vertex_table_with_edge_translation(
292        &self,
293        label: LabelId,
294        new_num_shards: usize,
295    ) -> StorageResult<usize> {
296        use std::collections::HashMap;
297        let _barrier = self.ctx.offline_maintenance_barrier();
298        let (rebuilt, id_mapping) = self.ctx.data_store().with_vertex_tables(|tables| {
299            let table = tables.get(&label).ok_or_else(|| {
300                StorageError::label_not_found(format!("vertex label {} not found", label))
301            })?;
302            table.reshard_to(new_num_shards)
303        })?;
304        let rows = rebuilt.approximate_total_count();
305        let shards = rebuilt.num_shards();
306        // Persist and check the rebuilt baseline before switching anything:
307        // a rebuild that cannot flush healthy refuses the reshard here.
308        self.probe_rebuilt_vertex_baseline(&rebuilt, label)?;
309        self.ctx.data_store().with_vertex_tables_mut(|tables| {
310            tables.insert(label, Arc::new(rebuilt));
311            Ok::<(), StorageError>(())
312        })?;
313
314        // Translate every edge partition referencing the label through the
315        // edge remap machinery. Wildcard endpoints (label 0) resolve against
316        // this label's mapping; partitions touching neither endpoint nor a
317        // wildcard are skipped. Unmapped endpoints keep their value, and an
318        // empty mapping remaps nothing.
319        let per_label: HashMap<LabelId, HashMap<u32, u32>> =
320            [(label, id_mapping)].into_iter().collect();
321        let mapping_for = |endpoint: LabelId| -> Option<&HashMap<u32, u32>> {
322            if let Some(mapping) = per_label.get(&endpoint) {
323                return Some(mapping);
324            }
325            if endpoint == 0 {
326                return per_label.get(&label);
327            }
328            None
329        };
330        let remapped: Vec<(crate::engine::data_store::EdgeTableKey, bool)> = self
331            .ctx
332            .data_store()
333            .for_all_edge_partitions_mut(|key, table| {
334                let src_mapping = mapping_for(key.src_label);
335                let dst_mapping = mapping_for(key.dst_label);
336                if src_mapping.is_none() && dst_mapping.is_none() {
337                    return Ok((key, false));
338                }
339                table.remap_vertex_ids(src_mapping, dst_mapping)?;
340                for endpoint in [key.src_label, key.dst_label] {
341                    if endpoint == label || endpoint == 0 {
342                        self.ctx.mark_edge_modified(key.edge_label);
343                    }
344                }
345                Ok((key, true))
346            })?;
347
348        self.ctx.invalidate_vertex_cache(label);
349        self.ctx.mark_vertex_modified(label);
350        self.ctx.bump_layout_version();
351
352        // Forced checkpoint fence: the translated state must reach a durable
353        // baseline before this call reports success.
354        let stats = self.create_checkpoint()?.ok_or_else(|| {
355            StorageError::db_error(format!(
356                "reshard of vertex label {} to {} shards translated {} edge partition(s) \
357                 but no checkpoint baseline was written: refusing success; the next open \
358                 still sees the old baseline",
359                label,
360                shards,
361                remapped.iter().filter(|(_, did)| *did).count(),
362            ))
363        })?;
364        // Check the published baseline before retiring old snapshots.
365        self.verify_resharded_baseline(label, stats.checkpoint_id)?;
366        log::info!(
367            "Resharded vertex label {} to {} shards with edge translation: {} rows, \
368             {} edge partition(s) translated, checkpoint {} published",
369            label,
370            shards,
371            rows,
372            remapped.iter().filter(|(_, did)| *did).count(),
373            stats.checkpoint_id,
374        );
375        self.cleanup_snapshots()?;
376        Ok(rows)
377    }
378
379    /// Offline bulk import into an empty vertex label.
380    ///
381    /// Empty-table fast path for initial loads: capacity reserved once, rows
382    /// applied under one lock hold per shard. Non-empty tables are rejected
383    /// with guidance toward incremental batches. `sorted` declares strictly
384    /// ordered unique keys, verified upfront.
385    pub fn offline_bulk_import_vertices_str(
386        &self,
387        label: LabelId,
388        rows: &[(String, Vec<(Arc<str>, Value)>)],
389        ts: Timestamp,
390        sorted: bool,
391    ) -> StorageResult<usize> {
392        let table = self.ctx.data_store().with_vertex_tables(|tables| {
393            tables.get(&label).cloned().ok_or_else(|| {
394                StorageError::label_not_found(format!("vertex label {} not found", label))
395            })
396        })?;
397        let borrowed: Vec<(&str, &[(Arc<str>, Value)])> = rows
398            .iter()
399            .map(|(id, props)| (id.as_str(), props.as_slice()))
400            .collect();
401        table.bulk_import_str(&borrowed, ts, sorted)
402    }
403
404    /// Integer-keyed offline bulk import. Same contract as
405    /// [`Self::offline_bulk_import_vertices_str`].
406    pub fn offline_bulk_import_vertices_i64(
407        &self,
408        label: LabelId,
409        rows: &[(i64, Vec<(Arc<str>, Value)>)],
410        ts: Timestamp,
411        sorted: bool,
412    ) -> StorageResult<usize> {
413        let table = self.ctx.data_store().with_vertex_tables(|tables| {
414            tables.get(&label).cloned().ok_or_else(|| {
415                StorageError::label_not_found(format!("vertex label {} not found", label))
416            })
417        })?;
418        let borrowed: Vec<(i64, &[(Arc<str>, Value)])> = rows
419            .iter()
420            .map(|(id, props)| (*id, props.as_slice()))
421            .collect();
422        table.bulk_import_i64(&borrowed, ts, sorted)
423    }
424}
425
426impl StoragePersistenceOps for GraphStorage {
427    fn flush(&self) -> StorageResult<()> {
428        persistence::flush(&self.ctx)
429    }
430
431    fn create_checkpoint(&self) -> StorageResult<Option<crate::CheckpointStats>> {
432        persistence::create_checkpoint(&self.ctx)
433    }
434
435    fn verify_snapshot(&self, snapshot_id: u64) -> StorageResult<bool> {
436        persistence::verify_snapshot(&self.ctx, snapshot_id)
437    }
438
439    fn cleanup_snapshots(&self) -> StorageResult<usize> {
440        persistence::cleanup_snapshots(&self.ctx)
441    }
442
443    fn snapshot_stats(&self) -> crate::SnapshotStats {
444        persistence::snapshot_stats(&self.ctx)
445    }
446
447    fn persistence_diagnostics(&self) -> Option<crate::PersistenceDiagnostics> {
448        persistence::persistence_diagnostics(&self.ctx)
449    }
450
451    fn compact(&self, config: &CompactConfig) -> StorageResult<()> {
452        persistence::compact_transactional(&self.ctx, config)
453    }
454
455    fn save_data(&self) -> StorageResult<()> {
456        persistence::save_data(&self.ctx)
457    }
458
459    fn save_data_to_dir(&self, dir: &std::path::Path) -> StorageResult<()> {
460        persistence::save_data_to_dir(&self.ctx, dir)
461    }
462
463    fn auto_flush_if_needed(&self) -> StorageResult<bool> {
464        persistence::auto_flush_if_needed(&self.ctx)
465    }
466
467    fn auto_checkpoint_if_needed(&self) -> StorageResult<Option<crate::CheckpointStats>> {
468        persistence::auto_checkpoint_if_needed(&self.ctx)
469    }
470
471    fn should_flush(&self) -> bool {
472        persistence::should_flush(&self.ctx)
473    }
474
475    fn should_checkpoint(&self) -> bool {
476        persistence::should_checkpoint(&self.ctx)
477    }
478
479    fn set_outbox_materialized_lsn_provider(
480        &self,
481        provider: Arc<dyn Fn() -> StorageResult<Option<CommitLsn>> + Send + Sync>,
482    ) {
483        if let Some(persistence) = self.ctx.persistence() {
484            persistence
485                .read()
486                .set_outbox_materialized_lsn_provider(provider);
487        }
488    }
489}