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
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
// SPDX-License-Identifier: BUSL-1.1
//! Cross-collection isolation: the in-memory graph CSR.
//!
//! Regression for the bug where two graph-enabled collections in the same
//! `(database, tenant)` shared ONE in-memory CSR adjacency index with no
//! collection axis, so a `MATCH ... IN <collA>` (and GraphRAG expansion, which
//! both read the CSR via the collection-scoped neighbor primitive) walked edges
//! inserted under `<collB>`.
//!
//! The fix tags every CSR edge with the collection it was inserted under
//! (nodes, node-labels and surrogates stay shared across collections — only
//! edges carry the collection axis) and filters the collection-scoped read
//! paths by it. These tests exercise that primitive directly: one CSR
//! partition (one database/tenant) holding TWO collections, asserting a
//! collection-scoped read sees only its own edges while the unscoped,
//! collection-less view (bare `GRAPH NEIGHBORS` / traversal, which have no
//! collection at any layer of the stack) still sees the merged graph.
//!
//! Pre-fix these assertions cannot hold: the CSR had no per-edge collection
//! tag, so a collection-scoped read of collection A necessarily also returned
//! collection B's edges.
use nodedb::engine::graph::csr::{CsrIndex, Direction};
const COLL_A: &str = "collection_a";
const COLL_B: &str = "collection_b";
/// Build one CSR partition holding distinct edges under two collections.
///
/// A: alice -KNOWS-> bob, bob -KNOWS-> carol
/// B: alice -KNOWS-> dave, dave -KNOWS-> erin
///
/// `alice` is shared across both collections (nodes are shared) but its
/// out-edges belong to different collections.
fn build_two_collection_csr() -> CsrIndex {
let mut csr = CsrIndex::new();
csr.add_edge_in_collection("alice", "KNOWS", "bob", COLL_A)
.unwrap();
csr.add_edge_in_collection("bob", "KNOWS", "carol", COLL_A)
.unwrap();
csr.add_edge_in_collection("alice", "KNOWS", "dave", COLL_B)
.unwrap();
csr.add_edge_in_collection("dave", "KNOWS", "erin", COLL_B)
.unwrap();
csr
}
fn out_neighbor_names(csr: &CsrIndex, node: &str, collection: &str) -> Vec<String> {
let mut names: Vec<String> = csr
.neighbors_in_collection(node, Some("KNOWS"), Direction::Out, collection)
.into_iter()
.map(|(_label, n)| n)
.collect();
names.sort();
names
}
/// A collection-scoped read of collection A returns ONLY A's edges — never B's —
/// even though `alice` has out-edges in both collections in the same partition.
#[test]
fn scoped_neighbors_do_not_leak_across_collections() {
let csr = build_two_collection_csr();
// Collection A: alice → bob only. NOT dave (that edge is collection B).
assert_eq!(
out_neighbor_names(&csr, "alice", COLL_A),
vec!["bob".to_string()],
"collection A scoped read must see alice→bob and NOT alice→dave (B)"
);
// Symmetric: collection B: alice → dave only.
assert_eq!(
out_neighbor_names(&csr, "alice", COLL_B),
vec!["dave".to_string()],
"collection B scoped read must see alice→dave and NOT alice→bob (A)"
);
// A node whose edges are entirely in A is invisible to a B-scoped read.
assert!(
out_neighbor_names(&csr, "bob", COLL_B).is_empty(),
"bob's KNOWS edge is in collection A; a B-scoped read must not see it"
);
assert_eq!(
out_neighbor_names(&csr, "bob", COLL_A),
vec!["carol".to_string()],
);
}
/// The same guarantee must hold after `compact()` merges the write buffer into
/// the dense CSR arrays — the collection tag must survive compaction, not just
/// live in the buffer.
#[test]
fn scoped_neighbors_isolated_after_compaction() {
let mut csr = build_two_collection_csr();
csr.compact()
.expect("compaction without a governor cannot fail");
assert_eq!(
out_neighbor_names(&csr, "alice", COLL_A),
vec!["bob".to_string()],
"post-compaction: A must see only alice→bob"
);
assert_eq!(
out_neighbor_names(&csr, "alice", COLL_B),
vec!["dave".to_string()],
"post-compaction: B must see only alice→dave"
);
}
/// An `IN '<collection>'` clause naming a collection with no edges in this
/// partition matches nothing — it must NOT fall back to the merged view (which
/// would re-introduce the leak).
#[test]
fn scoped_read_of_unknown_collection_is_empty_not_merged() {
let csr = build_two_collection_csr();
assert!(
out_neighbor_names(&csr, "alice", "collection_never_written").is_empty(),
"a scoped read of an unknown collection must match nothing, never the merged graph"
);
}
/// The collection-less merged view (bare `GRAPH NEIGHBORS` / traversal — these
/// carry no collection at any layer of the stack) still sees every collection's
/// edges. This is the intended tenant-wide behavior and proves the two
/// collections genuinely share ONE partition: the per-edge tag — not a separate
/// per-collection partition — is what scopes the MATCH/RAG reads above.
#[test]
fn unscoped_neighbors_see_the_merged_graph() {
let csr = build_two_collection_csr();
let mut merged: Vec<String> = csr
.neighbors("alice", Some("KNOWS"), Direction::Out)
.into_iter()
.map(|(_label, n)| n)
.collect();
merged.sort();
assert_eq!(
merged,
vec!["bob".to_string(), "dave".to_string()],
"the collection-less merged view sees BOTH collections' edges for alice"
);
}
/// The SAME triple `(X, KNOWS, Y)` inserted under BOTH collections must exist
/// as TWO distinct edges — each collection retains its own copy. A collA-scoped
/// read AND a collB-scoped read must each return it. Pre-identity-fix, edge
/// identity was `(src,label,dst)` so the second insert deduped away and collB's
/// read MISSED the shared triple (a false negative).
#[test]
fn shared_triple_is_visible_in_both_collections() {
let mut csr = CsrIndex::new();
csr.add_edge_in_collection("x", "KNOWS", "y", COLL_A)
.unwrap();
csr.add_edge_in_collection("x", "KNOWS", "y", COLL_B)
.unwrap();
// Both collections see their own copy of the shared triple (buffer path).
assert_eq!(out_neighbor_names(&csr, "x", COLL_A), vec!["y".to_string()]);
assert_eq!(out_neighbor_names(&csr, "x", COLL_B), vec!["y".to_string()]);
// And after compaction (dense path) — the collection tag on each copy
// must survive the buffer→dense merge without collapsing to one edge.
csr.compact()
.expect("compaction without a governor cannot fail");
assert_eq!(out_neighbor_names(&csr, "x", COLL_A), vec!["y".to_string()]);
assert_eq!(out_neighbor_names(&csr, "x", COLL_B), vec!["y".to_string()]);
}
/// Removing collA's copy of a shared triple leaves collB's copy intact —
/// collection-scoped removal keys on the full `(src,label,dst,collection)`
/// identity, so it never over-deletes the other collection's edge.
#[test]
fn scoped_removal_of_shared_triple_leaves_other_collection_intact() {
let mut csr = CsrIndex::new();
csr.add_edge_in_collection("x", "KNOWS", "y", COLL_A)
.unwrap();
csr.add_edge_in_collection("x", "KNOWS", "y", COLL_B)
.unwrap();
// Remove only collA's copy.
csr.remove_edge_in_collection("x", "KNOWS", "y", COLL_A);
assert!(
out_neighbor_names(&csr, "x", COLL_A).is_empty(),
"collA's copy of the shared triple must be gone"
);
assert_eq!(
out_neighbor_names(&csr, "x", COLL_B),
vec!["y".to_string()],
"collB's copy of the shared triple must remain after collA's removal"
);
// Same guarantee across a compaction boundary (dense tombstone survives).
csr.compact()
.expect("compaction without a governor cannot fail");
assert!(out_neighbor_names(&csr, "x", COLL_A).is_empty());
assert_eq!(out_neighbor_names(&csr, "x", COLL_B), vec!["y".to_string()]);
}
/// Tenant/database scoping is orthogonal to (and preserved alongside) the new
/// collection axis: distinct partitions never share edges. Modeled here at the
/// CSR level — two independent `CsrIndex` partitions (as `ShardedCsrIndex`
/// keys them per `(database, tenant)`) never observe each other's edges
/// regardless of collection. The dedicated cross-tenant executor test
/// (`test_tenant_isolation_graph`) covers the end-to-end path.
#[test]
fn distinct_partitions_do_not_share_edges() {
let mut tenant_a = CsrIndex::new();
let tenant_b = CsrIndex::new();
tenant_a
.add_edge_in_collection("alice", "KNOWS", "bob", COLL_A)
.unwrap();
// Tenant B's partition never received the edge, under any collection.
assert!(tenant_b.neighbors("alice", None, Direction::Out).is_empty());
assert!(
out_neighbor_names(&tenant_b, "alice", COLL_A).is_empty(),
"a separate partition must not see another partition's edges"
);
// Tenant A still has it.
assert_eq!(
out_neighbor_names(&tenant_a, "alice", COLL_A),
vec!["bob".to_string()]
);
}