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
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
//! Persistent property indexes for `DiskGraph`: build, lookup (eq +
//! prefix), and global (cross-type) variants.
//!
//! Split out of `graph.rs` to keep that file under the 2,500-line cap.
//! Lives in a sibling `impl DiskGraph {}` block.
use crate::datatypes::values::Value;
use crate::graph::schema::InternedKey;
use petgraph::graph::NodeIndex;
use std::collections::HashMap;
use std::sync::Arc;
use super::graph::DiskGraph;
use super::property_index;
#[cfg(test)]
thread_local! {
static BUILD_FAILPOINT: std::cell::Cell<Option<&'static str>> = const { std::cell::Cell::new(None) };
}
#[cfg(test)]
pub(crate) struct PropertyIndexBuildFailpoint;
#[cfg(test)]
impl Drop for PropertyIndexBuildFailpoint {
fn drop(&mut self) {
BUILD_FAILPOINT.with(|point| point.set(None));
}
}
#[cfg(test)]
pub(crate) fn fail_property_index_build(stage: &'static str) -> PropertyIndexBuildFailpoint {
BUILD_FAILPOINT.with(|point| point.set(Some(stage)));
PropertyIndexBuildFailpoint
}
fn property_index_build_failpoint(stage: &'static str) -> std::io::Result<()> {
#[cfg(test)]
if BUILD_FAILPOINT.with(|point| point.get() == Some(stage)) {
return Err(std::io::Error::other(format!(
"injected {stage} property-index build failure"
)));
}
let _ = stage;
Ok(())
}
impl DiskGraph {
/// Build (or rebuild) a persistent string property index for
/// `(node_type, property)`. Writes four files to `data_dir` and
/// caches the handle. Subsequent `lookup_property_eq` calls use the
/// index; the planner sees it via the `GraphRead::lookup_by_property_eq`
/// trait method.
///
/// Only `TypedColumn::Str` columns are indexable today — the property
/// must exist on the type's ColumnStore as a string column. Non-string
/// or missing properties are a no-op that returns `Ok(())`; the index
/// will simply contain zero entries and all lookups will miss.
pub fn build_property_index(
&mut self,
node_type: &str,
property: &str,
) -> std::io::Result<usize> {
self.prepare_mutation()?;
let index_key = (node_type.to_string(), property.to_string());
let type_key = InternedKey::from_str(node_type);
let type_u64 = type_key.as_u64();
let prop_key = InternedKey::from_str(property);
// Three ways to resolve a property to a string value per node:
// 1. Title/id alias columns (checked via helpers below) — covers
// `label`, `nid`, and any user-chosen title/id field names.
// 2. Regular schema column via `get_str_by_slot`.
// 3. Fall back to NodeData::get_property, which is the arena
// path used by the pattern matcher — slower but correct for
// exotic cases (non-columnar properties, map storage).
let col_store = self.column_stores.get(&type_key);
let schema_slot = col_store.and_then(|cs| cs.schema().slot(prop_key));
// Heuristic: "title" or "id" literals, and anything stored outside
// the regular schema, goes through the NodeData materialisation
// path so title/id aliases and mapped-mode stores resolve
// correctly. Everything else reads directly from the column.
let use_slot_path = schema_slot.is_some();
let node_bound = self.node_slot_len();
let mut entries: Vec<(String, u32)> = Vec::with_capacity(node_bound);
for i in 0..node_bound {
let nslot = self.node_slot(i);
if !nslot.is_alive() || nslot.node_type != type_u64 {
continue;
}
// Try paths in order of specificity:
// 1. Regular schema column (`get_str_by_slot`) — fast path.
// 2. Title column (`get_title`) — covers `label`/`name`/
// any user-chosen title alias.
// 3. Id column (`get_id`) — covers `nid` and other id
// aliases when the user explicitly indexes the id.
let maybe_str: Option<String> = if use_slot_path {
col_store
.and_then(|cs| cs.get_str_by_slot(nslot.row_id, schema_slot.unwrap()))
.map(str::to_string)
} else if let Some(cs) = col_store {
// Not in schema — try title, then id. If both return a
// non-empty String, prefer title (which is what users
// typically mean when aliasing `label` / `name` / ...).
let from_title = cs.get_title(nslot.row_id).and_then(|v| match v {
Value::String(s) if !s.is_empty() => Some(s),
_ => None,
});
if from_title.is_some() {
from_title
} else {
cs.get_id(nslot.row_id).and_then(|v| match v {
Value::String(s) if !s.is_empty() => Some(s),
_ => None,
})
}
} else {
None
};
if let Some(s) = maybe_str {
entries.push((s, i as u32));
}
}
let count = entries.len();
// Evict the cached index before rebuilding it. `PropertyIndex::build`
// truncates the same `keys`/`offsets`/`ids` files that a cached entry
// still has memory-mapped, and Windows refuses to re-create a mapped
// file (`ERROR_USER_MAPPED_FILE`). Removing the key (rather than
// storing `None`, which means "no such index") lets a concurrent
// lookup fall back to opening the bundle from disk. A legacy-value
// mask remains authoritative until the replacement is published.
self.property_indexes.write().unwrap().remove(&index_key);
property_index_build_failpoint("typed")?;
let idx = property_index::PropertyIndex::build(
self.active_write_dir(),
node_type,
property,
entries,
)?;
self.property_indexes
.write()
.unwrap()
.insert(index_key.clone(), Some(Arc::new(idx)));
self.index_freshness
.mark_typed_built(index_key.clone(), node_bound as u32);
self.removed_property_indexes.remove(&index_key);
self.legacy_invalidated_property_indexes.remove(&index_key);
Ok(count)
}
/// Drop a typed persistent index in the writer overlay. The selected
/// generation remains immutable; the next save omits its bundle.
pub fn drop_property_index(
&mut self,
node_type: &str,
property: &str,
) -> std::io::Result<bool> {
self.prepare_mutation()?;
let existed = self.has_property_index(node_type, property);
if !existed {
return Ok(false);
}
self.removed_property_indexes
.insert((node_type.to_string(), property.to_string()));
self.property_indexes
.write()
.unwrap()
.insert((node_type.to_string(), property.to_string()), None);
self.index_freshness
.forget_typed(&(node_type.to_string(), property.to_string()));
property_index::PropertyIndex::remove_files(self.active_write_dir(), node_type, property)?;
Ok(true)
}
/// The typed bundle serving `(node_type, property)`, or `None` when there
/// is none — cache first, then the filesystem, caching whichever answer it
/// finds so a repeat miss does not stat again.
///
/// Says nothing about freshness: [`Self::serving_property_index`] is what a
/// lookup asks.
fn cached_property_index(
&self,
key: &(String, String),
) -> Option<Arc<property_index::PropertyIndex>> {
{
let read = self.property_indexes.read().unwrap();
if let Some(slot) = read.get(key) {
return slot.clone();
}
}
let opened = property_index::PropertyIndex::open(&self.data_dir, &key.0, &key.1)
.ok()
.flatten()
.map(Arc::new);
self.property_indexes
.write()
.unwrap()
.insert(key.clone(), opened.clone());
opened
}
/// The typed bundle for `(node_type, property)`, **only if it still covers
/// the graph**.
///
/// `None` means *unknown* — go scan — and covers three cases a caller must
/// not distinguish: no bundle, a masked legacy bundle, and a bundle the
/// graph has moved under. Nothing maintains an mmap bundle, so the third is
/// as unanswerable as the first: returning `Some(hits)` from it reports
/// "no such row" for every row written since the build (deep-scan item 3).
fn serving_property_index(
&self,
node_type: &str,
property: &str,
) -> Option<Arc<property_index::PropertyIndex>> {
let key = (node_type.to_string(), property.to_string());
if self.legacy_invalidated_property_indexes.contains(&key) {
return None;
}
let index = self.cached_property_index(&key)?;
self.index_freshness
.typed_is_fresh(&key, self.node_slot_len() as u32)
.then_some(index)
}
/// Exact-match lookup. Returns `None` when no index can answer for
/// `(node_type, property)` — none built, or one the graph has moved under
/// since it was — and `Some(Vec)` (possibly empty) from an index that
/// provably covers every live row. The planner uses the distinction to
/// decide whether to route through the fast path or fall back to scan.
pub fn lookup_property_eq(
&self,
node_type: &str,
property: &str,
value: &str,
) -> Option<Vec<NodeIndex>> {
Some(
self.serving_property_index(node_type, property)?
.lookup_eq_str(value),
)
}
/// Prefix lookup (STARTS WITH). Same `None`/`Some` semantics as
/// [`lookup_property_eq`].
pub fn lookup_property_prefix(
&self,
node_type: &str,
property: &str,
prefix: &str,
limit: usize,
) -> Option<Vec<NodeIndex>> {
Some(
self.serving_property_index(node_type, property)?
.lookup_prefix_str(prefix, limit),
)
}
/// Whether an index has been built for `(node_type, property)`.
/// Checks the cache first, then the filesystem.
pub fn has_property_index(&self, node_type: &str, property: &str) -> bool {
let key = (node_type.to_string(), property.to_string());
if self.legacy_invalidated_property_indexes.contains(&key) {
return false;
}
if let Some(slot) = self.property_indexes.read().unwrap().get(&key) {
return slot.is_some();
}
property_index::PropertyIndex::open(&self.data_dir, node_type, property)
.ok()
.flatten()
.is_some()
}
/// Build a cross-type global index for `property`. Scans every
/// alive `DiskNodeSlot` and emits one `(string_value, NodeIndex)`
/// entry per node where `property` resolves to a non-empty string
/// (regular column, title alias, or id alias — same resolution
/// order as [`build_property_index`]).
///
/// Powers untyped patterns like `MATCH (n {label: 'X'})` and the
/// `search(text)` helper. Re-run whenever the graph is rebuilt.
pub fn build_global_property_index(&mut self, property: &str) -> std::io::Result<usize> {
self.prepare_mutation()?;
let prop_key = InternedKey::from_str(property);
let node_bound = self.node_slot_len();
let mut entries: Vec<(String, u32)> = Vec::with_capacity(node_bound / 2);
// Cache per-type (column_store, schema_slot) lookups so every
// node in the same type reuses the slot resolution.
type ColStore = Arc<crate::graph::storage::column_store::ColumnStore>;
type TypeCacheEntry = Option<(ColStore, Option<u16>)>;
let mut type_cache: HashMap<u64, TypeCacheEntry> = HashMap::new();
for i in 0..node_bound {
let nslot = self.node_slot(i);
if !nslot.is_alive() {
continue;
}
let cached = type_cache.entry(nslot.node_type).or_insert_with(|| {
let tk = InternedKey::from_u64(nslot.node_type);
self.column_stores.get(&tk).cloned().map(|cs| {
let slot = cs.schema().slot(prop_key);
(cs, slot)
})
});
let Some((col_store, schema_slot)) = cached else {
continue;
};
let maybe_str: Option<String> = if let Some(slot) = schema_slot {
col_store
.get_str_by_slot(nslot.row_id, *slot)
.map(str::to_string)
} else {
let from_title = col_store.get_title(nslot.row_id).and_then(|v| match v {
Value::String(s) if !s.is_empty() => Some(s),
_ => None,
});
from_title.or_else(|| {
col_store.get_id(nslot.row_id).and_then(|v| match v {
Value::String(s) if !s.is_empty() => Some(s),
_ => None,
})
})
};
if let Some(s) = maybe_str {
if !s.is_empty() {
entries.push((s, i as u32));
}
}
}
let count = entries.len();
// Same rebuild-over-a-live-mapping hazard as `build_property_index`:
// release the cached bundle before `build_global` truncates the files
// it maps. `save_disk` rebuilds the `title` and `nid` global indexes on
// every save, so on Windows the second save of a graph would otherwise
// fail here. A legacy-value mask remains authoritative until the
// replacement is published.
self.global_indexes.write().unwrap().remove(property);
property_index_build_failpoint("global")?;
let idx = property_index::PropertyIndex::build_global(
self.active_write_dir(),
property,
entries,
)?;
self.global_indexes
.write()
.unwrap()
.insert(property.to_string(), Some(Arc::new(idx)));
self.index_freshness
.mark_global_built(property, node_bound as u32);
self.legacy_invalidated_global_indexes.remove(property);
Ok(count)
}
/// The global bundle for `property`, cache first then filesystem.
/// Freshness is [`Self::serving_global_index`]'s question.
fn cached_global_index(&self, property: &str) -> Option<Arc<property_index::PropertyIndex>> {
{
let read = self.global_indexes.read().unwrap();
if let Some(slot) = read.get(property) {
return slot.clone();
}
}
let opened = property_index::PropertyIndex::open_global(&self.data_dir, property)
.ok()
.flatten()
.map(Arc::new);
self.global_indexes
.write()
.unwrap()
.insert(property.to_string(), opened.clone());
opened
}
/// The global bundle for `property`, only if it still covers the graph.
///
/// Every disk `save()` auto-builds the `title` and `nid` globals, so this
/// gate is what a graph gets for free: without it, a save+load armed a
/// bundle that answered "no such node" for everything ingested since the
/// load (deep-scan item 2).
fn serving_global_index(&self, property: &str) -> Option<Arc<property_index::PropertyIndex>> {
if self.legacy_invalidated_global_indexes.contains(property) {
return None;
}
let index = self.cached_global_index(property)?;
self.index_freshness
.global_is_fresh(property, self.node_slot_len() as u32)
.then_some(index)
}
/// Exact-match lookup across every node type for a cross-type
/// global index. Same `None` = *unknown* contract as
/// [`lookup_property_eq`].
pub fn lookup_global_eq(&self, property: &str, value: &str) -> Option<Vec<NodeIndex>> {
Some(self.serving_global_index(property)?.lookup_eq_str(value))
}
/// Prefix lookup (STARTS WITH) against the cross-type global
/// index. Same `None`/`Some` semantics as [`lookup_global_eq`].
pub fn lookup_global_prefix(
&self,
property: &str,
prefix: &str,
limit: usize,
) -> Option<Vec<NodeIndex>> {
Some(
self.serving_global_index(property)?
.lookup_prefix_str(prefix, limit),
)
}
/// Whether this graph has any persistent bundle whose freshness has to be
/// tracked — the whole cost the write path pays when it does not.
#[inline]
pub(crate) fn tracks_index_freshness(&self) -> bool {
self.index_freshness.tracks_anything(&self.data_dir)
}
/// A node of `node_type` was created at `slot`.
#[inline]
pub(crate) fn note_index_node_created(&self, slot: u32, node_type: &str) {
self.index_freshness.note_created(slot, node_type);
}
/// A property of the node at `slot` was written; `None` for a caller that
/// did not resolve the node's type.
#[inline]
pub(crate) fn note_index_property_written(&self, slot: u32, node_type: Option<&str>) {
self.index_freshness.note_property_written(slot, node_type);
}
/// The node at `slot` was removed.
#[inline]
pub(crate) fn note_index_node_removed(&self, slot: u32) {
self.index_freshness.note_removed(slot);
}
/// Whether the typed bundle for `(node_type, property)` is currently
/// serving lookups — it exists *and* still covers the graph.
///
/// Distinct from [`Self::has_property_index`], which answers "was one
/// built?". Introspection needs both: `DROP INDEX` acts on existence,
/// while a `describe()` hint that names an index a query will not use is
/// an agent-facing claim the engine contradicts.
pub(crate) fn property_index_is_serving(&self, node_type: &str, property: &str) -> bool {
self.serving_property_index(node_type, property).is_some()
}
/// Whether a global bundle for `property` exists but is refusing to
/// answer because the graph has moved under it.
///
/// The distinction a plain `None` from [`Self::lookup_global_eq`] cannot
/// carry: "no index here, that answer is as good as it gets" versus "there
/// is an index and it cannot be trusted". Only the second is worth a scan.
pub(crate) fn global_index_is_declining(&self, property: &str) -> bool {
if self.legacy_invalidated_global_indexes.contains(property) {
return false;
}
self.cached_global_index(property).is_some()
&& !self
.index_freshness
.global_is_fresh(property, self.node_slot_len() as u32)
}
/// Every persistent bundle reachable from this graph, as
/// `(typed pairs, global properties)`.
///
/// Unions the published generation with the writer workspaces, exactly the
/// set [`Self::copy_persisted_indexes`] would carry into the next
/// generation, plus whatever the caches have opened. Legacy-named bundles
/// are invisible to the scanners by design (their filenames destroyed the
/// identity), so a rebuild cannot reach them — they stay masked by the
/// freshness gate instead, which is the correct answer for a bundle whose
/// key nothing can reconstruct.
pub(crate) fn persisted_index_names(&self) -> (Vec<(String, String)>, Vec<String>) {
let mut typed: std::collections::BTreeSet<(String, String)> = self
.property_indexes
.read()
.unwrap()
.iter()
.filter(|(_, slot)| slot.is_some())
.map(|(key, _)| key.clone())
.collect();
let mut global: std::collections::BTreeSet<String> = self
.global_indexes
.read()
.unwrap()
.iter()
.filter(|(_, slot)| slot.is_some())
.map(|(property, _)| property.clone())
.collect();
let mut dirs = vec![self.data_dir.clone()];
dirs.extend(
self.parent_workspaces
.iter()
.map(|workspace| workspace.segment_dir().to_path_buf()),
);
if let Some(workspace) = &self.mutation_workspace {
dirs.push(workspace.segment_dir().to_path_buf());
}
for dir in dirs {
typed.extend(property_index::scan_data_dir(&dir).unwrap_or_default());
global.extend(property_index::scan_global_data_dir(&dir).unwrap_or_default());
}
typed.retain(|key| {
!self.removed_property_indexes.contains(key)
&& !self.legacy_invalidated_property_indexes.contains(key)
});
global.retain(|property| !self.legacy_invalidated_global_indexes.contains(property));
(typed.into_iter().collect(), global.into_iter().collect())
}
/// Rebuild every persistent bundle the graph has moved under, so the next
/// lookup can serve from it again.
///
/// `force` rebuilds even a bundle that is already current — what
/// `reindex()` means. Without it, only the stale ones are rewritten, which
/// is what keeps an unmutated `save()` at its previous cost.
///
/// Callers: [`DirGraph::reindex`] and the pre-save consolidation. Both run
/// under `&mut`, which is the reason the read path can only decline: a
/// rebuild writes four files per bundle.
pub(crate) fn refresh_persistent_indexes(&mut self, force: bool) -> std::io::Result<usize> {
let node_bound = self.node_slot_len() as u32;
let (typed, global) = self.persisted_index_names();
let mut rebuilt = 0;
for (node_type, property) in typed {
let key = (node_type.clone(), property.clone());
if !force && self.index_freshness.typed_is_fresh(&key, node_bound) {
continue;
}
self.build_property_index(&node_type, &property)?;
rebuilt += 1;
}
for property in global {
if !force && self.index_freshness.global_is_fresh(&property, node_bound) {
continue;
}
self.build_global_property_index(&property)?;
rebuilt += 1;
}
// The baseline is deliberately left where it was. It stands in for
// bundles this pass could *not* reach — the legacy-named ones, whose
// filenames destroyed their identity — and those are still as stale as
// they were, so a lookup that discovers one later must still decline.
Ok(rebuilt)
}
/// Mask persisted lookup bundles that were built from raw legacy values.
/// The immutable selected generation is untouched; a later explicit index
/// build clears the matching mask after rebuilding against normalized data.
pub(crate) fn invalidate_legacy_value_indexes(
&mut self,
typed: impl IntoIterator<Item = (String, String)>,
global: impl IntoIterator<Item = String>,
) {
for key in typed {
self.property_indexes
.write()
.unwrap()
.insert(key.clone(), None);
self.legacy_invalidated_property_indexes.insert(key);
}
for property in global {
self.global_indexes
.write()
.unwrap()
.insert(property.clone(), None);
self.legacy_invalidated_global_indexes.insert(property);
}
}
}