우선 Rust 프로젝트를 생성한다.
~/workspace/occult-graph$ cargo new rust-engine ~/workspace/occult-graph$ tree . ├── compose.yaml ├── data │ ├── edges.csv │ └── nodes.csv ├── proto │ └── occult.proto ├── rust-engine │ ├── Cargo.toml │ └── src │ └── main.rs └── scripts └── database ├── 01_init.sql └── 02_data.py 7 directories, 8 files
Cargo.toml의존성 파일을 작성한다.
rust-engine/Cargo.toml[package] name = "rust-engine" version = "0.1.0" edition = "2024" description = "오컬트 지식 그래프 재귀 탐색을 위한 고성능 gRPC 코어 엔진" [profile.release] opt-level = 3 lto = true codegen-units = 1 panic = "abort" [dependencies] # 비동기 런타임 tokio = { version = "1.52", features = ["full"] } tokio-stream = "0.1" # gRPC tonic = "0.14" prost = "0.14" tonic-prost = "0.14" # 데이터베이스 sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono", "macros"] } uuid = { version = "1.23", features = ["serde", "v7"] } # 직렬화 및 유틸리티 serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" dotenvy = "0.15" # 구조화된 로깅 tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } [build-dependencies] # gRPC proto 파일 컴파일러 tonic-prost-build = "0.14"
build.rsproto/occult.proto 파일에 정의한 gRPC 서비스 명세를
Rust 코드로 자동 변환하는 코드를 작성한다.
rust-engine/build.rsuse std::env; use std::path::PathBuf; fn main() -> Result<(), Box<dyn std::error::Error>> { let proto_file = "../proto/occult.proto"; let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); // proto 파일이 변경될 때만 재빌드를 트리거하도록 설정합니다. println!("cargo:rerun-if-changed={}", proto_file); tonic_prost_build::configure() .type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]") .file_descriptor_set_path(out_dir.join("occult_descriptor.bin")) .compile_protos(&[proto_file], &["../proto"])?; Ok(()) }
config.rs안전한 서버 구동을 위해 애플리케이션 시작 시점에 환경 변수를 읽어오고
필수 값이 없으면 즉시 Panic을 발생시키는 Fail-fast 패턴을 적용한다.
rust-engine/src/config.rsuse std::env; #[derive(Debug, Clone)] pub struct AppConfig { pub database_url: String, pub grpc_port: u16, } impl AppConfig { pub fn from_env() -> Self { dotenvy::dotenv().ok(); let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| { panic!("DATABASE_URL 환경 변수가 설정되지 않았습니다. (.env 파일을 확인하세요)"); }); let grpc_port_str = env::var("GRPC_PORT").unwrap_or_else(|_| "50051".to_string()); let grpc_port = grpc_port_str.parse::<u16>().unwrap_or_else(|_| { panic!("GRPC_PORT는 유효한 u16 숫자여야 합니다."); }); Self { database_url, grpc_port, } } }
occult.rs자동 생성된 코드를 메인 로직에 직접 우겨넣기보다는
별도의 모듈로 깔끔하게 분리하는 것이 낫다.
rust-engine/src/occult.rspub mod occult { include!(concat!(env!("OUT_DIR"), "/occult.rs")); }
db.rsSQL문을 사용하여 DB와 실제적인 통신을 하는 코드를 작성한다.
rust-engine/src/db.rsuse crate::occult::{EdgePath, Node}; use sqlx::{PgPool, FromRow}; use tracing::instrument; use uuid::Uuid; /// 데이터베이스에서 조회한 노드의 내부 모델 #[derive(Debug, FromRow)] pub struct NodeEntity { pub id: Uuid, pub name: String, pub entity_type: String, pub attributes: sqlx::types::JsonValue, } /// 데이터베이스에서 조회한 경로의 내부 모델 #[derive(Debug, FromRow)] pub struct PathEntity { pub parent_node_id: Uuid, pub child_node_id: Uuid, pub relation_type: String, pub depth: i32, pub weight: f32, } /// 데이터베이스 커넥션 풀 초기화 pub async fn init_pool(database_url: &str) -> Result<PgPool, sqlx::Error> { let pool = PgPool::connect(database_url).await?; tracing::info!("PostgreSQL 커넥션 풀 연결 성공"); Ok(pool) } /// 이름으로 단일 노드의 UUID 조회 pub async fn find_node_id_by_name(pool: &PgPool, name: &str) -> Result<Uuid, sqlx::Error> { let record = sqlx::query!("SELECT id FROM nodes WHERE name = $1 LIMIT 1", name) .fetch_one(pool) .await?; Ok(record.id) } /// 식별자(UUID/이름)로 단일 노드의 정보 조회 pub async fn get_node(pool: &PgPool, identifier: &str) -> Result<Node, sqlx::Error> { let id = match Uuid::parse_str(identifier) { Ok(uuid) => uuid, Err(_) => find_node_id_by_name(pool, identifier).await?, }; let record = sqlx::query_as::<_, NodeEntity>( "SELECT id, name, entity_type, attributes FROM nodes WHERE id = $1" ) .bind(id) .fetch_one(pool) .await?; Ok(Node { id: record.id.to_string(), name: record.name, entity_type: record.entity_type, attributes_json: record.attributes.to_string(), }) } /// 검색어로 노드 목록 조회 pub async fn search_nodes( pool: &PgPool, query: &str, entity_type_filter: &str, limit: i32, ) -> Result<Vec<Node>, sqlx::Error> { let search_pattern = format!("%{}%", query); let records = if entity_type_filter.is_empty() { sqlx::query_as::<_, NodeEntity>( // name 뿐만 아니라 attributes(JSONB)를 text로 변환하여 내부 값까지 검색 "SELECT id, name, entity_type, attributes FROM nodes WHERE name ILIKE $1 OR attributes::text ILIKE $1 LIMIT $2" ) .bind(&search_pattern) .bind(limit) .fetch_all(pool) .await? } else { sqlx::query_as::<_, NodeEntity>( // AND 조건 연산자 우선순위를 위해 괄호() 처리 필수 "SELECT id, name, entity_type, attributes FROM nodes WHERE (name ILIKE $1 OR attributes::text ILIKE $1) AND entity_type = $2 LIMIT $3" ) .bind(&search_pattern) .bind(entity_type_filter) .bind(limit) .fetch_all(pool) .await? }; let nodes = records .into_iter() .map(|r| Node { id: r.id.to_string(), name: r.name, entity_type: r.entity_type, attributes_json: r.attributes.to_string(), }) .collect(); Ok(nodes) } /// 주어진 시작 노드부터 그래프 재귀적 탐색 (Recursive CTE) /// bottom_up이 true일 경우 자식에서 부모로 역추적 #[instrument(skip(pool), err)] pub async fn traverse_graph( pool: &PgPool, start_id: Uuid, max_depth: i32, bottom_up: bool, ) -> Result<(Vec<Node>, Vec<EdgePath>), sqlx::Error> { // 방향에 따라 탐색에 사용할 조인(Join) 조건 설정 let join_condition = if bottom_up { "e.child_node_id = pt.current_node" } else { "e.parent_node_id = pt.current_node" }; let next_node = if bottom_up { "e.parent_node_id" } else { "e.child_node_id" }; // 재귀 CTE 쿼리 구성 let query = format!( r#" WITH RECURSIVE path_tree AS ( -- Base Case: 깊이 0 (시작 노드) SELECT $1::uuid AS current_node, $1::uuid AS parent_node_id, $1::uuid AS child_node_id, 'START'::varchar AS relation_type, 0 AS depth, 0.0::real AS weight UNION ALL -- Recursive Step: 간선을 타고 탐색 SELECT {} AS current_node, e.parent_node_id, e.child_node_id, e.relation_type, pt.depth + 1, e.weight FROM edges e INNER JOIN path_tree pt ON {} WHERE pt.depth < $2 ) -- 결과 반환 시 시작점 더미 데이터(depth=0)는 제외 SELECT parent_node_id, child_node_id, relation_type, depth, weight FROM path_tree WHERE depth > 0; "#, next_node, join_condition ); // 간선(Edge) 경로 쿼리 실행 let db_paths: Vec<PathEntity> = sqlx::query_as(&query) .bind(start_id) .bind(max_depth) .fetch_all(pool) .await?; // 관련된 모든 고유 노드 ID 추출 let mut node_ids = vec![start_id]; for p in &db_paths { node_ids.push(p.parent_node_id); node_ids.push(p.child_node_id); } node_ids.sort(); node_ids.dedup(); // 고유 ID 배열을 이용하여 노드 상세 정보 한 번에 조회 (= ANY) let db_nodes: Vec<NodeEntity> = sqlx::query_as( r#" SELECT id, name, entity_type, attributes FROM nodes WHERE id = ANY($1) "# ) .bind(&node_ids) .fetch_all(pool) .await?; // Protobuf 모델로 변환 let nodes = db_nodes.into_iter().map(|n| Node { id: n.id.to_string(), name: n.name, entity_type: n.entity_type, attributes_json: n.attributes.to_string(), }).collect(); let paths = db_paths.into_iter().map(|p| EdgePath { parent_id: p.parent_node_id.to_string(), child_id: p.child_node_id.to_string(), relation_type: p.relation_type, depth: p.depth, weight: p.weight, }).collect(); Ok((nodes, paths)) }
service.rsdb.rs 의 함수들을 활용하여 API를 처리하는 코드를 작성한다.
rust-engine/src/service.rsuse crate::db; use crate::occult::occult_knowledge_server::OccultKnowledge; use crate::occult::{ GetNodeRequest, GetNodeResponse, SearchNodesRequest, SearchNodesResponse, TraversalRequest, TraversalResponse, }; use sqlx::PgPool; use tonic::{Request, Response, Status}; use uuid::Uuid; pub struct OccultServiceImpl { pool: PgPool, } impl OccultServiceImpl { pub fn new(pool: PgPool) -> Self { Self { pool } } } #[tonic::async_trait] impl OccultKnowledge for OccultServiceImpl { /// 단건 조회 API async fn get_node( &self, request: Request<GetNodeRequest>, ) -> Result<Response<GetNodeResponse>, Status> { let req = request.into_inner(); let identifier = req.identifier.trim(); if identifier.is_empty() { return Err(Status::invalid_argument("식별자가 비어 있습니다.")); } match db::get_node(&self.pool, identifier).await { Ok(node) => Ok(Response::new(GetNodeResponse { node: Some(node) })), Err(e) => { tracing::error!("GetNode 에러 ({}): {:?}", identifier, e); Err(Status::not_found("노드를 찾을 수 없습니다.")) } } } /// 검색 API async fn search_nodes( &self, request: Request<SearchNodesRequest>, ) -> Result<Response<SearchNodesResponse>, Status> { let req = request.into_inner(); let limit = if req.limit <= 0 || req.limit > 100 { 10 } else { req.limit }; match db::search_nodes(&self.pool, &req.query, &req.entity_type_filter, limit).await { Ok(nodes) => Ok(Response::new(SearchNodesResponse { nodes })), Err(e) => { tracing::error!("SearchNodes 에러 ({}): {:?}", req.query, e); Err(Status::internal("검색 중 오류가 발생했습니다.")) } } } /// 그래프 탐색 API async fn traverse_graph( &self, request: Request<TraversalRequest>, ) -> Result<Response<TraversalResponse>, Status> { let req = request.into_inner(); let identifier = req.start_node_identifier.trim(); tracing::info!( "탐색 요청: 시작 = {}, 깊이 = {}, Bottom-Up = {}", identifier, req.max_depth, req.bottom_up ); // UUID 파싱 또는 이름 기반 조회 let start_uuid = match Uuid::parse_str(identifier) { Ok(id) => id, Err(_) => match db::find_node_id_by_name(&self.pool, identifier).await { Ok(id) => id, Err(e) => { tracing::error!("시작 노드를 찾을 수 없습니다 ({}): {:?}", identifier, e); return Err(Status::not_found("시작 노드를 찾을 수 없습니다.")); } }, }; // 깊이 제한 let max_depth = if req.max_depth <= 0 || req.max_depth > 20 { 5 } else { req.max_depth }; // DB 탐색 호출 let (nodes, paths) = db::traverse_graph(&self.pool, start_uuid, max_depth, req.bottom_up) .await .map_err(|e| { tracing::error!("그래프 탐색 중 DB 에러: {:?}", e); Status::internal("데이터베이스 탐색 실패") })?; Ok(Response::new(TraversalResponse { nodes, paths })) } }
main.rs
rust-engine/src/main.rsmod config; mod db; mod pb; mod service; use config::AppConfig; use crate::pb::occult; use std::net::SocketAddr; use tokio::signal; use tracing::Level; use tracing_subscriber::FmtSubscriber; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let subscriber = FmtSubscriber::builder() .with_max_level(Level::INFO) .finish(); tracing::subscriber::set_global_default(subscriber) .expect("로깅 시스템 초기화 실패"); tracing::info!("Occult Graph Core Engine 구동을 시작합니다 (Rust 2024)"); // 환경 변수 로드 (.env 파일 또는 시스템 환경변수) let config = AppConfig::from_env(); // PostgreSQL 커넥션 풀 초기화 tracing::info!("데이터베이스 연결 시도: {}", config.database_url); let pool = db::init_pool(&config.database_url).await?; // gRPC 서비스 구현체 생성 및 서버 라우팅 바인딩 let addr: SocketAddr = format!("0.0.0.0:{}", config.grpc_port).parse()?; // 비즈니스 로직이 담긴 서비스 생성 let occult_service = service::OccultServiceImpl::new(pool.clone()); // tonic이 생성한 gRPC 서버 래퍼에 서비스를 주입 let svc = occult::occult_knowledge_server::OccultKnowledgeServer::new(occult_service); tracing::info!("gRPC 서버가 {} 포트에서 요청을 대기합니다.", config.grpc_port); // 서버 실행 및 Graceful Shutdown 대기 tonic::transport::Server::builder() .add_service(svc) .serve_with_shutdown(addr, shutdown_signal()) .await?; // 종료 시그널 수신 후 리소스 안전 정리 (Graceful Shutdown) tracing::info!("서버가 안전하게 종료되었습니다. 데이터베이스 커넥션 풀을 반환합니다."); pool.close().await; Ok(()) } async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c().await.expect("Ctrl+C 리스너 설치 실패"); }; #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("SIGTERM 리스너 설치 실패") .recv() .await; }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { _ = ctrl_c => { tracing::warn!("SIGINT (Ctrl+C) 수신: 안전한 종료를 시작합니다."); }, _ = terminate => { tracing::warn!("SIGTERM 수신: 안전한 종료를 시작합니다."); }, } }
PostgreSQL가 돌아가고 있는 docker를 실행 중인 환경에서 진행한다.
~/workspace/occult-graph/rust-engine$ cargo run --release
새 터미널을 열고 작동 테스트를 수행한다.
~/workspace/occult-graph/rust-engine$ rpcurl -plaintext -import-path ../proto -proto occult.proto \ -d '{"identifier": "Bael"}' \ localhost:50051 occult.OccultKnowledge/GetNode { "node": { "id": "019e6bd2-7511-73e5-ae3e-f7029acaeb43", "name": "Bael", "entityType": "Demon", "attributesJson": "{\"goetia_number\":1,\"rank\":\"King\"}" } }
~/workspace/occult-graph/rust-engine$ grpcurl -plaintext -import-path ../proto -proto occult.proto \ -d '{"query": "Fire", "limit": 5}' \ localhost:50051 occult.OccultKnowledge/SearchNodes { "nodes": [ { "id": "019e6bd2-74e5-7314-9d7e-9afed6e520c6", "name": "Fire", "entityType": "Element", "attributesJson": "{\"category\":\"Classical\",\"tattva\":\"Tejas\"}" }, { "id": "019e6bd2-7505-7ac8-93ff-df4a6a0851cf", "name": "Aries", "entityType": "Zodiac", "attributesJson": "{\"element\":\"Fire\",\"modality\":\"Cardinal\"}" }, { "id": "019e6bd2-7506-74a5-8139-c938734beb4d", "name": "Leo", "entityType": "Zodiac", "attributesJson": "{\"element\":\"Fire\",\"modality\":\"Fixed\"}" }, { "id": "019e6bd2-7507-7015-ba3c-b8933955eff0", "name": "Sagittarius", "entityType": "Zodiac", "attributesJson": "{\"element\":\"Fire\",\"modality\":\"Mutable\"}" } ] }
~/workspace/occult-graph/rust-engine$ grpcurl -plaintext -import-path ../proto -proto occult.proto \ -d '{"start_node_identifier": "5 of Wands", "max_depth": 5, "bottom_up": false}' \ localhost:50051 occult.OccultKnowledge/TraverseGraph { "nodes": [ { "id": "019e6bd2-74e5-7314-9d7e-9afed6e520c6", "name": "Fire", "entityType": "Element", "attributesJson": "{\"category\":\"Classical\",\"tattva\":\"Tejas\"}" }, { "id": "019e6bd2-74eb-7e1c-a747-8aef7f5c7fde", "name": "Geburah", "entityType": "Sephirah", "attributesJson": "{\"meaning\":\"Severity\",\"number\":5}" }, { "id": "019e6bd2-74ec-716a-b0ec-34730e4efa8d", "name": "Tiferet", "entityType": "Sephirah", "attributesJson": "{\"meaning\":\"Beauty\",\"number\":6}" }, { "id": "019e6bd2-74ec-77ee-b5ed-fef9379b8822", "name": "Hod", "entityType": "Sephirah", "attributesJson": "{\"meaning\":\"Glory\",\"number\":8}" }, { "id": "019e6bd2-74f1-72ce-95b3-bc93a447385a", "name": "Justice", "entityType": "Tarot", "attributesJson": "{\"arcana\":\"Major\",\"hebrew_letter\":\"Lamed\",\"number\":11}" }, { "id": "019e6bd2-74f1-75b5-9c67-28d11e7b6f11", "name": "The Hanged Man", "entityType": "Tarot", "attributesJson": "{\"arcana\":\"Major\",\"hebrew_letter\":\"Mem\",\"number\":12}" }, { "id": "019e6bd2-74f6-7909-aa09-1301ad9df3a1", "name": "5 of Wands", "entityType": "Tarot", "attributesJson": "{\"arcana\":\"Minor\",\"value\":5}" }, { "id": "019e6bd2-7501-7eea-aa02-12b355c7f5ee", "name": "Leo Decan 1", "entityType": "ZodiacDecan", "attributesJson": "{\"decan\":1,\"sign\":\"Leo\"}" }, { "id": "019e6bd2-7506-74a5-8139-c938734beb4d", "name": "Leo", "entityType": "Zodiac", "attributesJson": "{\"element\":\"Fire\",\"modality\":\"Fixed\"}" }, { "id": "019e6bd2-7509-73ed-abf7-2ea3ca1b0457", "name": "Path 22 (Lamed)", "entityType": "TreePath", "attributesJson": "{\"hebrew_letter\":\"Lamed\",\"path_number\":22}" }, { "id": "019e6bd2-7509-7632-be67-7cb86414eb9d", "name": "Path 23 (Mem)", "entityType": "TreePath", "attributesJson": "{\"hebrew_letter\":\"Mem\",\"path_number\":23}" }, { "id": "019e6bd2-7509-7871-a8e1-c655623ebad1", "name": "Path 24 (Nun)", "entityType": "TreePath", "attributesJson": "{\"hebrew_letter\":\"Nun\",\"path_number\":24}" }, { "id": "019e6bd2-7509-7ac1-b08c-b62fdfe013b2", "name": "Path 25 (Samekh)", "entityType": "TreePath", "attributesJson": "{\"hebrew_letter\":\"Samekh\",\"path_number\":25}" }, { "id": "019e6bd2-7509-7d3a-90ac-616ad90e1825", "name": "Path 26 (Ayin)", "entityType": "TreePath", "attributesJson": "{\"hebrew_letter\":\"Ayin\",\"path_number\":26}" }, { "id": "019e6bd2-750a-7662-9faf-e7a313916482", "name": "Path 30 (Resh)", "entityType": "TreePath", "attributesJson": "{\"hebrew_letter\":\"Resh\",\"path_number\":30}" }, { "id": "019e6bd2-750a-7915-858d-7b6346367960", "name": "Path 31 (Shin)", "entityType": "TreePath", "attributesJson": "{\"hebrew_letter\":\"Shin\",\"path_number\":31}" }, { "id": "019e6bd2-750b-776d-bd2f-41ad9d364239", "name": "Geburah of Atziluth", "entityType": "Sephirah", "attributesJson": "{\"number\":5,\"world\":\"Atziluth\"}" } ], "paths": [ { "parentId": "019e6bd2-74f6-7909-aa09-1301ad9df3a1", "childId": "019e6bd2-74e5-7314-9d7e-9afed6e520c6", "relationType": "BELONGS_TO_ELEMENT", "depth": 1, "weight": 1 }, { "parentId": "019e6bd2-74f6-7909-aa09-1301ad9df3a1", "childId": "019e6bd2-750b-776d-bd2f-41ad9d364239", "relationType": "MANIFESTS_IN", "depth": 1, "weight": 1.5 }, { "parentId": "019e6bd2-74f6-7909-aa09-1301ad9df3a1", "childId": "019e6bd2-7501-7eea-aa02-12b355c7f5ee", "relationType": "RULES_DECAN", "depth": 1, "weight": 2 }, { "parentId": "019e6bd2-7501-7eea-aa02-12b355c7f5ee", "childId": "019e6bd2-7506-74a5-8139-c938734beb4d", "relationType": "PART_OF_SIGN", "depth": 2, "weight": 1 }, { "parentId": "019e6bd2-750b-776d-bd2f-41ad9d364239", "childId": "019e6bd2-74eb-7e1c-a747-8aef7f5c7fde", "relationType": "MANIFESTATION_OF", "depth": 2, "weight": 1.5 }, { "parentId": "019e6bd2-74eb-7e1c-a747-8aef7f5c7fde", "childId": "019e6bd2-7509-73ed-abf7-2ea3ca1b0457", "relationType": "FLOWS_INTO_PATH", "depth": 3, "weight": 1 }, { "parentId": "019e6bd2-74eb-7e1c-a747-8aef7f5c7fde", "childId": "019e6bd2-7509-7632-be67-7cb86414eb9d", "relationType": "FLOWS_INTO_PATH", "depth": 3, "weight": 1 }, { "parentId": "019e6bd2-7509-73ed-abf7-2ea3ca1b0457", "childId": "019e6bd2-74ec-716a-b0ec-34730e4efa8d", "relationType": "LEADS_TO_SEPHIRAH", "depth": 4, "weight": 1 }, { "parentId": "019e6bd2-7509-73ed-abf7-2ea3ca1b0457", "childId": "019e6bd2-74f1-72ce-95b3-bc93a447385a", "relationType": "EMBODIES_TAROT", "depth": 4, "weight": 1 }, { "parentId": "019e6bd2-7509-7632-be67-7cb86414eb9d", "childId": "019e6bd2-74ec-77ee-b5ed-fef9379b8822", "relationType": "LEADS_TO_SEPHIRAH", "depth": 4, "weight": 1 }, { "parentId": "019e6bd2-7509-7632-be67-7cb86414eb9d", "childId": "019e6bd2-74f1-75b5-9c67-28d11e7b6f11", "relationType": "EMBODIES_TAROT", "depth": 4, "weight": 1 }, { "parentId": "019e6bd2-74ec-716a-b0ec-34730e4efa8d", "childId": "019e6bd2-7509-7871-a8e1-c655623ebad1", "relationType": "FLOWS_INTO_PATH", "depth": 5, "weight": 1 }, { "parentId": "019e6bd2-74ec-716a-b0ec-34730e4efa8d", "childId": "019e6bd2-7509-7ac1-b08c-b62fdfe013b2", "relationType": "FLOWS_INTO_PATH", "depth": 5, "weight": 1 }, { "parentId": "019e6bd2-74ec-716a-b0ec-34730e4efa8d", "childId": "019e6bd2-7509-7d3a-90ac-616ad90e1825", "relationType": "FLOWS_INTO_PATH", "depth": 5, "weight": 1 }, { "parentId": "019e6bd2-74ec-77ee-b5ed-fef9379b8822", "childId": "019e6bd2-750a-7662-9faf-e7a313916482", "relationType": "FLOWS_INTO_PATH", "depth": 5, "weight": 1 }, { "parentId": "019e6bd2-74ec-77ee-b5ed-fef9379b8822", "childId": "019e6bd2-750a-7915-858d-7b6346367960", "relationType": "FLOWS_INTO_PATH", "depth": 5, "weight": 1 } ] }