#!/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())