-
-
Notifications
You must be signed in to change notification settings - Fork 9.5k
FalkorDB backend (server + embedded Lite), replacing NetworkX #1379
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
galshubeli
wants to merge
20
commits into
Graphify-Labs:v8
Choose a base branch
from
galshubeli:falkordb-backend
base: v8
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
23be870
feat: FalkorDB backend (server + embedded Lite), replacing NetworkX
galshubeli 6e689de
refactor: rename graph-connection helpers to reflect they connect, no…
galshubeli b6e54ff
review: FalkorDB-only consistency + lazy UDF load
galshubeli 905131f
review: bound two O(n^2)/whole-graph costs on large graphs
galshubeli a8e9620
review: guard affected.connect_graph + add exporter/error-path tests
galshubeli 76c3d38
ci+perf: FalkorDB CI service container; one-pass degree map in cluster()
galshubeli bee3979
Merge origin/v8 into falkordb-backend
galshubeli abd558d
review: address PR #1 Copilot comments
galshubeli cb702d7
fix: _find_node label_tokens used undefined `d` (merge artifact)
galshubeli 9c6016b
fix: preserve _origin so incremental rebuild evicts removed symbols (…
galshubeli cfc80b3
ci: regenerate uv.lock for the FalkorDB dependency set
galshubeli fcad53d
fix: GraphStore.in_degree/out_degree + build_merge test uses graph_name
galshubeli 23dc4ac
Merge origin/v8 (18 commits) into falkordb-backend
galshubeli ad18f03
fix: deterministic _find_node tie-ordering across backends
galshubeli f51f720
fix: address PR #1379 review findings (build crash, packaging, edge d…
galshubeli 4a0e961
fix: query traversal dropped same-level multi-parent edges
galshubeli 779fc42
fix: keep pre-FalkorDB and --no-cluster graphs queryable (auto-import…
galshubeli b30e3ef
feat: zero-setup default — auto-fall back to embedded FalkorDB Lite
galshubeli 459e233
Merge v8 into falkordb-backend: re-port FalkorDB onto the new cli.py
galshubeli 1c229de
fix: port the remaining v8 tests onto the FalkorDB backend
galshubeli File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,8 +6,6 @@ | |
| from typing import Iterable | ||
| import unicodedata | ||
|
|
||
| import networkx as nx | ||
|
|
||
|
|
||
| DEFAULT_AFFECTED_RELATIONS = ( | ||
| "calls", | ||
|
|
@@ -50,16 +48,16 @@ def _format_location(data: dict) -> str: | |
| return str(source_file) | ||
|
|
||
|
|
||
| def _normalize_label(label: str) -> str: | ||
| return unicodedata.normalize("NFC", label).casefold() | ||
|
|
||
|
|
||
| def _bare_name(label: str) -> str: | ||
| """Lowercased label with the callable decoration (trailing "()") removed.""" | ||
| """Normalized label with the callable decoration (trailing "()") removed.""" | ||
| label = _normalize_label(label) | ||
| return label[:-2] if label.endswith("()") else label | ||
|
|
||
|
|
||
| def _normalize_label(label: str) -> str: | ||
| return unicodedata.normalize("NFC", label).casefold() | ||
|
|
||
|
|
||
| def _prefer_file_node( | ||
| graph: nx.Graph, | ||
| node_ids: list[str], | ||
|
|
@@ -95,11 +93,37 @@ def _prefer_file_node( | |
| return None | ||
|
|
||
|
|
||
| def resolve_seed(graph: nx.Graph, query: str) -> str | None: | ||
| def resolve_seed(graph, query: str) -> str | None: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
11 callers depend on it (afferent coupling). Grounded coupling-delta finding (deterministic), not an LLM guess. |
||
| # A trailing path separator must not change a source-file match — serve's | ||
| # _find_node tokenizes the path (which drops it), so strip it here for parity | ||
| # (otherwise `affected "src/x.ts/"` returned None while `explain` resolved it). | ||
| query = query.rstrip("/\\") or query | ||
| # FalkorDB-native: resolve via scoped queries (no full-graph load). | ||
| if hasattr(graph, "find_node_ids"): | ||
| if graph.has_node(query): | ||
| return query | ||
| # Mirror the in-memory tier order: exact label -> bare callable name -> | ||
| # exact source_file -> substring (#1353). Without the bare-name tier a | ||
| # query like "foo" failed to resolve to "foo()" on the native path. | ||
| for kwargs in ({"label": query}, {"label_bare": _bare_name(query)}): | ||
| matches = graph.find_node_ids(limit=2, **kwargs) | ||
| if len(matches) == 1: | ||
| return str(matches[0]) | ||
| # source_file tier: when several nodes share the file, prefer the | ||
| # file-level node, exactly as the in-memory path does — otherwise a | ||
| # path query is ambiguous natively but resolves in memory. | ||
| src_matches = [str(m) for m in graph.find_node_ids(source_file=query, limit=50)] | ||
| if len(src_matches) == 1: | ||
| return src_matches[0] | ||
| if src_matches: | ||
| preferred = _prefer_file_node(graph, src_matches, query) | ||
| if preferred is not None: | ||
| return preferred | ||
| matches = graph.find_node_ids(limit=2, label_contains=query) | ||
| if len(matches) == 1: | ||
| return str(matches[0]) | ||
| return None | ||
| # In-memory fallback (nx / MemGraph) — normalized + bare-name matching (#1353). | ||
| if query in graph: | ||
| return query | ||
| query_lower = _normalize_label(query) | ||
|
|
@@ -150,6 +174,47 @@ def affected_nodes( | |
| depth: int = 2, | ||
| ) -> list[AffectedHit]: | ||
| relation_set = set(relations) | ||
|
|
||
| # FalkorDB-native: one batched reverse-edge query per BFS level (depth queries | ||
| # total) instead of a per-node loop over an in-memory copy of the graph. | ||
| if hasattr(graph, "incoming_edges"): | ||
| seen = {seed} | ||
| hits: list[AffectedHit] = [] | ||
| frontier = [seed] | ||
| # #1669: seed the reverse walk with the root's own member nodes (one | ||
| # outward `method`/`contains` hop), mirroring the in-memory path. A caller | ||
| # can bind to a class's method node rather than the class node itself, so | ||
| # those callers are otherwise unreachable. Seeds only — not reported as | ||
| # hits, and `method`/`contains` stay out of the relation-filtered walk. | ||
| if hasattr(graph, "member_nodes"): | ||
| for member in sorted(str(m) for m in graph.member_nodes(seed)): | ||
| if member not in seen: | ||
| seen.add(member) | ||
| frontier.append(member) | ||
| for d in range(depth): | ||
| if not frontier: | ||
| break | ||
| rows = graph.incoming_edges(frontier, relation_set) | ||
| nxt: list[str] = [] | ||
| for _fid, src, relation, via_file, via_loc in sorted( | ||
| rows, key=lambda r: (str(r[0]), str(r[1]), str(r[2])) | ||
| ): | ||
| src = str(src) | ||
| if src in seen: | ||
| continue | ||
| seen.add(src) | ||
| # Location comes from the SAME edge whose relation passed the | ||
| # filter, so relation and site stay consistent (#BUG1). | ||
| hits.append(AffectedHit( | ||
| src, d + 1, str(relation), | ||
| via_file=str(via_file or "") or None, | ||
| via_location=str(via_loc or "") or None, | ||
| )) | ||
| nxt.append(src) | ||
| frontier = nxt | ||
| return hits | ||
|
|
||
| # In-memory fallback (nx / MemGraph) | ||
| seen = {seed} | ||
| queue: deque[tuple[str, int]] = deque([(seed, 0)]) | ||
| hits: list[AffectedHit] = [] | ||
|
|
@@ -206,7 +271,6 @@ def affected_nodes( | |
| ) | ||
| hits.append(hit) | ||
| queue.append((source, current_depth + 1)) | ||
|
|
||
| return hits | ||
|
|
||
|
|
||
|
|
@@ -223,8 +287,19 @@ def format_affected( | |
| return f"No unique node match for {query}" | ||
|
|
||
| hits = affected_nodes(graph, seed, relations=relation_list, depth=depth) | ||
|
|
||
| # Fetch attrs for seed + all hits in one batched query (native) or per-node (fallback). | ||
| ids = [seed] + [h.node_id for h in hits] | ||
| if hasattr(graph, "node_attrs_batch"): | ||
| attrs = graph.node_attrs_batch(ids) | ||
| else: | ||
| attrs = {nid: dict(graph.nodes[nid]) for nid in ids if nid in graph} | ||
|
|
||
| def _label(nid): | ||
| return str(attrs.get(nid, {}).get("label") or nid) | ||
|
|
||
| lines = [ | ||
| f"Affected nodes for {_node_label(graph, seed)}", | ||
| f"Affected nodes for {_label(seed)}", | ||
| f"Relations: {', '.join(relation_list)}", | ||
| f"Depth: {depth}", | ||
| ] | ||
|
|
@@ -233,40 +308,48 @@ def format_affected( | |
| return "\n".join(lines) | ||
|
|
||
| for hit in hits: | ||
| data = graph.nodes[hit.node_id] | ||
| data = attrs.get(hit.node_id, {}) | ||
| if hit.via_location: | ||
| # The relation SITE in this node's file (call/import/reference line), | ||
| # labeled by [via_relation] so it's never mistaken for a def line. | ||
| location = f"{hit.via_file or data.get('source_file') or '-'}:{hit.via_location}" | ||
| else: | ||
| location = _format_location(data) # honest fallback: the node's own def line | ||
| lines.append( | ||
| f"- {_node_label(graph, hit.node_id)} [{hit.via_relation}] {location}" | ||
| f"- {_label(hit.node_id)} [{hit.via_relation}] {location}" | ||
| ) | ||
| return "\n".join(lines) | ||
|
|
||
|
|
||
| def load_graph(path: Path) -> nx.Graph: | ||
| import json | ||
| from networkx.readwrite import json_graph | ||
|
|
||
| try: | ||
| raw = json.loads(path.read_text(encoding="utf-8")) | ||
| except (json.JSONDecodeError, OSError) as exc: | ||
| raise RuntimeError( | ||
| f"Cannot read graph file {path}: {exc}. " | ||
| "Re-run 'graphify extract' to regenerate it." | ||
| ) from exc | ||
| # Force directed so stored caller→callee direction survives the round-trip; | ||
| # mirrors serve.py and __main__.py (#1174). | ||
| raw = {**raw, "directed": True} | ||
| # Normalize the edge key: graphify's `extract` output uses "edges" while | ||
| # networkx's node_link_data default is "links". Without this, an edges-keyed | ||
| # graph.json raises an uncaught KeyError: 'links' here — every other loader | ||
| # (__main__.py) already normalizes this (#738; same class as #1198). | ||
| if "links" not in raw and "edges" in raw: | ||
| raw = dict(raw, links=raw["edges"]) | ||
| try: | ||
| return json_graph.node_link_graph(raw, edges="links") | ||
| except TypeError: | ||
| return json_graph.node_link_graph(raw) | ||
| def connect_graph(path: Path): | ||
| """Open a connection to the FalkorDB-backed graph for the output dir containing | ||
| `path`. Cache-free: returns a store handle (a connection), it does NOT load the | ||
| graph into memory. | ||
|
|
||
| `path` is the legacy graph.json location (e.g. graphify-out/graph.json); we | ||
| use its parent directory to locate the FalkorDB pointer and open the store. | ||
| """ | ||
| from .store import open_store | ||
|
|
||
| resolved = Path(path) | ||
| out_dir = resolved.parent if resolved.suffix else resolved | ||
| store = open_store(out_dir, create=False) | ||
| if store.number_of_nodes() == 0: | ||
| # Back-compat: the store is empty but a node-link graph.json may still | ||
| # hold the graph (a pre-FalkorDB project, or a `--no-cluster` run that | ||
| # only wrote JSON). Import it on first use, exactly as serve._connect_graph | ||
| # does, so `affected`/`god-nodes` stay usable on the same graphs `query` | ||
| # and `explain` can already read. | ||
| from .serve import _import_graph_json_into_store | ||
|
|
||
| gj = resolved if resolved.suffix == ".json" else (out_dir / "graph.json") | ||
| _import_graph_json_into_store(gj, store) | ||
| # open_store(create=False) hands back a store even when no graph was built | ||
| # (it derives a name from the pointer/root rather than erroring), so guard the | ||
| # empty case here — matching serve._connect_graph — so `graphify affected` | ||
| # tells the user to build instead of silently reporting nothing. | ||
| if store.number_of_nodes() == 0: | ||
| raise FileNotFoundError( | ||
| f"No graph found for {out_dir} (FalkorDB graph empty). Re-run /graphify to build." | ||
| ) | ||
| return store | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resolve_seed()11 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.