new-graph-visualization / falkor_load /build_falkor_graph.py
anurag-raapid's picture
Upload app, Docker, staging parquet only (no venv)
4b988e0 verified
Raw
History Blame Contribute Delete
17.4 kB
#!/usr/bin/env python3
"""Load clinical graph staging parquet into FalkorDB (GRAPH.BULK)."""
from __future__ import annotations
import argparse
import os
import sys
import time
from pathlib import Path
from .graph_bulk_falkor import (
DEFAULT_BULK_BUFFER_MB,
bulk_update_hier,
ensure_falkor_graph_absent,
export_hier_update_csv,
falkor_bulk_load_graph,
falkor_graph_counts,
falkor_graph_redis_exists,
verify_falkor_counts,
wait_for_falkor_ready,
warn_if_docker_memory_low,
)
from .graph_load_utils import (
DEFAULT_STAGING_DIR,
concept_rows_for_load,
estimate_batches,
hier_rows_for_load,
mrrel_rows_for_load,
parquet_row_count,
require_staging,
run_batched_load,
)
DEFAULT_FALKOR_GRAPH = "clinical_graph"
DEFAULT_BATCH_SIZE = 50_000
DEFAULT_EDGE_BATCH_SIZE = 10_000
def _print_load_plan(
staging: dict[str, Path], batch_size: int, max_rows: int | None
) -> None:
n_c = min(parquet_row_count(staging["concepts"]), max_rows or 10**18)
n_r = min(parquet_row_count(staging["mrrel"]), max_rows or 10**18)
n_h = min(parquet_row_count(staging["hier"]), max_rows or 10**18)
_, b_c = estimate_batches(staging["concepts"], batch_size, max_rows=max_rows)
_, b_r = estimate_batches(staging["mrrel"], batch_size, max_rows=max_rows)
_, b_h = estimate_batches(staging["hier"], batch_size, max_rows=max_rows)
total_batches = b_c + b_r + b_h
print(
f"\nLoad plan: {n_c:,} concepts + {n_r:,} MRREL + {n_h:,} HIER "
f"≈ {total_batches:,} Cypher batches per backend (batch_size={batch_size:,})",
flush=True,
)
print(
"Note: Ladybug bulk COPY ~4 min. FalkorDB edges use GRAPH.BULK (fast). "
"Use --falkor-method cypher for slow Cypher-only load.",
flush=True,
)
print(
"For quick profiling only: --max-rows 100000\n"
" python3 -m falkor_load.build_falkor_graph --rebuild\n",
flush=True,
)
def _falkor_concept_count(graph) -> int:
"""Count Concept nodes; graph Redis key must already exist."""
result = graph.query("MATCH (c:Concept) RETURN count(c) AS n").result_set
return int(result[0][0]) if result else 0
def _falkor_relation_counts(graph) -> tuple[int, int]:
mr = graph.query("MATCH ()-[r:MRREL]->() RETURN count(r) AS n").result_set
hi = graph.query("MATCH ()-[r:HIER_ISA]->() RETURN count(r) AS n").result_set
return int(mr[0][0]) if mr else 0, int(hi[0][0]) if hi else 0
# Rough full-scale targets (for --resume-hier after OOM during bulk load).
_FULL_NODES = 6_500_000
_FULL_MRREL = 10_500_000
_FULL_HIER = 14_500_000
def _falkor_create_indexes(graph) -> None:
"""FalkorDB 1.6.x API: create_node_range_index(label, *properties)."""
for prop in ("aui", "cui"):
try:
graph.create_node_range_index("Concept", prop)
print(f" index Concept.{prop} OK", flush=True)
except Exception as exc:
print(f" index Concept.{prop}: {exc}", flush=True)
def _falkor_load_concepts_cypher(
graph,
staging: dict[str, Path],
*,
rebuild: bool,
batch_size: int,
max_rows: int | None,
) -> None:
node_cypher = (
"""
UNWIND $batch AS row
CREATE (c:Concept {
aui: row.aui, cui: row.cui, tui: row.tui, sab: row.sab,
str: row.str, tty: row.tty, rank: row.rank
})
"""
if rebuild
else """
UNWIND $batch AS row
MERGE (c:Concept {aui: row.aui})
SET c.cui = row.cui, c.tui = row.tui, c.sab = row.sab,
c.str = row.str, c.tty = row.tty, c.rank = row.rank
"""
)
def load_concepts(batch: list[dict]) -> None:
graph.query(node_cypher, {"batch": batch})
run_batched_load(
"Falkor concepts",
staging["concepts"],
batch_size,
concept_rows_for_load,
load_concepts,
max_rows=max_rows,
)
def _load_hier_phase(
graph,
staging: dict[str, Path],
*,
graph_name: str,
host: str,
port: int,
batch_size: int,
max_rows: int | None,
hier_method: str,
hier_update_token_mb: int,
) -> None:
if hier_method == "cypher":
print(
" phase 2: HIER_ISA via Cypher UNWIND batches (slow) …",
flush=True,
)
_falkor_load_hier_cypher(
graph, staging, batch_size=batch_size, max_rows=max_rows
)
return
print(
" phase 2: HIER_ISA via batched bulk-update (in-process, no Redis socket timeout) …",
flush=True,
)
hier_pipe = export_hier_update_csv(staging["concepts"].parent, max_rows=max_rows)
bulk_update_hier(
graph_name,
hier_pipe,
host=host,
port=port,
max_token_mb=hier_update_token_mb,
)
def _falkor_load_hier_cypher(
graph,
staging: dict[str, Path],
*,
batch_size: int,
max_rows: int | None,
) -> None:
from .graph_bulk_falkor import DEFAULT_BULK_QUERY_TIMEOUT_MS
def load_hier(batch: list[dict]) -> None:
graph.query(
"""
UNWIND $batch AS row
MATCH (a:Concept {aui: row.src}), (b:Concept {aui: row.dst})
CREATE (a)-[:HIER_ISA {sab: row.sab, rela: row.rela}]->(b)
""",
{"batch": batch},
timeout=DEFAULT_BULK_QUERY_TIMEOUT_MS,
)
edge_batch = min(batch_size, DEFAULT_EDGE_BATCH_SIZE)
run_batched_load(
"Falkor HIER_ISA (Cypher resume)",
staging["hier"],
edge_batch,
hier_rows_for_load,
load_hier,
max_rows=max_rows,
progress_every_batches=1,
)
def _falkor_load_edges_cypher(
graph,
staging: dict[str, Path],
*,
batch_size: int,
max_rows: int | None,
hier_only: bool = False,
) -> None:
if hier_only:
_falkor_load_hier_cypher(
graph, staging, batch_size=batch_size, max_rows=max_rows
)
return
def load_mrrel(batch: list[dict]) -> None:
graph.query(
"""
UNWIND $batch AS row
MATCH (a:Concept {aui: row.src}), (b:Concept {aui: row.dst})
CREATE (a)-[:MRREL {rel: row.rel, rela: row.rela, sab: row.sab}]->(b)
""",
{"batch": batch},
)
edge_batch = min(batch_size, DEFAULT_EDGE_BATCH_SIZE)
run_batched_load(
"Falkor MRREL (Cypher)",
staging["mrrel"],
edge_batch,
mrrel_rows_for_load,
load_mrrel,
max_rows=max_rows,
progress_every_batches=1,
)
_falkor_load_hier_cypher(graph, staging, batch_size=batch_size, max_rows=max_rows)
def build_falkor(
staging: dict[str, Path],
*,
host: str,
port: int,
graph_name: str,
rebuild: bool,
batch_size: int,
max_rows: int | None,
falkor_method: str,
cypher_edges: bool,
edges_only: bool,
resume_hier: bool,
bulk_buffer_mb: int,
split_bulk: bool,
hier_method: str,
hier_update_token_mb: int,
) -> None:
print(f"\n▶ FalkorDB {host}:{port} graph={graph_name}", flush=True)
warn_if_docker_memory_low()
wait_for_falkor_ready(graph_name, host=host, port=port)
if falkor_method == "cypher":
cypher_edges = True
print(
" method: Cypher batches (reliable; edges can take many hours at full scale)",
flush=True,
)
else:
bulk_all = not split_bulk
mode = (
"GRAPH.BULK Concept+MRREL+HIER_ISA (fast, default)"
if bulk_all
else f"GRAPH.BULK nodes+MRREL only, then HIER via {hier_method} (--split-bulk)"
)
print(f" method: {mode}", flush=True)
if bulk_all:
warn_if_docker_memory_low(min_gib=8.0)
from .graph_bulk_falkor import falkordb_connect
db = falkordb_connect(host=host, port=port)
graph = db.select_graph(graph_name)
if resume_hier:
if not falkor_graph_redis_exists(graph_name, host=host, port=port):
raise RuntimeError(
"--resume-hier: no graph in Redis. Run a bulk load first or use --rebuild."
)
n_nodes = _falkor_concept_count(graph)
n_mrrel, n_hier = _falkor_relation_counts(graph)
print(
f" resume: {n_nodes:,} nodes, {n_mrrel:,} MRREL, {n_hier:,} HIER_ISA",
flush=True,
)
if n_hier >= _FULL_HIER:
print(" HIER_ISA already complete — nothing to do.", flush=True)
return
if n_nodes < _FULL_NODES or n_mrrel < _FULL_MRREL:
raise RuntimeError(
f"--resume-hier needs a partial bulk load (nodes+MRREL done, HIER missing). "
f"Got {n_nodes:,} nodes, {n_mrrel:,} MRREL. Use --rebuild for a fresh load."
)
# Cypher / bulk-update HIER append is very slow. Full GRAPH.BULK reload is faster.
print(
" --resume-hier: full GRAPH.BULK reload (Concept+MRREL+HIER, ~7–10 min) …",
flush=True,
)
ensure_falkor_graph_absent(graph_name, host=host, port=port)
graph = db.select_graph(graph_name)
buf_mb = bulk_buffer_mb or DEFAULT_BULK_BUFFER_MB
exp_n, exp_mr, exp_hi = falkor_bulk_load_graph(
staging["concepts"].parent,
graph_name=graph_name,
host=host,
port=port,
max_rows=max_rows,
max_buffer_mb=buf_mb,
bulk_all=True,
)
print(" creating FalkorDB indexes on Concept.aui / Concept.cui …", flush=True)
_falkor_create_indexes(graph)
ok, detail = verify_falkor_counts(
graph_name,
host=host,
port=port,
expected_nodes=exp_n,
expected_mrrel=exp_mr,
expected_hier=exp_hi,
)
if not ok:
raise RuntimeError(
f"FalkorDB graph incomplete after resume reload: {detail}"
)
print(f" FalkorDB after resume: {detail}", flush=True)
return
if rebuild and not edges_only:
ensure_falkor_graph_absent(graph_name, host=host, port=port)
print(" deleted existing graph (Redis key cleared)", flush=True)
graph = db.select_graph(graph_name)
n_existing = 0
elif falkor_graph_redis_exists(graph_name, host=host, port=port):
n_existing = _falkor_concept_count(graph)
else:
n_existing = 0
if edges_only:
if n_existing == 0:
raise RuntimeError(
"edges-only: no Concept nodes in graph. Load nodes first or drop --edges-only."
)
print(f" edges-only: {n_existing:,} Concept nodes present", flush=True)
if not cypher_edges:
print(
" edges-only: GRAPH.BULK cannot append to an existing graph; "
"using Cypher edge load (slow). For fast full reload use --rebuild without --edges-only.",
flush=True,
)
cypher_edges = True
elif n_existing > 0 and not rebuild:
print(
f" graph already has {n_existing:,} Concept nodes — skip (use --rebuild)"
)
return
if cypher_edges and not edges_only:
_falkor_load_concepts_cypher(
graph, staging, rebuild=rebuild, batch_size=batch_size, max_rows=max_rows
)
print(" creating FalkorDB indexes on Concept.aui / Concept.cui …", flush=True)
_falkor_create_indexes(graph)
if cypher_edges:
if falkor_method != "cypher":
print(
" WARNING: Cypher edge load is slow at full scale "
"(MATCH per edge). Default is GRAPH.BULK.",
flush=True,
)
_falkor_load_edges_cypher(
graph, staging, batch_size=batch_size, max_rows=max_rows
)
else:
# GRAPH.BULK (split by default: nodes+MRREL bulk, HIER Cypher — avoids OOM at end).
if n_existing > 0:
raise RuntimeError(
f"GRAPH.BULK requires an empty graph (found {n_existing:,} Concept nodes). "
"Use --rebuild to delete and reload, or --cypher-edges to append slowly."
)
print(
" tip: FalkorDB only, ≥10 GB — "
"docker compose -f docker-compose.graph-bench.yml up -d --force-recreate falkordb",
flush=True,
)
buf_mb = bulk_buffer_mb or DEFAULT_BULK_BUFFER_MB
exp_n, exp_mr, exp_hi = falkor_bulk_load_graph(
staging["concepts"].parent,
graph_name=graph_name,
host=host,
port=port,
max_rows=max_rows,
max_buffer_mb=buf_mb,
bulk_all=not split_bulk,
)
graph = db.select_graph(graph_name)
print(" creating FalkorDB indexes on Concept.aui / Concept.cui …", flush=True)
_falkor_create_indexes(graph)
if split_bulk:
n, mr, hi = falkor_graph_counts(graph_name, host=host, port=port)
print(
f" after bulk phase: {n:,} nodes, {mr:,} MRREL, {hi:,} HIER_ISA",
flush=True,
)
need_hier = hi < int(exp_hi * 0.98)
if need_hier:
_load_hier_phase(
graph,
staging,
graph_name=graph_name,
host=host,
port=port,
batch_size=batch_size,
max_rows=max_rows,
hier_method=hier_method,
hier_update_token_mb=hier_update_token_mb,
)
ok, detail = verify_falkor_counts(
graph_name,
host=host,
port=port,
expected_nodes=exp_n,
expected_mrrel=exp_mr,
expected_hier=exp_hi,
)
if not ok:
raise RuntimeError(f"FalkorDB graph incomplete after load: {detail}")
print(f" FalkorDB load verified: {detail}", flush=True)
n, mr, hi = falkor_graph_counts(graph_name, host=host, port=port)
print(
f" FalkorDB ready: {n:,} Concept nodes, {mr:,} MRREL, {hi:,} HIER_ISA",
flush=True,
)
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--staging-dir", type=Path, default=DEFAULT_STAGING_DIR)
p.add_argument("--falkor-only", action="store_true", help=argparse.SUPPRESS)
p.add_argument(
"--rebuild", action="store_true", help="Drop existing graph data first"
)
p.add_argument(
"--batch-size",
type=int,
default=DEFAULT_BATCH_SIZE,
help=f"Rows per Cypher UNWIND batch (default: {DEFAULT_BATCH_SIZE:,})",
)
p.add_argument(
"--max-rows", type=int, default=None, help="Limit rows per table (testing)"
)
p.add_argument(
"--falkor-method",
choices=("bulk", "cypher"),
default="bulk",
help="FalkorDB load: bulk=GRAPH.BULK (fast, default); cypher=all Cypher batches",
)
p.add_argument("--cypher-edges", action="store_true")
p.add_argument("--edges-only", action="store_true")
p.add_argument("--resume-hier", action="store_true")
p.add_argument("--bulk-buffer-mb", type=int, default=48)
p.add_argument("--split-bulk", action="store_true")
p.add_argument("--bulk-all", action="store_true", help=argparse.SUPPRESS)
p.add_argument(
"--hier-method",
choices=("bulk-update", "cypher"),
default="bulk-update",
)
p.add_argument("--hier-update-token-mb", type=int, default=32)
p.add_argument("--estimate-only", action="store_true")
p.add_argument(
"--falkor-host", default=os.environ.get("FALKORDB_HOST", "localhost")
)
p.add_argument(
"--falkor-port", type=int, default=int(os.environ.get("FALKORDB_PORT", "6379"))
)
p.add_argument(
"--falkor-graph",
default=os.environ.get("FALKORDB_GRAPH", DEFAULT_FALKOR_GRAPH),
)
return p.parse_args()
def main() -> int:
args = parse_args()
t0 = time.time()
try:
staging = require_staging(args.staging_dir)
except FileNotFoundError as exc:
print(exc, file=sys.stderr)
return 1
_print_load_plan(staging, args.batch_size, args.max_rows)
if args.estimate_only:
return 0
try:
build_falkor(
staging,
host=args.falkor_host,
port=args.falkor_port,
graph_name=args.falkor_graph,
rebuild=args.rebuild,
batch_size=args.batch_size,
max_rows=args.max_rows,
falkor_method=args.falkor_method,
cypher_edges=args.cypher_edges,
edges_only=args.edges_only,
resume_hier=args.resume_hier,
bulk_buffer_mb=args.bulk_buffer_mb,
split_bulk=args.split_bulk and not args.bulk_all,
hier_method=args.hier_method,
hier_update_token_mb=args.hier_update_token_mb,
)
except Exception as exc:
print(f"FalkorDB load failed: {exc}", file=sys.stderr)
return 1
print(f"\nDone in {(time.time() - t0) / 60:.1f} min")
return 0
if __name__ == "__main__":
raise SystemExit(main())