Skip to content

feat(graph): engine overhaul — freeze-boundary correctness + P1-P3 optimization waves#231

Merged
pilotspacex-byte merged 17 commits into
mainfrom
feat/graph-engine-overhaul
Jul 7, 2026
Merged

feat(graph): engine overhaul — freeze-boundary correctness + P1-P3 optimization waves#231
pilotspacex-byte merged 17 commits into
mainfrom
feat/graph-engine-overhaul

Conversation

@pilotspacex-byte

@pilotspacex-byte pilotspacex-byte commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Graph engine overhaul — deep-review fixes + optimization waves (P0–P3)

Implements the full roadmap from tmp/GRAPH-DEEP-REVIEW-2026-07.md (12 commits, red/green TDD throughout).

P0 — freeze-boundary correctness (data loss fixes)

  • Red suite first: tests/graph_freeze_boundary.rs (11 tests) pins the mutable→frozen lifecycle; every P0 fix turned one red.
  • CSR v5: segments persist node properties, embeddings, and edge weights/properties across freeze (previously silently dropped).
  • MergedNodeView: Cypher NodeScan/eval/IndexScan see the frozen tier; NodeKey collision class (fresh-slotmap aliasing) fixed via external_id validation.
  • Cross-tier delta edges, GRAPH.NEIGHBORS existence + direction fixes, GRAPH.HYBRID/VSEARCH across both tiers.

P1 — point-query indexes

  • Lazy per-segment property indexes (freeze-time data, OnceLock, built at first use).
  • PhysicalOp::IndexScan in planner + executor: MATCH (n:L {p: v}) narrows instead of label-scanning.

P2 — query-path cost

  • Plan cache actually hits now: token-level auto-parameterization (literal variants share one normalized plan; $__pN params re-bound per run) + LRU eviction (1024/graph). A cache hit runs with ZERO parse/compile. Single-parse dispatch (token-scan write detection).
  • Slot-indexed executor rows: per-row HashMap + String-key clones replaced by SmallVec slots against a per-plan SlotTable.
  • Allocation cleanups: ADDNODE double WAL-encode deleted; itoa/ryu statement temporaries in wal.rs; FxHash for slotmap-keyed sets; Dijkstra 3-maps→1; allocation-free neighbors_into in both executors' Expand.

P3 — traversal engine

  • CSR row-space BFS fast path (src/graph/row_bfs.rs): on fully-frozen graphs — dense AtomicU64 visited bitmap, TRUE parallel frontier levels (thread::scope over Send+Sync CsrStorage, ≤8 workers), direction-optimizing Beamer push/pull (α=14/β=24) over the incoming index. Gate: anything the reader path handles generically falls back — never a behavior fork. Parity-tested against a hand-rolled reader-path oracle.
  • TraversalGuard in Cypher: 30s wall-clock budget checked per hop in variable-length Expand and per ShortestPath row (ExecErrorKind::Timeout); None guard = old behavior.
  • Hybrid HnswPreFilter is real (src/graph/hnsw_bridge.rs): was a silent stub (strategy selected, brute force always ran). Now a lazy per-segment HNSW (vector engine's HnswBuilder, raw-f32 cosine, ≥4096 embeddings gate) with exact-scoring fallback whenever the approximate beam under-fills — never silently truncates.

⚠ Requires approval: 4 new unsafe expressions

src/graph/csr/mmap.rs (commit 67e80aa, CSR v5 property blobs): two base.add(ppos) pointer derivations + two slice::from_raw_parts accessors for the node/edge property blobs — the exact pattern of the existing v3 mmap array accessors, bounds validated at load, SAFETY comments present, scripts/audit-unsafe.sh green.

File-size cap note

csr/mod.rs (2104) and traversal.rs (1815) exceed 1500 raw lines but are under 1000 lines of non-test code (tests stay in mod.rs per convention). graph_read.rs (1789) is a command-group file already read/write-split. All three were over the raw cap on main before this branch; left unsplit to keep the review diff clean.

Verification

  • lib 3821 green; graph_freeze_boundary 11/11; full default + tokio suites green (both runtimes).
  • Pre-existing (fails identically on main, same feature set): shard::scatter_aggregate::tests::test_scatter_text_aggregate_single_shard_skips_spsc under runtime-tokio,jemalloc,graph,text-index — "ShardSlice not initialized on this thread". CI's tokio job runs without graph,text-index so it never sees this; not touched by this branch.
  • clippy -D warnings both feature sets; cargo fmt --check; unsafe audit green.
  • Criterion probe (macOS dev numbers, quiet machine, graph_bench + graph_traversal vs main baseline):
    • graph_addedge_command −23%; bfs_memgraph/bfs_csr 2-hop expansions −1.5–2%; everything else statistically unchanged (BFS 10k-depth3 both variants, NEIGHBORS, freeze, cosine kernels).
    • graph_addnode_command reads +18% (~9 ns on a 60 ns loop) but MemGraph::add_node is byte-identical on this branch and the raw graph_edge_insert bench (same file) is unchanged — thin-LTO code-layout shift, not an algorithmic regression. (A first, contended run of the same pair read −43%; the bench is layout/noise-sensitive.)
    • The row-space BFS fast path does not appear in these benches (they build mutable-tier-only graphs, so the frozen-single-segment gate never engages); its win is covered by the parity tests + design doc.

Summary by CodeRabbit

  • New Features
    • Enhanced graph querying with smarter plan caching and improved literal handling for faster repeated queries.
    • Cross-tier operations now support frozen CSR data alongside live updates, including vector/hybrid search.
  • Bug Fixes
    • Fixed missing node/edge properties, embeddings, and weights after compaction/freeze by persisting CSR v5 property blobs.
    • Improved traversal accuracy across frozen tiers, including neighbor direction parsing for GRAPH.NEIGHBORS and more reliable timeout enforcement for long-running paths.

@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds CSR v5 property/embedding storage, cross-tier freeze-boundary reads and writes, lazy per-segment indexing with plan-cache normalization, slot-based Cypher execution with timeout guards, HNSW-backed hybrid search, traversal fast paths, and updated tests, fuzzing, and changelog entries.

Changes

Graph engine correctness and performance overhaul

Layer / File(s) Summary
CSR v5 property/embedding codec
src/graph/csr/props.rs, src/graph/csr/mod.rs, src/graph/csr/mmap.rs, src/graph/csr/storage.rs, src/graph/index.rs, src/graph/types.rs
Adds the v5 blob codec, persisted node/edge property accessors, lazy segment property indexes, and CSR version bump.
Compaction property preservation
src/graph/compaction.rs
Copies node and edge property records into merged CSR blobs and rebuilds node-key mappings during compaction.
Freeze-boundary cross-tier support
src/graph/memgraph.rs, src/graph/view.rs, src/graph/store.rs, src/graph/visibility.rs, src/graph/replay.rs, src/graph/mod.rs
Adds ghost adjacency, cross-tier edge insertion, merged-node reads, in-place thawing, recovery/replay alias handling, and related module exports.
Write command updates
src/command/graph/graph_write.rs, src/command/graph/graph_read.rs
Updates GRAPH.ADDNODE, GRAPH.ADDEDGE, and GRAPH.NEIGHBORS for the frozen tier and direction-aware neighbor reads.
IndexScan planning
src/graph/cypher/planner.rs, src/graph/cypher/executor/read.rs, src/graph/cypher/executor/write.rs, src/graph/index.rs
Adds IndexScan, per-segment property indexes, and shared execution paths for indexed scans.
Query normalization and plan-cache flow
src/graph/cypher/parameterize.rs, src/command/graph/graph_read.rs, src/command/graph/mod.rs
Adds literal auto-parameterization, normalized plan-cache lookup/insert paths, token-scan write detection, and plan-cache tests.
Executor slot-based rows
src/graph/cypher/executor/mod.rs, src/graph/cypher/executor/eval.rs, src/graph/cypher/executor/read.rs, src/graph/cypher/executor/write.rs, src/graph/traversal_guard.rs
Replaces Row with slot-based bindings, adds traversal timeout errors/guards, and updates read/write execution around the merged view.
HNSW bridge and HYBRID tier-spanning search
src/graph/hnsw_bridge.rs, src/graph/hybrid.rs, src/command/graph/graph_read.rs, src/graph/csr/storage.rs
Adds lazy per-segment HNSW search and updates HYBRID/VSEARCH to operate across mutable and frozen tiers.
Traversal performance and WAL allocations
src/graph/fasthash.rs, src/graph/row_bfs.rs, src/graph/traversal.rs, src/graph/wal.rs
Adds FxHash collections, a CSR row-space BFS fast path, and lower-allocation WAL serialization.
Tests and CI/fuzz/changelog
tests/graph_freeze_boundary.rs, .github/workflows/fuzz.yml, fuzz/Cargo.toml, .gitignore, CHANGELOG.md
Adds freeze-boundary integration tests, a new fuzz target, ignore rules, and changelog notes.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant GraphQuery
  participant PlanCache
  participant Executor
  participant MergedNodeView
  participant MemGraph
  participant CsrStorage

  Client->>GraphQuery: GRAPH.QUERY(cypher)
  GraphQuery->>GraphQuery: normalize_cypher / parameterize
  GraphQuery->>PlanCache: lookup normalized hash
  alt cache hit
    PlanCache-->>GraphQuery: cached PhysicalPlan
  else cache miss
    GraphQuery->>GraphQuery: parse_effective + compile plan
    GraphQuery->>PlanCache: insert plan
  end
  GraphQuery->>Executor: run_read_query(plan, params)
  Executor->>MergedNodeView: is_visible / property / labels
  MergedNodeView->>MemGraph: check mutable tier
  MergedNodeView->>CsrStorage: check frozen segments
  Executor-->>GraphQuery: result rows
  GraphQuery-->>Client: response
Loading
sequenceDiagram
  participant HybridEngine
  participant MergedNodeView
  participant GraphHnsw
  participant SegmentMergeReader
  participant CsrStorage

  HybridEngine->>MergedNodeView: validate start node / embedding
  HybridEngine->>SegmentMergeReader: bfs_collect across tiers
  SegmentMergeReader-->>HybridEngine: candidate nodes
  HybridEngine->>CsrStorage: hnsw_bridge() lazily built
  HybridEngine->>GraphHnsw: search(query, k, allow)
  GraphHnsw-->>HybridEngine: approximate scored rows
  HybridEngine->>MergedNodeView: exact-score residual candidates
  HybridEngine-->>HybridEngine: merge + rank top-k results
Loading

Possibly related PRs

  • pilotspace/moon#70: Touches the same graph subsystem areas, including CSR format, query execution, and graph command handling.
  • pilotspace/moon#97: Also modifies src/command/graph/graph_write.rs, especially graph_addnode/graph_addedge.

Suggested labels: ci-fuzz

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is detailed, but it does not follow the required Summary/Checklist/Performance Impact/Notes template. Add the required headings and include checklist statuses, performance impact, and reviewer notes in the template format.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the PR’s main graph-engine overhaul and highlights freeze-boundary fixes and optimization work.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/graph-engine-overhaul

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/graph/memgraph.rs (1)

34-39: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider FxHashMap for ghost adjacency.

ghost_out/ghost_in are keyed by NodeKey (a slotmap key) — exactly the "graph-internal keys" profile fasthash.rs (added in this same PR) documents FxHashMap/FxHashSet for. Using std::collections::HashMap here means SipHash overhead on every cross-tier edge insert/freeze rebuild for a key type that doesn't need DoS resistance.

♻️ Suggested change
-    ghost_out: std::collections::HashMap<NodeKey, SmallVec<[EdgeKey; 4]>>,
-    ghost_in: std::collections::HashMap<NodeKey, SmallVec<[EdgeKey; 4]>>,
+    ghost_out: crate::graph::fasthash::FxHashMap<NodeKey, SmallVec<[EdgeKey; 4]>>,
+    ghost_in: crate::graph::fasthash::FxHashMap<NodeKey, SmallVec<[EdgeKey; 4]>>,
-            ghost_out: std::collections::HashMap::new(),
-            ghost_in: std::collections::HashMap::new(),
+            ghost_out: crate::graph::fasthash::FxHashMap::default(),
+            ghost_in: crate::graph::fasthash::FxHashMap::default(),

Also applies to: 55-56

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/graph/memgraph.rs` around lines 34 - 39, The ghost adjacency maps in
MemGraph should use the faster internal-key hash map type instead of
std::collections::HashMap. Update the ghost_out and ghost_in fields in MemGraph
to use FxHashMap (matching the guidance in fasthash.rs for graph-internal keys
like NodeKey), and make the corresponding adjustment wherever these maps are
rebuilt or initialized so the same type is used consistently.
src/graph/fasthash.rs (1)

1-105: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider the rustc-hash crate instead of a hand-rolled FxHash.

The reimplementation here faithfully matches the legacy FxHash algorithm, but upstream rustc-hash (2.x) replaced this exact algorithm with a newer polynomial hash + bit-rotation finisher (by Orson Peters) specifically to fix known weaknesses in the old design (poor string/slice hashing, high-bit information loss). The doc comment's justification ("to avoid a new dependency") is reasonable, but rustc-hash is a tiny, zero-dependency, widely-vetted crate with the same FxHashMap/FxHashSet/FxBuildHasher API surface, so swapping would get a better-tested and improved hash for free with a near drop-in change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/graph/fasthash.rs` around lines 1 - 105, Replace the hand-rolled hashing
in FxHasher/FxBuildHasher with the upstream rustc-hash crate, since it already
provides the same FxHashMap/FxHashSet/FxBuildHasher API and a better-tested
newer hash implementation. Update the graph/fasthash module to re-export or
alias the crate types instead of maintaining the custom Hasher/write_* logic,
and keep the tests focused on the public map/set behavior rather than the
internal algorithm.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/graph/csr/props.rs`:
- Around line 81-119: The record offset stored by encode_node_record and
encode_edge_record is being truncated to u32, which can wrap once the
props/embedding blob grows past 4 GiB. Update these writers (and the related
copy_record path) to either use a larger offset type or explicitly validate pos
before casting, and make freeze/compaction fail early if the blob would overflow
u32. Keep the behavior consistent across encode_node_record, encode_edge_record,
and any code that persists property_offset so decoders never see a wrapped
offset.

In `@src/graph/cypher/executor/mod.rs`:
- Around line 194-201: Row::iter currently returns every plan-bound name in slot
order, which causes later unbound variables to be projected as Null in
Expr::Star cases like RETURN * and WITH *. Update Row::iter in Row to skip
unbound slots by filtering against the row’s bound state, or make the iterator
sparse so only in-scope bindings are yielded; then ensure Expr::Star uses that
bounded iteration path when expanding projections.

In `@src/graph/row_bfs.rs`:
- Around line 288-293: The worker-join handling in row_bfs::expand_push and the
matching pattern in expand_pull is swallowing panics by only collecting Ok
results, which can return an incomplete BfsResult as if traversal succeeded.
Update the join handling to propagate any JoinHandle panic immediately instead
of skipping it, so a panicking expand_chunk worker causes the overall BFS to
fail loudly rather than silently losing discoveries.

---

Nitpick comments:
In `@src/graph/fasthash.rs`:
- Around line 1-105: Replace the hand-rolled hashing in FxHasher/FxBuildHasher
with the upstream rustc-hash crate, since it already provides the same
FxHashMap/FxHashSet/FxBuildHasher API and a better-tested newer hash
implementation. Update the graph/fasthash module to re-export or alias the crate
types instead of maintaining the custom Hasher/write_* logic, and keep the tests
focused on the public map/set behavior rather than the internal algorithm.

In `@src/graph/memgraph.rs`:
- Around line 34-39: The ghost adjacency maps in MemGraph should use the faster
internal-key hash map type instead of std::collections::HashMap. Update the
ghost_out and ghost_in fields in MemGraph to use FxHashMap (matching the
guidance in fasthash.rs for graph-internal keys like NodeKey), and make the
corresponding adjustment wherever these maps are rebuilt or initialized so the
same type is used consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 81f09bef-cbed-4ac9-a9e9-e073fbe93b41

📥 Commits

Reviewing files that changed from the base of the PR and between 597ff59 and 4fc5190.

📒 Files selected for processing (35)
  • .github/workflows/fuzz.yml
  • .gitignore
  • CHANGELOG.md
  • fuzz/Cargo.toml
  • src/command/graph/graph_read.rs
  • src/command/graph/graph_write.rs
  • src/command/graph/mod.rs
  • src/graph/compaction.rs
  • src/graph/csr/mmap.rs
  • src/graph/csr/mod.rs
  • src/graph/csr/props.rs
  • src/graph/csr/storage.rs
  • src/graph/cypher/executor/eval.rs
  • src/graph/cypher/executor/mod.rs
  • src/graph/cypher/executor/read.rs
  • src/graph/cypher/executor/write.rs
  • src/graph/cypher/mod.rs
  • src/graph/cypher/parameterize.rs
  • src/graph/cypher/planner.rs
  • src/graph/fasthash.rs
  • src/graph/hnsw_bridge.rs
  • src/graph/hybrid.rs
  • src/graph/index.rs
  • src/graph/memgraph.rs
  • src/graph/mod.rs
  • src/graph/replay.rs
  • src/graph/row_bfs.rs
  • src/graph/store.rs
  • src/graph/traversal.rs
  • src/graph/traversal_guard.rs
  • src/graph/types.rs
  • src/graph/view.rs
  • src/graph/visibility.rs
  • src/graph/wal.rs
  • tests/graph_freeze_boundary.rs

Comment thread src/graph/csr/props.rs
Comment on lines +81 to +119
pub fn encode_node_record(
blob: &mut Vec<u8>,
props: &[(u16, PropertyValue)],
embedding: Option<&[f32]>,
) -> u32 {
if props.is_empty() && embedding.is_none() {
return 0;
}
let pos = blob.len();
encode_props(blob, props);
match embedding {
Some(emb) => {
blob.extend_from_slice(&(emb.len() as u32).to_le_bytes());
for f in emb {
blob.extend_from_slice(&f.to_le_bytes());
}
}
None => blob.extend_from_slice(&0u32.to_le_bytes()),
}
(pos as u32) + 1
}

/// Append an edge record (weight + optional properties) to `blob`.
///
/// Returns the stored offset (`byte_pos + 1`), or `0` when the edge carries
/// the default weight and no properties (nothing is written).
pub fn encode_edge_record(blob: &mut Vec<u8>, weight: f64, props: Option<&PropertyMap>) -> u32 {
let has_props = props.is_some_and(|p| !p.is_empty());
if weight == DEFAULT_EDGE_WEIGHT && !has_props {
return 0;
}
let pos = blob.len();
blob.extend_from_slice(&weight.to_le_bytes());
match props {
Some(p) => encode_props(blob, p),
None => blob.extend_from_slice(&0u16.to_le_bytes()),
}
(pos as u32) + 1
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for any segment/blob size cap that would bound node_props/edge_props length.
rg -nP 'max_segment|segment.*cap|property_offset|u32::MAX' src/graph --type=rust -C2

Repository: pilotspace/moon

Length of output: 23902


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the codec and compaction paths around property offsets and blob sizing.
sed -n '1,260p' src/graph/csr/props.rs
printf '\n---\n'
sed -n '1,260p' src/graph/csr/mod.rs
printf '\n---\n'
sed -n '340,500p' src/graph/compaction.rs
printf '\n---\n'
rg -n "u32::MAX|max_segment_edges|max_segments|property_offset|blob.len\(|as u32\)\s*\+\s*1|copy_record\(" src/graph/csr src/graph/compaction.rs

Repository: pilotspace/moon

Length of output: 32586


property_offset must not be truncated to u32. encode_node_record, encode_edge_record, and copy_record all store (pos as u32) + 1, and there’s no blob-size cap in the CSR/compaction path. Once a node_props or edge_props blob exceeds 4 GiB, the offset wraps and decoders read the wrong record, silently corrupting properties/embeddings. Guard the cast or fail freeze/compaction before the blob can overflow u32.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/graph/csr/props.rs` around lines 81 - 119, The record offset stored by
encode_node_record and encode_edge_record is being truncated to u32, which can
wrap once the props/embedding blob grows past 4 GiB. Update these writers (and
the related copy_record path) to either use a larger offset type or explicitly
validate pos before casting, and make freeze/compaction fail early if the blob
would overflow u32. Keep the behavior consistent across encode_node_record,
encode_edge_record, and any code that persists property_offset so decoders never
see a wrapped offset.

Comment on lines +194 to +201
/// Iterate (name, value) pairs in slot order.
pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
self.table
.names
.iter()
.map(String::as_str)
.zip(self.slots.iter())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all Star / RETURN * / WITH * consumers of Row::iter to gauge column impact.
rg -nP -C3 'Expr::Star|row\.iter\(\)|\.iter\(\)\.map\(\|\(k, v\)\|' src/graph/cypher

Repository: pilotspace/moon

Length of output: 5926


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the row/slot-table implementation and the star projection consumers.
sed -n '1,260p' src/graph/cypher/executor/mod.rs
printf '\n==== eval.rs slice ====\n'
sed -n '390,430p' src/graph/cypher/executor/eval.rs
printf '\n==== read.rs slice 1 ====\n'
sed -n '390,430p' src/graph/cypher/executor/read.rs
printf '\n==== read.rs slice 2 ====\n'
sed -n '930,970p' src/graph/cypher/executor/read.rs
printf '\n==== write.rs slice ====\n'
sed -n '170,195p' src/graph/cypher/executor/write.rs
printf '\n==== tests around star/projection ====\n'
rg -n -C 3 'RETURN \*|WITH \*|Expr::Star|row\.iter\(\)' src/graph/cypher -g '!target'

Repository: pilotspace/moon

Length of output: 21316


Row::iter should skip unbound slots row.iter() now walks every plan-bound name, so RETURN * / WITH * can project later variables as Null before they’re in scope. Filter Expr::Star to bound bindings or make the iterator sparse.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/graph/cypher/executor/mod.rs` around lines 194 - 201, Row::iter currently
returns every plan-bound name in slot order, which causes later unbound
variables to be projected as Null in Expr::Star cases like RETURN * and WITH *.
Update Row::iter in Row to skip unbound slots by filtering against the row’s
bound state, or make the iterator sparse so only in-scope bindings are yielded;
then ensure Expr::Star uses that bounded iteration path when expanding
projections.

Comment thread src/graph/row_bfs.rs
Comment on lines +288 to +293
for h in handles {
// A worker panicking is a bug in expand_chunk itself; surface it.
if let Ok(out) = h.join() {
outs.push(out);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Propagate worker panics instead of swallowing them. The comment says a panicking worker should be surfaced, but if let Ok(out) = h.join() silently discards that chunk's discoveries — a panicked worker would produce an incomplete BfsResult returned as Ok, i.e. silently wrong traversal results rather than a crash. expand_pull (Lines 375-379) has the identical pattern.

🛡️ Re-raise the worker panic
         for h in handles {
-            // A worker panicking is a bug in expand_chunk itself; surface it.
-            if let Ok(out) = h.join() {
-                outs.push(out);
-            }
+            // A worker panicking is a bug in expand_chunk itself; surface it.
+            match h.join() {
+                Ok(out) => outs.push(out),
+                Err(e) => std::panic::resume_unwind(e),
+            }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for h in handles {
// A worker panicking is a bug in expand_chunk itself; surface it.
if let Ok(out) = h.join() {
outs.push(out);
}
}
for h in handles {
// A worker panicking is a bug in expand_chunk itself; surface it.
match h.join() {
Ok(out) => outs.push(out),
Err(e) => std::panic::resume_unwind(e),
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/graph/row_bfs.rs` around lines 288 - 293, The worker-join handling in
row_bfs::expand_push and the matching pattern in expand_pull is swallowing
panics by only collecting Ok results, which can return an incomplete BfsResult
as if traversal succeeded. Update the join handling to propagate any JoinHandle
panic immediately instead of skipping it, so a panicking expand_chunk worker
causes the overall BFS to fail loudly rather than silently losing discoveries.

TinDang97 added 17 commits July 7, 2026 11:09
…hase 0)

Adds tests/graph_freeze_boundary.rs driving the real GRAPH.* command
handlers with edge_threshold=8 so MemGraph::freeze_and_compact fires
after 8 inserts. Documents the P0 found in the 2026-07 graph deep
review (tmp/GRAPH-DEEP-REVIEW-2026-07.md): freeze() DRAINS the write
buffer and CSR segments carry no properties, so compacted data is
lost to queries.

Red (9 failing / 1 passing mechanism-sanity):
- MATCH label scan / property filter / RETURN prop lose frozen nodes
- 1-hop expand from a frozen point query returns 0 rows
- mixed-tier scan sees only the mutable tail
- GRAPH.NEIGHBORS rejects frozen nodes ("node not found")
- GRAPH.ADDEDGE between frozen endpoints fails (append-once flaw)
- GRAPH.PROFILE returns 0 rows post-freeze
- GRAPH.HYBRID loses compacted candidates

These go green across Phase 0 (property blob v5, MergedNodeView,
NEIGHBORS/PROFILE/HYBRID rewiring).

author: Tin Dang
…ross freeze

Fixes the freeze-boundary data-loss P0 (storage half): CsrSegment::from_frozen
wrote property_offset: 0 unconditionally, silently DISCARDING node properties,
embeddings, edge weights, and edge properties when a graph crossed
edge_threshold (64K edges).

- src/graph/csr/props.rs: self-contained record codec (tagged prop entries +
  embedding dim + edge weight), offset+1 scheme so 0 keeps meaning "absent".
  Bounds-checked decoders never panic; fuzz target graph_props_record added
  to fuzz/ and both CI fuzz matrices.
- Format v5: two trailing [len:u64][bytes] blob sections after label_overflow;
  header layout unchanged (positional parsing like v4). v1-v4 files load with
  empty blobs (graceful degradation). New unsafe: two mmap pointer-slice
  accessors following the module's existing validated-pattern (SAFETY
  comments; flagged for unsafe-policy review).
- from_frozen + compact_segments now encode/carry records; the merge copies
  each winner's record verbatim from its winning segment.
- Bonus fix: compact_segments left node_id_to_row + MPH EMPTY, so a freshly
  merged in-memory segment could not resolve any NodeKey (lookup_node → None)
  until persisted and reloaded. Rebuilt from external_id like from_bytes does.
- parse_label_overflow now returns its end offset (v5 sections follow it).

Tests: props codec unit suite (incl. truncation sweep), v5 roundtrip
(from_frozen / bytes / mmap), v4-compat parse, merged-segment carry-over +
NodeKey lookup, downgrade helpers fixed for v5 trailer. graph lib 424/424.
Read-side wiring (NodeScan/eval/NEIGHBORS/HYBRID) lands next — the
freeze-boundary integration suite stays red until then by design.

author: Tin Dang
…y collision fix

Phase 0 task 3 of the graph-engine overhaul: post-freeze read correctness
for Cypher MATCH / property eval, plus a newly discovered NodeKey aliasing
bug in the freeze path.

MergedNodeView (src/graph/view.rs): one seam over both tiers (mutable
MemGraph write buffer + immutable CSR segments) — segment_row / contains /
is_visible / property / properties / embedding / labels /
for_each_visible_node. Frozen rows resolve properties through the v5
property blob, labels through label_bitmap + overflow, visibility through
the new visibility::is_meta_visible (NodeMeta twin of is_node_visible,
txn_id = 0 since frozen segments hold only committed data).

Wired through:
- Cypher NodeScan (execute + execute_profile): scans both tiers via
  for_each_visible_node — frozen nodes were invisible to MATCH before
  (freeze DRAINS the memgraph; the read side assumed a copy).
- Expand target visibility: frozen targets now get the CSR NodeMeta
  MVCC/valid-time check instead of a silent free pass.
- eval_expr PropertyAccess + labels(): merged-tier lookup, so RETURN
  n.prop / WHERE n.prop = x / labels(n) work on compacted nodes.

NodeKey collision fix (P0, pre-existing): freeze_and_compact replaced
write_buf with a FRESH MemGraph. A fresh SlotMap restarts key allocation
at the same (index, generation) pairs, so the first node added after a
freeze received the same NodeKey ffi as the first frozen node — aliasing
new nodes onto frozen CSR rows in every merged lookup (SegmentMergeReader
included). Fixed with MemGraph::thaw(): re-arm the SAME drained slot maps
(drain bumps each slot's generation, guaranteeing fresh keys) and reset
live counters; freeze_and_compact now thaws in place on both the success
and CSR-build-failure paths.

Freeze-boundary integration suite: 6/10 green (was 1/10) — label scan,
property filter, property return, one-hop expand, mixed-tier scan.
Remaining 4 red are the next tasks: NEIGHBORS/ADDEDGE existence + PROFILE
Expand (task 4), GRAPH.HYBRID (task 5).

Tests: graph lib suite 429/429; new red/green coverage:
- store: test_freeze_and_compact_preserves_key_uniqueness (red on the
  fresh-MemGraph design, green with thaw)
- view: two-tier fixture tests (contains / property+embedding / labels /
  merged scan with label filter)

Also: /target-fast/ gitignored (release-fast iteration dir, matches the
target-* convention).

author: Tin Dang
… see frozen tier

Phase 0 task 4: the native command surface and WAL replay now work across
the freeze boundary, built on a new delta-edge mechanism in MemGraph.

Delta edges (memgraph.rs): an edge whose endpoint was frozen into a CSR
segment has no MutableNode to carry adjacency, and CsrSegment::from_frozen
silently drops edges without in-segment endpoint rows. New design:
- ghost_out / ghost_in maps hold adjacency keyed by frozen NodeKey;
  neighbors() serves them for non-resident nodes, so cross-tier edges are
  traversable from BOTH ends (SegmentMergeReader picks them up for free).
- add_edge_across_tiers(): validates resident endpoints alive; trusts the
  caller (MergedNodeView::is_visible) for frozen ones. Plain add_edge keeps
  rejecting unknown endpoints.
- freeze() now PARTITIONS edges: live + both endpoints resident → CSR;
  live + any earlier-frozen endpoint → retained in the mutable tier with
  its EdgeKey intact and ghost adjacency rebuilt; dead → dropped.
  freeze_and_compact skips empty freezes (all-delta buffer would otherwise
  churn empty segments) and thaw() recounts retained edges.

Fixed call sites (each silently lost or rejected data post-freeze):
- GRAPH.ADDEDGE: verifies frozen endpoints via MergedNodeView (alive in a
  segment), then inserts a delta edge — previously "ERR source or
  destination node not found".
- GRAPH.NEIGHBORS: existence check via the merged view (comment claimed
  "freeze copies, doesn't move" — it drains); new DIRECTION IN|OUT|BOTH
  argument is parsed and honored (was hardcoded Both).
- GRAPH.PROFILE Expand: now uses SegmentMergeReader + merged-view target
  visibility (parity with GRAPH.QUERY) — profiled expansions returned 0
  rows on frozen graphs.
- WAL replay AddEdge: node_map already seeded CSR-resident endpoints, but
  add_edge refused them, silently dropping every cross-segment edge during
  recovery; now inserts delta edges (node_map membership is the existence
  proof).

Freeze-boundary suite: 10/11 green (was 6/10; +new DIRECTION test). Last
red is GRAPH.HYBRID (next task). Graph lib 431/431, command::graph 25/25.

author: Tin Dang
…complete

Phase 0 task 5 (last freeze-boundary correctness gap): the hybrid
graph+vector executors (HYB-01 FILTER, HYB-02 EXPAND, HYB-03 WALK, HYB-04
RERANK) read only the mutable write buffer — post-freeze they rejected the
start node (NodeNotFound) or silently scored an empty candidate set.

- All four executors now take the immutable CSR segments alongside the
  MemGraph: traversal goes through SegmentMergeReader (frozen + delta
  edges), node existence and embeddings resolve through MergedNodeView
  (embeddings decode from the CSR v5 blob for frozen rows). bfs_collect /
  collect_context are segment-aware; RERANK's all-nodes scan enumerates
  both tiers with MVCC visibility. Pass `&[]` for a bare MemGraph.
- GRAPH.VSEARCH and GRAPH.HYBRID handlers load the segment snapshot and
  thread it through.
- extract_f32_vector: binary little-endian f32 fallback after the text
  form — the SAME blob format GRAPH.ADDNODE's VECTOR argument accepts, so
  vectors round-trip between ADDNODE and VSEARCH/HYBRID unchanged
  (previously HYBRID only accepted whitespace-separated text and answered
  "ERR invalid vector" to its own storage format).

Freeze-boundary suite: 11/11 GREEN (was 1/10 at review time) — Phase 0
exit criterion met. Graph lib 431/431.

author: Tin Dang
…elete dead insert-time index

Phase 1 task 6 — the storage half of the property-index lever identified by
the 2026-07 deep review (point query = 99% unindexed label scan; FalkorDB
wins reads on exactly this).

SegmentPropertyIndexes (index.rs): per-CSR-segment indexes over row ids,
built lazily on first use from the v5 node-property blob (OnceLock, same
pattern as the incoming-edge index — one code path covers from_frozen,
compact, from_bytes, and mmap reload). Numeric values (Int/Float/Bool)
share a per-property B-tree (equality + range via the existing
PropertyIndex); String/Bytes index by xxh64 hash (equality). Hash
collisions and Bool/Int aliasing only ever produce SUPERSET candidate
sets — the planner keeps its residual Filter, so collisions cost a
re-check, never a wrong row. The build is exhaustive over segment rows, so
lookups return empty bitmaps for absent values (no fall-back-to-scan
signal needed; pre-v5 segments genuinely hold no properties).

Accessor: CsrStorage::property_index() (heap + mmap variants).

Deleted the dead insert-time maintenance: NamedGraph.property_indexes was
populated on every ADDNODE with `ext_id as u32` — TRUNCATED external ids,
a row space nothing could resolve — and no query path ever read it. Field,
initializer, and the per-ADDNODE update loop are gone (the _key
registration that lived in the same loop is preserved).

Next (task 7): PhysicalOp::IndexScan consumes these via label-bitmap ∧
property-bitmap per segment.

Tests: graph lib 432/432; new test_segment_property_index_eq_and_range
covers Int/Float equality on one B-tree, string hash equality, range, and
absent-property emptiness.

author: Tin Dang
…operty indexes

Phase 1 task 7, the read-side half of the property-index lever (probe
showed Cypher point queries spend ~99% of their time in the unindexed
label scan; this is FalkorDB's entire read advantage).

Planner: a pattern node whose inline properties are all literals or
parameters (`(n:L {k:3})`, `(n:L {k:$v})`) now plans as
PhysicalOp::IndexScan { variable, label, prop_eq } instead of NodeScan —
emitted for the scanned first node and shortestPath endpoints (shared
push_node_scan helper). The full residual Filter is ALWAYS kept
immediately downstream: index lookups have superset semantics (string
hashes can collide, Bool/Int alias numerically), so the index only
prunes, never decides.

Executor (index_scan_keys, both execute and execute_profile):
- prop_eq values resolve per run via eval_expr (parameters index-hit under
  the plan cache); an unresolvable value degrades to the merged label scan.
- Frozen tier: per segment, property bitmaps intersect (short-circuit on
  empty) ∧ label bitmap, then MVCC/valid-time visibility per surviving row.
- Mutable tail: linear scan with a superset-consistent loose-equality
  check (numeric coercion mirrors the index) — bounded by edge_threshold.
- Write-path executor treats IndexScan as its NodeScan (mutable-tier
  MATCH semantics unchanged; residual Filter keeps it exact).
- GRAPH.PROFILE reports the op as "IndexScan".

Planner contract tests updated: literal inline props now pin
IndexScan+Filter (the v3-2 "Filter immediately follows the binding op"
contract is preserved, the binding op changed); new emission tests cover
literal / parameter / multi-prop / no-prop cases.

Known scope cut: WHERE-clause equality (`WHERE n.k = 3`) still plans as
NodeScan+Filter — extracting index-eligible conjuncts from WHERE is a
follow-up.

Tests: graph lib 433/433; freeze-boundary 11/11 (point-query tests now
exercise IndexScan end-to-end); tokio cypher suites green (inline-filter,
shortest-path, txn-write-rollback).

author: Tin Dang
…spatch

Phase 2 task 8. The plan cache was keyed on the raw query bytes, so the
dominant graph workload — point queries differing only in literal values —
NEVER hit it (every GRAPH.QUERY re-parsed and re-compiled). Eviction was
also arbitrary (HashMap keys().next()), and the server routing path parsed
every Cypher query TWICE (is_cypher_write_query full parse + handler parse).

Auto-parameterization (new src/graph/cypher/parameterize.rs): value
literals (int/float/string) are rewritten into synthetic parameters
($__p0, $__p1, …) at the TOKEN level, with values extracted into the
params map — so `{id: 3}` and `{id: 4}` share one cached plan and still
index-hit (PhysicalOp::IndexScan resolves Expr::Parameter per run).
Structural literals stay literal because they are baked into the plan,
not evaluated: variable-length hop bounds (prev token `*`/`..` or next
`..`), LIMIT/SKIP counts (conservative pin), and leading-minus literals
(logos maximal munch lexes `x-1` as Ident+Integer(-1); rewriting would
swallow a binary minus). Fail-open by construction: lex error, non-UTF-8
string, unparsable number, or a pre-existing `$__p` name returns None and
the caller falls back to raw-text raw-hash caching; if normalized text
ever failed to parse, parse_effective re-parses the raw text so user
errors reference their own query. Wrong results are impossible — only a
missed cache share.

Plan cache: proper LRU (monotonic access tick, evict min on overflow;
O(capacity) scan only when a full cache takes a new query text) and a new
invariant — only READ-ONLY plans are inserted (graph_query previously
cached write plans compiled for its error path).

Single-parse dispatch, enabled by that invariant:
- Cache HIT now runs with ZERO parse/compile work (previously: parse +
  hash + lookup). The hit path routes read-only directly since only read
  plans are cached.
- is_cypher_write_query: full parse → token scan for CREATE/DELETE/SET/
  MERGE. May false-positive (property named `set`) which only routes via
  the write-capable handler; graph_query_or_write AST-classifies and runs
  the read path correctly. Never false-negatives for a parsable write.
- GRAPH.RO_QUERY: parsed twice before (own classify parse + delegated
  graph_query parse); now shares graph_query_readonly (single parse, and
  zero on cache hit — a hit is read-only by the invariant).
- Write path merges auto-params before execute_mut (CREATE literals are
  normalized too; without the merge they would store Nulls).

Behavior notes: RO_QUERY of a write query against a MISSING graph now
reports "graph not found" instead of the write-clause error (graph lookup
precedes parse in the shared core); GRAPH.EXPLAIN/PROFILE stay raw-parse
(stable output, no cache).

Tests (red→green): plan-cache sharing across int and string literal
variants with per-query correct results, write-plan non-caching, LRU
eviction order, var-length hop bounds pinned to distinct plans (wrongly
parameterizing hops would return wrong row counts — pinned 5 vs 6 rows),
11 parameterize unit tests incl. normalized-text-reparses.

Suites: lib 3800 green, graph_freeze_boundary 11/11, tokio cypher suites
(inline-filter, shortest-path, txn-write-rollback) green, clippy both
feature sets, fmt.

author: Tin Dang
…tion

Phase 2 task 9. Every executor row was a HashMap<String, Value>: each
scan/expand/unwind emission allocated a fresh hasher table AND cloned the
variable name String per binding; row clones re-hashed everything. For
multi-row pipelines (the normal case) this dominated per-row cost.

Row is now a slot-indexed SmallVec<[Value; 4]> plus a reference to a
per-execution SlotTable (all variables the plan can bind, collected once
by walking the binding operators: scans, expands, unwind, shortestPath,
create/merge patterns). Name→slot resolution is a linear scan over a
handful of short names — cheaper than hashing at this cardinality.
The public row API is shape-compatible (get/insert/iter), so eval_expr's
signature is unchanged; insert now takes &str, deleting the per-binding
String clone.

Semantics: unbound slots hold Value::Null, which every consumer already
treats identically to the old HashMap miss — expression eval defaulted a
missing variable to Null, and operator sources pattern-match a concrete
variant (Some(Value::Node(_))), which Null fails exactly like None did.
Binding is uniform per operator (each binding op binds its variable on
every row it emits), so the no-Project column set (SlotTable names,
sorted) and RETURN * maps match the old bound-keys view.

Referenced-but-never-bound names resolve to no slot (None), matching a
HashMap miss; a bound name missing from the table would be a plan/table
desync and is debug_assert'ed.

Tests: SlotTable collection (match + create/merge pattern vars), Row
get/insert/iter/clone semantics incl. unbound-vs-unknown distinction.
Suites: lib 3802 green, graph_freeze_boundary 11/11, tokio cypher suites
green, clippy both feature sets.

author: Tin Dang
Phase 2 task 10 — the deep review's allocation-discipline findings
(NEIGHBORS profile: ~25% of time dropping Frames, ~20% in the ALLOCATING
SegmentMergeReader::neighbors; ADDNODE 2× WAL-encode).

- ADDNODE double WAL serialize deleted: a full record (incl. embedding
  bytes) was built with a placeholder id, dropped unread, and rebuilt from
  the stored node. Now a single serialization from the stored node's refs.
- wal.rs: 15 `itoa::Buffer::format().to_owned()` sites drop the String —
  the formatted &str is passed straight to write_bulk (statement-temporary
  pattern already used by write_bulk itself). format_f64's format!() is
  replaced by ryu (already a dep) for the edge weight and Float property
  sites; replay parses f64 so "1.0"-vs-"1" style differences are inert.
- New src/graph/fasthash.rs: FxHash multiply-fold (rustc's internal
  hasher, inlined — no new dependency). NodeKey/EdgeKey are slotmap PODs
  in maps we control (visited sets, dist maps), where SipHash's DoS
  resistance buys nothing. Module docs pin the provenance contract:
  never key by attacker-controlled bytes.
- traversal.rs: all non-test NodeKey sets/maps → FxHashSet/FxHashMap;
  Dijkstra's dist/prev/depth map TRIPLE merged into one
  FxHashMap<NodeKey, DijkstraNodeState> — one hash lookup per relaxation
  where three (×SipHash) used to run.
- Cypher Expand (both executors): the allocating `neighbors()` (fresh
  HashSet+Vec per source node — worst in variable-length BFS, per
  frontier node) replaced with `neighbors_into` on scratch hoisted per
  operator; BFS visited sets → FxHashSet.

Deferred (follow-up, noted in review doc): Value::String(String)→Bytes
to stop value_to_frame cloning string cells — ripples through all of
eval's string ops, separate change.

Tests: fasthash unit tests (map/set behavior, 10k-distinct-hash sanity);
suites: lib 3804 green, graph_freeze_boundary 11/11, tokio cypher suites
green, clippy both feature sets.

author: Tin Dang
…n-optimizing pull

Phase 3 task 11. BFS on a fully-frozen graph paid NodeKey hashing on
every edge probe (visited set), MPH lookups per hop, and — in the old
"ParallelBfs" — fake parallelism: the reader borrows !Send MemGraph, so
neighbor expansion ran sequentially (with a per-node nb_buf.clone()) and
worker threads only did DashSet merging.

New src/graph/row_bfs.rs, gated per query (mutable tier absent-or-empty
∧ exactly one CSR segment ∧ segment visible at the snapshot — every case
the reader path handles generically is a reason to fall back, never a
behavior fork; BoundedBfs/ParallelBfs::execute try the gate first):

- Row-space expansion: rows are dense u32s — visited set is a bitmap,
  adjacency is row_offsets/col_indices slice walks, NodeKey
  materialization happens once per RESULT row instead of per edge probe.
- TRUE parallel levels: CsrStorage is Send+Sync, so frontier chunks
  expand on worker threads (thread::scope) against a shared AtomicU64
  visited bitmap (fetch_or test-and-set); level triggers at frontier
  ≥ 256, ≤ 8 workers.
- Direction-optimizing BFS (Beamer α=14/β=24): when the frontier's
  out-edge count dwarfs the unexplored remainder, the level switches to
  bottom-up pull over the v3-2 IncomingIndex (each unvisited row scans
  its in-edges for a frontier parent; rows partitioned per worker).
  Outgoing direction only; the lazy incoming index is warmed outside the
  worker scope.

Semantics parity with the reader path: same edge-validity bitmap, same
edge-type filter, same deleted_lsn node visibility, same MergedNeighbor
materialization for CSR edges (placeholder edge key, weight 1.0,
segment-LSN timestamp). Within a parallel level, discovery ORDER is
unspecified (visited set + depths deterministic).

Tests (7): push parity vs a hand-rolled reader-path oracle across all
3 directions × filters; pull-vs-push equality (incl. edge-type filter);
parallel-level parity on a 2000-node star; gate fallback (mutable tier
non-empty / two segments / future segment); deleted-node exclusion
parity; depth limit + frontier cap; synthetic-key NodeNotFound (a key
from a fresh MemGraph would ALIAS frozen rows — the task-3 collision
class — so the test uses an out-of-range synthetic key).

Suites: lib 3811 green, graph_freeze_boundary 11/11, tokio cypher suites
green, clippy both feature sets.

author: Tin Dang
…wPreFilter

Phase 3 task 12 — the deep review's last two items.

TraversalGuard threading (bounded epoch hold, wall-clock):
- ExecutionContext gains `guard: Option<TraversalGuard>` (None default —
  Default/unit-test behavior unchanged) and ExecErrorKind gains
  Timeout(TraversalTimeout).
- Both read executors (execute + execute_profile) check the guard once
  per hop in variable-length Expand BFS and once per row before each
  ShortestPath run; GRAPH.QUERY/RO_QUERY/PROFILE create the guard with
  the default 30s budget. The write executor is untouched: it has no
  ExecutionContext and walks the mutable tier only (hop-capped), so the
  epoch-hold risk the guard bounds — long traversals pinning old CSR
  snapshots — doesn't apply.

Hybrid HnswPreFilter (was a silent stub — select_strategy picked it at
>= 10K candidates, execute brute-forced anyway):
- New src/graph/hnsw_bridge.rs: GraphHnsw builds the vector engine's
  HnswGraph (HnswBuilder, m=16/efC=128, deterministic seed) over a CSR
  segment's v5 embeddings with raw-f32 cosine distances — no TurboQuant.
  Embeddings copied once, unit-normalized (scores identical to
  simd::cosine_similarity). Search = greedy upper descent + L0 ef-beam
  (ef = max(64, 4k)), allow-filter applied to the collected beam.
- Lazy per-segment build via CsrStorage::hnsw_bridge (OnceLock, same
  pattern as incoming/property indexes), gated at >= 4096 embeddings;
  the negative answer is cached. In-memory only, never persisted.
- GraphFilteredSearch::execute now honors the strategy: CSR-resident
  candidates route through their segment's bridge (group top-k with
  exact cosines), everything else — mutable tier, bridge-less rows, dim
  mismatch, and any group whose approximate beam under-fills k — falls
  back to exact scoring. Never silently truncates.

Tests: guard timeout red/green for var-length Expand + shortestPath
(both executors) with generous-guard parity; bridge build/recall (>= 8/10
vs brute force, exact-score parity), allow-filter, min-vectors gate,
degenerate queries, unembedded-row skip; hybrid HnswPreFilter vs
brute-force overlap + bit-exact fallback without a bridge + tier
partition contract. Suites: lib 3821 green, graph_freeze_boundary 11/11,
tokio cypher suites green, clippy both feature sets, fmt.

author: Tin Dang
CHANGELOG entry covering the P0-P3 graph deep-review implementation
(freeze-boundary correctness, point-query indexes, plan-cache +
executor cost, row-space BFS + TraversalGuard + HNSW bridge), per the
CI Lint changelog gate.

author: Tin Dang
`slotmap::SlotMap` key allocation is deterministic: a fresh `MemGraph`
always mints SlotMap index 0 / generation 1 first. CSR segments persist
`NodeMeta::external_id` as the pre-crash process's raw NodeKey FFI bits.
`recover_graph_store` previously re-seeded the post-recovery mutable tier
with another fresh, unoffset `MemGraph`, and `replay.rs`'s AddNode replay
loop called `mg.add_node()` unconditionally for every replayed record.
The first brand-new node replayed after a restart could therefore mint
the exact same key as a loaded CSR segment's first frozen node --
`MergedNodeView` checks the mutable tier first, so the frozen node's
properties would be silently and permanently shadowed by the replayed
node's properties.

Fix is two-part:
- `MemGraph::with_id_offset` translates every public NodeKey by a fixed
  offset at the mutable/internal boundary. `recover_graph_store` seeds
  the post-recovery mutable tier with a watermark one past the largest
  persisted `external_id` across all loaded segments, so freshly-minted
  keys can never numerically alias a loaded segment's rows. Zero extra
  memory (no dummy insert/remove cycle, which would both leak an
  ever-growing SlotMap backing Vec and remain unsound against arbitrarily
  bumped pre-crash generations).
- `replay.rs`'s AddNode loop now seeds its `node_map` from every loaded
  CSR segment's node_meta before replaying, and skips `mg.add_node()`
  for any `node_id` already resident there -- handling full-history
  replay (AOF fold / WAL truncation left a pre-freeze record in place)
  without double-enumerating the same logical node.

Also updates `MergedNodeView`'s doc comment (src/graph/view.rs) to
describe how the "NodeKey lives in exactly one tier" invariant is
actively enforced across restart, not merely automatic the way it is
for in-process freeze/thaw.

Added tests/graph_restart_id_aliasing.rs (red/green, confirmed failing
against the pre-fix code via git stash before this commit): asserts no
replayed key aliases a loaded segment's external_id, that a frozen
node's properties are never shadowed by a replayed node, and that
replaying an already-frozen node's record doesn't double-enumerate it.

Known follow-up (not in scope here, documented for a separate fix):
`NamedGraph.write_buf` -- not `NamedGraph.segments.mutable` -- is the
tier every live command dispatch/read path actually consults. Nothing
currently reconciles `segments.mutable` (which recovery/replay populate)
back into `write_buf`, so today a restart does not expose replayed
post-freeze WAL mutations to live queries at all. This fix protects the
`segments`-based tier exactly as specified and matches replay.rs's own
pre-existing test contract; the write_buf/segments reconciliation gap is
a distinct, likely more severe issue requiring its own design pass.

author: Tin Dang
`normalize_cypher()` -> `parameterize()` ran unconditionally before the
plan-cache lookup on every GRAPH.QUERY -- a full lexer pass plus
allocations even for an exact-repeat query -- and `SlotTable::from_plan`
was rebuilt from scratch on every execution regardless of cache hit.

`PlanCache` now supports dual-keyed inserts: a raw query-bytes hash
(exact-repeat fast path, checked first, skipping `parameterize()`
entirely on hit and replaying that text's own auto-extracted params) and
the pre-existing literal-normalized hash (shared across every query that
differs only in literal values), both pointing at one shared
`Arc<PhysicalPlan>` + `Arc<SlotTable>` so a hit never rebuilds the slot
table. `graph_query_readonly`/`graph_query_or_write` check the raw hash
first, fall back to the normalized hash on raw miss, and only compile +
`insert_both()` on a full miss.

Cache storage switches from `std::HashMap` to the in-repo
`FxHashMap` (keyed by an already-xxhashed u64 digest -- never raw
attacker bytes -- and bounded by `max_entries` LRU eviction, so the
documented "not DoS-resistant" caveat on `FxHashMap` does not apply
here).

`PlanCache::len()` now reports raw hash-key-slot count (what
`max_entries`/LRU actually bounds); added `distinct_plan_count()` for
"how many distinct compiled plans are cached" -- the metric the
existing literal-sharing correctness tests in
src/command/graph/mod.rs actually want, now that a single distinct plan
can occupy two hash-key slots.

New unit tests: exact-repeat raw-hash hit, string/int literal-variant
sharing via the normalized hash, same-hash dedup when a query has no
literals to extract, and LRU eviction correctness under dual keys.

author: Tin Dang
…ytes

`CsrStorage::resident_bytes()` fed the shard's elastic memory budget but
omitted three real allocations: the v5 `node_props`/`edge_props` blobs,
the lazily-built `SegmentPropertyIndexes`, and the lazily-built
`GraphHnsw` bridge -- all invisible to eviction accounting. It also
double-counted the mmap-backed `row_offsets`/`col_indices`/`edge_meta`/
`node_meta` arrays for `CsrStorage::Mmap` segments as if they were heap
allocations, even though the doc comment already claimed otherwise.

Rewritten to follow the `RawF16Store` precedent (PR #232): mmap-backed
sections are kernel page cache (reclaimable under memory pressure) and
count as 0 for the `Mmap` variant; the same sections count in full for
the `Heap` variant. `props_index`/`hnsw_bridge` are ALWAYS heap-owned
once built (derived structures over the source data, never themselves
mapped) and count in full for both variants -- read via `OnceLock::get`
only, so `resident_bytes()` stays a cheap read that never triggers a
lazy build.

New tests in src/graph/csr/storage.rs: resident_bytes grows once after
`property_index()` is built and again after the HNSW bridge is built
(fixture carries real per-node properties and embeddings so both
indexes have non-trivial content, not an accidental 0 -> 0 pass); a
second test pins the Heap-variant invariant that the core CSR arrays
count in full.

author: Tin Dang
Adds the three review-gated fixes (restart NodeKey aliasing P0, plan-
cache raw-hash fast path, CSR resident_bytes accounting) to the graph
engine overhaul entry under [Unreleased].

author: Tin Dang
@TinDang97
TinDang97 force-pushed the feat/graph-engine-overhaul branch from 4fc5190 to 206eaf5 Compare July 7, 2026 05:38
@pilotspacex-byte

Copy link
Copy Markdown
Contributor Author

Pre-merge deep review (2 review agents + manual verification)

Branch rebased onto main and extended with 3 review-gated fix commits (dc2c14a7, 3b7d5023, 5394c8f7):

  1. P0 — restart NodeKey aliasing (fixed): a fresh post-recovery MemGraph's deterministic SlotMap could mint keys bit-identical to loaded CSR segments' external_ids; the mutable tier wins every MergedNodeView lookup, so a frozen node could be silently and permanently shadowed after a restart whose AddNode history was truncated (AOF fold / WAL checkpoint). Fix: MemGraph::with_id_offset watermark + replay dedup-skip. Red/green verified both directions (collision 4294967297 == 4294967297 reproduced with fix sources reverted).
  2. Plan-cache allocations on every hit (fixed): parameterize() (full lexer pass + allocs) ran before the cache lookup even for exact repeats; SlotTable was rebuilt per execution. Now: raw-bytes-hash pre-lookup (skips the lexer entirely on exact repeats, replays cached auto_params), SlotTable cached alongside the plan, FxHashMap backing. Read-only-plans-only invariant preserved (verified at both insert_both call sites).
  3. resident_bytes blind spots (fixed): v5 property blobs, lazy SegmentPropertyIndexes, and the GraphHnsw bridge (full embedding copy!) were invisible to the elastic memory budget. Now counted (heap-owned; mmap sections 0 per the RawF16Store precedent).

Validation: 475 graph tests green (lib + graph_freeze_boundary + new graph_restart_id_aliasing), fmt + clippy clean on both feature sets.

Follow-ups (documented, intentionally not gating this merge)

  • write_buf/segments reconciliation gap (pre-existing, likely severe): live dispatch reads NamedGraph.write_buf, but recovery/replay populate segments.mutable — nothing reconciles them, so replayed post-freeze WAL mutations are invisible to live queries after a real restart. Needs its own design pass.
  • RemoveNode replay of a CSR-resident node is lost (pre-existing): replay can't tombstone frozen rows.
  • EdgeKey determinism across restart (pre-existing): same class as the node P0 but for WAL RemoveEdge by id; lower surface.
  • Row-space BFS parallel levels spawn OS threads inline on the shard event-loop thread (this PR): co-located tail latency unmeasured; consider worker pool / yield (same class PR perf(vector): FT.SEARCH off-event-loop — cooperative-yield local slice (monoio timer-park fix) #179 fixed for FT.SEARCH).
  • Lazy HNSW bridge build runs synchronously in dispatch (this PR): first HYBRID query on a big segment = seconds of CPU on the shard thread.
  • Minor: Row SmallVec inline capacity 4 spills for ≥5 bound vars; raw-hash hit clones auto_params Vec (could pass Arc through); TraversalGuard calls Instant::now() per hop.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/graph/replay.rs (1)

539-556: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Add edge replay should skip already-applied frozen edges
AddEdge replay ignores edge_id and always calls add_edge_across_tiers, which inserts a fresh edge. If an already-frozen edge is left in WAL history, replay can duplicate it and inflate neighbor/traversal results. Add an edge-id dedup check or make the insert path idempotent for replay.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/graph/replay.rs` around lines 539 - 556, The AddEdge replay path in
replay.rs always inserts through add_edge_across_tiers and ignores edge_id,
which can duplicate already-frozen edges during WAL replay. Update the AddEdge
handling in the replay loop to detect and skip edges that have already been
applied, using edge_id-based deduplication or by making add_edge_across_tiers
idempotent for replay. Keep the change localized around the replay logic that
increments replayed after a successful insert.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/graph/replay.rs`:
- Around line 539-556: The AddEdge replay path in replay.rs always inserts
through add_edge_across_tiers and ignores edge_id, which can duplicate
already-frozen edges during WAL replay. Update the AddEdge handling in the
replay loop to detect and skip edges that have already been applied, using
edge_id-based deduplication or by making add_edge_across_tiers idempotent for
replay. Keep the change localized around the replay logic that increments
replayed after a successful insert.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 083ad10e-b25c-4d4b-84b2-28cccab6dd0d

📥 Commits

Reviewing files that changed from the base of the PR and between 4fc5190 and 206eaf5.

📒 Files selected for processing (37)
  • .github/workflows/fuzz.yml
  • .gitignore
  • CHANGELOG.md
  • fuzz/Cargo.toml
  • src/command/graph/graph_read.rs
  • src/command/graph/graph_write.rs
  • src/command/graph/mod.rs
  • src/graph/compaction.rs
  • src/graph/csr/mmap.rs
  • src/graph/csr/mod.rs
  • src/graph/csr/props.rs
  • src/graph/csr/storage.rs
  • src/graph/cypher/executor/eval.rs
  • src/graph/cypher/executor/mod.rs
  • src/graph/cypher/executor/read.rs
  • src/graph/cypher/executor/write.rs
  • src/graph/cypher/mod.rs
  • src/graph/cypher/parameterize.rs
  • src/graph/cypher/planner.rs
  • src/graph/fasthash.rs
  • src/graph/hnsw_bridge.rs
  • src/graph/hybrid.rs
  • src/graph/index.rs
  • src/graph/memgraph.rs
  • src/graph/mod.rs
  • src/graph/recovery.rs
  • src/graph/replay.rs
  • src/graph/row_bfs.rs
  • src/graph/store.rs
  • src/graph/traversal.rs
  • src/graph/traversal_guard.rs
  • src/graph/types.rs
  • src/graph/view.rs
  • src/graph/visibility.rs
  • src/graph/wal.rs
  • tests/graph_freeze_boundary.rs
  • tests/graph_restart_id_aliasing.rs
✅ Files skipped from review due to trivial changes (4)
  • .github/workflows/fuzz.yml
  • src/graph/cypher/mod.rs
  • .gitignore
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (28)
  • fuzz/Cargo.toml
  • src/graph/mod.rs
  • src/graph/traversal_guard.rs
  • src/graph/fasthash.rs
  • src/graph/types.rs
  • src/graph/row_bfs.rs
  • src/graph/visibility.rs
  • src/graph/hnsw_bridge.rs
  • src/graph/wal.rs
  • src/graph/index.rs
  • src/graph/store.rs
  • src/graph/csr/props.rs
  • src/graph/cypher/executor/eval.rs
  • tests/graph_freeze_boundary.rs
  • src/graph/cypher/executor/mod.rs
  • src/graph/csr/mmap.rs
  • src/graph/cypher/parameterize.rs
  • src/command/graph/mod.rs
  • src/graph/view.rs
  • src/graph/traversal.rs
  • src/command/graph/graph_write.rs
  • src/graph/memgraph.rs
  • src/command/graph/graph_read.rs
  • src/graph/cypher/executor/write.rs
  • src/graph/compaction.rs
  • src/graph/csr/mod.rs
  • src/graph/hybrid.rs
  • src/graph/cypher/executor/read.rs

@pilotspacex-byte
pilotspacex-byte merged commit c950806 into main Jul 7, 2026
11 checks passed
@TinDang97
TinDang97 deleted the feat/graph-engine-overhaul branch July 7, 2026 07:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants