1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
//! `DROP GRAPH` factory-reset for the transaction mutator.
//!
//! Extracted from the mutator module to keep `mutator.rs` under the 700-LOC
//! file cap. The reset reuses the same change-free row-removal cores the
//! BRIEF-150 truncate path uses (`remove_node_row` / `remove_edge_row`), so the
//! resulting in-memory state is byte-identical to `MATCH (n) DETACH DELETE n`
//! plus a full schema drop.
use selene_core::Change;
use crate::Mutator;
use crate::error::GraphResult;
use crate::store::RowIndex;
impl<'tx, 'g> Mutator<'tx, 'g> {
/// Factory-reset the entire graph: wipe every node and edge and reset the
/// schema to open, recording exactly **one** declarative
/// [`Change::GraphReset`] (deletion-reclamation audit Item 10, BRIEF-152).
///
/// This is the `DROP GRAPH` primitive. Under D1 single-graph it targets the
/// one bound graph. Behaviour and invariants:
///
/// * **Wipes ALL rows, including untyped ones.** Live rows are enumerated
/// from the two alive [`roaring::RoaringBitmap`]s
/// ([`crate::SeleneGraph::live_nodes`] / [`crate::SeleneGraph::live_edges`]),
/// **never** per label — so nodes/edges whose labels are not declared
/// types (legal in an open GG01 graph) are removed too. A per-type
/// truncate would silently miss them.
/// * **Resets the schema to open.** `meta.bound_type` is set to `None`,
/// making a previously closed (GG02) graph open (GG01) again. There are no
/// standalone type defs outside `bound_type`, so clearing it is the
/// complete schema reset. As a side effect, the commit-time closed-graph
/// validation loop (`write_txn`) is skipped entirely once `bound_type` is
/// `None`, which is correct — nothing is left to validate.
/// * **O(1) WAL.** Exactly one [`Change::GraphReset`] is pushed regardless of
/// the number of rows removed. The per-row `NodeDeleted`/`EdgeDeleted`
/// tombstones are staged into the fan-out buffer (`truncate_expansions`)
/// only, never into the persisted changeset, so derived state (e.g. the
/// label/property indexes) is reclaimed without leaks while the WAL stays
/// constant-size.
/// * **Idempotent.** On an already-empty + open graph the row enumeration is
/// empty and `bound_type` is already `None`; a `GraphReset` is still pushed
/// with an empty staged expansion (which the fan-out expander drops), so a
/// second `DROP GRAPH` is a clean observable no-op, never an error.
///
/// The MANIFEST epoch and WAL archive lineage are untouched: this is one
/// committed WAL entry on top of the existing snapshot, not a file-level
/// wipe.
///
/// # Errors
///
/// Returns a [`crate::GraphError`] only if the change-free removal cores hit
/// a structural inconsistency (e.g. a missing edge row for a live index
/// entry) — the same error surface the truncate path exposes.
pub fn factory_reset(&mut self) -> GraphResult<()> {
// Snapshot every live row BEFORE any removal: removal mutates the alive
// bitmaps and adjacency/index structures we are iterating (the same
// clone-collect discipline truncate_node_type uses).
let node_rows: Vec<u32> = self.txn.read().node_store.alive.iter().collect();
let edge_rows: Vec<u32> = self.txn.read().edge_store.alive.iter().collect();
let mut expansion = Vec::with_capacity(node_rows.len() + edge_rows.len());
for row in node_rows {
// Every row came from the alive bitmap, so its external id is mapped
// (an unmapped row would be a never-committed hole, never alive).
let Some(id) = self.txn.read().node_id_for_row(RowIndex::new(row)) else {
continue;
};
// remove_node_row scrubs idx_label, property/composite indexes,
// adjacency, and node liveness. Its returned incident-edge set is
// discarded here because the alive-edge bitmap below is the
// authoritative superset (it also covers untyped edges between
// untyped nodes), so we clear every edge row directly.
let _ = self.remove_node_row(id, row as usize)?;
expansion.push(Change::NodeDeleted { id });
}
// Remove every still-alive edge row. remove_node_row detached incident
// edges from adjacency but did NOT clear edge liveness / edge-label
// index, so iterate the full alive-edge set captured before removal.
for row in edge_rows {
// Defensive: a row may already be dead if it shared two truncated
// endpoints — remove_edge_row is only called for still-alive rows.
if !self.txn.read().edge_store.is_alive(row) {
continue;
}
let Some(id) = self.txn.read().edge_id_for_row(RowIndex::new(row)) else {
continue;
};
debug_assert!(
self.txn.read().row_for_edge_id(id) == Some(RowIndex::new(row)),
"edge row/id round-trip must hold"
);
self.remove_edge_row(id, row as usize)?;
expansion.push(Change::EdgeDeleted { id });
}
// Reset the schema to open. Different from DROP TYPE (which keeps the
// graph closed by setting bound_type = Some(next)); factory-reset clears
// the WHOLE bound_type to None.
self.txn.guard_mut().meta.bound_type = None;
// Push EXACTLY ONE declarative change (O(1) WAL) and stage the per-row
// tombstones for subscriber fan-out, keyed to this change's index.
let index = self.txn.changes.len();
self.txn.changes.push(Change::GraphReset {});
self.txn.truncate_expansions.push((index, expansion));
Ok(())
}
}