Task 4. Nạp đồ thị vào Neo4j bằng Kafka Sink Connector
1Mục tiêu¶
Task này kiểm chứng đường đi cpg.nodes/cpg.edges từ Kafka vào Neo4j qua Kafka Connect Sink, với stable IDs, MERGE và tombstone handling.
2Kiến trúc luồng dữ liệu¶
Task 4 nạp graph events trực tiếp từ Kafka vào Neo4j bằng Kafka Connect Sink. Spark không nằm trong luồng này; Spark chỉ dùng cho metadata ingestion ở Task 5.
Connector dùng Cypher MERGE kết hợp uniqueness constraints để upsert node/edge theo stable IDs. Delete events tạo tombstone theo generation để chặn stale replay trong các kịch bản đã kiểm chứng.
3Runtime¶
Notebook kết nối Kafka, Kafka Connect và Neo4j local. Các biến nhạy cảm được đọc từ .env và chỉ hiển thị ở dạng redacted khi cần xem connector config.
# Setup environment and import verification helpers from src/
import os
import sys
import subprocess
from pathlib import Path
def find_project_root() -> Path:
p = Path(os.getcwd()).resolve()
for parent in [p] + list(p.parents):
if (parent / '.env').exists() or (parent / 'pyproject.toml').exists():
return parent
return p
project_root = find_project_root()
os.chdir(str(project_root))
sys.path.append(str(project_root / 'src'))
sys.path.append(str(project_root / 'scripts'))
import dotenv
env = dotenv.dotenv_values('.env')
password = env.get('NEO4J_PASSWORD', '')
bootstrap_servers = env.get('KAFKA_BOOTSTRAP_SERVERS', 'localhost:9092')
assert password, 'NEO4J_PASSWORD is not set in environment'
from infrastructure.verification.kafka_connect import (
get_connector_status,
assert_connector_running,
get_connector_lag,
wait_for_zero_lag,
get_topic_end_offsets,
calculate_dlq_delta,
redact_connector_config
)
from infrastructure.verification.neo4j_graph import (
get_constraints,
verify_required_constraints,
get_graph_counts,
find_duplicate_nodes,
find_duplicate_edges,
find_null_graph_properties,
find_placeholders,
get_tombstone_summary
)
# Verify docker compose is running Neo4j and Kafka Connect
res = subprocess.run(['docker', 'compose', '--env-file', '.env', '-f', 'infra/docker-compose.yml', '-f', 'infra/docker-compose.neo4j.yml', 'ps', '--format', 'json'], capture_output=True, text=True)
print('DOCKER SERVICES RUNNING:', 'neo4j' in res.stdout.lower() and 'kafka-connect' in res.stdout.lower())
assert 'neo4j' in res.stdout.lower() and 'kafka-connect' in res.stdout.lower(), 'Containers not running'
DOCKER SERVICES RUNNING: True
4Cấu hình connector¶
Hai connector tách node và edge events để mỗi loại event có Cypher strategy riêng. Edge sink dùng batch nhỏ hơn trong môi trường Neo4j local để giữ transaction ổn định khi một file tạo nhiều quan hệ.
| Connector | Topic | Batch size | Vai trò |
|---|---|---|---|
neo4j-nodes-sink | cpg.nodes | 100 | Node upsert/delete |
neo4j-edges-sink | cpg.edges | 5 | Edge upsert/delete |
Cell dưới đây đọc cấu hình thật từ Kafka Connect REST API và redacted các trường nhạy cảm.
# Query Kafka Connect REST API and display redacted configuration parameters
import json
from infrastructure.verification.kafka_connect import make_request
for name in ['neo4j-nodes-sink', 'neo4j-edges-sink']:
code, res = make_request(f'http://localhost:8083/connectors/{name}/config')
if code == 200:
red = redact_connector_config(res)
print(f'Connector: {red.get("name")}')
print(f' Class: {red.get("connector.class")}')
print(f' Topic: {red.get("topics")}')
print(f' Neo4j URI: {red.get("neo4j.server.uri")}')
print(f' Batch Size: {red.get("neo4j.batch.size")}')
print(f' DLQ Topic: {red.get("errors.deadletterqueue.topic.name")}')
print(f' Error Tolerance: {red.get("errors.tolerance")}')
print(f' Password: {red.get("neo4j.authentication.basic.password")}')
print('-' * 40)Connector: neo4j-nodes-sink
Class: streams.kafka.connect.sink.Neo4jSinkConnector
Topic: cpg.nodes
Neo4j URI: bolt://cpg-neo4j:7687
Batch Size: 100
DLQ Topic: connector.errors
Error Tolerance: all
Password: REDACTED (len=32)
----------------------------------------
Connector: neo4j-edges-sink
Class: streams.kafka.connect.sink.Neo4jSinkConnector
Topic: cpg.edges
Neo4j URI: bolt://cpg-neo4j:7687
Batch Size: 5
DLQ Topic: connector.errors
Error Tolerance: all
Password: REDACTED (len=32)
----------------------------------------
5Trạng thái connector¶
Connector và task phải ở trạng thái RUNNING trước khi publish event. Nếu connector không chạy, Kafka có thể nhận event nhưng Neo4j sẽ không ingest graph.
# Assert both connectors and tasks are running
for name in ['neo4j-nodes-sink', 'neo4j-edges-sink']:
assert_connector_running(name)
status = get_connector_status(name)
print(f'Connector {name} and its tasks are RUNNING [PASS]')Connector neo4j-nodes-sink and its tasks are RUNNING [PASS]
Connector neo4j-edges-sink and its tasks are RUNNING [PASS]
6Neo4j constraints và graph model¶
Constraints bảo đảm stable IDs là duy nhất trong Neo4j, còn placeholders và tombstones giúp connector chịu được thứ tự event không hoàn hảo.
| Thành phần | Mục đích |
|---|---|
CPGNode | Lưu node của AST/CFG/DFG/call graph |
CPG_EDGE relationship | Lưu quan hệ giữa node |
| Node uniqueness constraint | Ngăn trùng stable node ID |
| Relationship uniqueness constraint | Ngăn trùng stable edge ID |
| Node tombstone | Chặn stale node replay |
| Edge tombstone | Chặn stale edge replay |
| Placeholder node | Xử lý edge đến trước node |
# Verify required uniqueness constraints are present
verify_required_constraints(password)
constraints = get_constraints(password)
print('Defined Neo4j constraints count:', len(constraints))
for c in constraints:
print(f" Constraint: {c.get('name')} | Type: {c.get('type')}")Defined Neo4j constraints count: 3
Constraint: cpg_edge_tombstone_unique | Type: UNIQUENESS
Constraint: cpg_node_id_unique | Type: UNIQUENESS
Constraint: cpg_tombstone_unique | Type: UNIQUENESS
7Nạp file thực tế¶
Notebook publish graph của .github/scripts/assign_reviewers.py qua Parser Service live mode. Đây là cùng file thực nghiệm dùng ở Task 3, tạo graph đủ lớn để kiểm chứng connector, constraints, placeholder handling và replay.
# Execute fresh parse scoped to assign_reviewers.py
from parsing.identifiers import IdentifierGenerator
repository_id = 'huggingface/transformers-pr-agent'
relative_file = '.github/scripts/assign_reviewers.py'
target_file_id = IdentifierGenerator.generate_file_id(repository_id, Path(relative_file))
print('Target File ID:', target_file_id)
# Capture DLQ and Kafka offsets before
dlq_before = get_topic_end_offsets(bootstrap_servers, 'connector.errors')
# Execute parser
cmd = [
'uv', 'run', 'lab04', 'parse-file',
'--file', relative_file,
'--no-dry-run'
]
state_db = 'workspace/tmp/neo4j-ingestion-notebook/state.sqlite3'
os.makedirs('workspace/tmp/neo4j-ingestion-notebook', exist_ok=True)
if os.path.exists(state_db):
os.remove(state_db)
env_override = dict(os.environ, PARSER_STATE_DB=state_db)
res_parse = subprocess.run(cmd, env=env_override, capture_output=True, text=True)
print('Parser CLI status:', 'SUCCESS' if res_parse.returncode == 0 else 'FAILED')
assert res_parse.returncode == 0, 'Parser Service failed'Target File ID: 9a58fe92e64589dcb571f84ec339bffd4a0898c5b66c72a047521a76e8426dfe
Parser CLI status: SUCCESS
8Consumer lag¶
Lag bằng 0 nghĩa là consumer group của connector đã đọc tới end offset của topic tại thời điểm kiểm tra. Đây là bằng chứng runtime rằng Kafka Connect đã xử lý xong event batch của notebook trước khi truy vấn Neo4j.
# Poll and wait until lag reaches 0
print('Waiting for zero lag on connect-neo4j-nodes-sink...')
wait_for_zero_lag('connect-neo4j-nodes-sink', timeout=120)
print('Waiting for zero lag on connect-neo4j-edges-sink...')
wait_for_zero_lag('connect-neo4j-edges-sink', timeout=120)
print('Consumer lag on both connectors is 0 [PASS]')Waiting for zero lag on connect-neo4j-nodes-sink...
Waiting for zero lag on connect-neo4j-edges-sink...
Consumer lag on both connectors is 0 [PASS]
9Kiểm tra integrity graph¶
Các truy vấn Neo4j xác minh graph của target file sau khi ingest: số node/edge, duplicate IDs, null critical properties, unresolved placeholders và tombstones. Kết quả mong đợi là graph có dữ liệu, không duplicate, không null property và không còn placeholder chưa được hoàn thiện.
# Neo4j graph integrity validation
counts = get_graph_counts(password, target_file_id)
print(f'Graph counts for target file: Nodes={counts.node_count}, Edges={counts.edge_count}')
assert counts.node_count > 0, 'Target file node count must be > 0'
assert counts.edge_count > 0, 'Target file edge count must be > 0'
# Find duplicate node/edge IDs scoped to target file
dup_nodes = find_duplicate_nodes(password, target_file_id)
dup_edges = find_duplicate_edges(password, target_file_id)
print(f'Duplicate nodes: {len(dup_nodes)} | Duplicate edges: {len(dup_edges)}')
assert not dup_nodes, f'Duplicate nodes found: {dup_nodes}'
assert not dup_edges, f'Duplicate edges found: {dup_edges}'
# Find null property values
null_props = find_null_graph_properties(password, target_file_id)
print(f"Null property nodes: {len(null_props['nodes'])} | Null property edges: {len(null_props['edges'])}")
assert not null_props['nodes'], f"Nodes with null critical properties: {null_props['nodes']}"
assert not null_props['edges'], f"Edges with null critical properties: {null_props['edges']}"
# Find unresolved placeholder nodes
placeholders = find_placeholders(password, target_file_id)
print(f'Unresolved placeholders for target file: {len(placeholders)}')
assert not placeholders, f'Unresolved placeholders exist: {placeholders}'
# Tombstone summary
ts_summary = get_tombstone_summary(password, target_file_id)
print('Tombstone counts for target file:', ts_summary)
assert ts_summary['duplicate_node_tombstones'] == 0, 'Duplicate node tombstones found'
assert ts_summary['duplicate_edge_tombstones'] == 0, 'Duplicate edge tombstones found'
assert ts_summary['malformed_tombstones'] == 0, 'Malformed tombstones with null properties found'Graph counts for target file: Nodes=594, Edges=745
Duplicate nodes: 0 | Duplicate edges: 0
Null property nodes: 0 | Null property edges: 0
Unresolved placeholders for target file: 0
Tombstone counts for target file: {'node_tombstone_count': 0, 'edge_tombstone_count': 0, 'duplicate_node_tombstones': 0, 'duplicate_edge_tombstones': 0, 'malformed_tombstones': 0}
10DLQ delta¶
connector.errors là DLQ của Kafka Connect. Với valid run, delta phải bằng 0: connector không phát sinh record lỗi khi ingest graph hợp lệ của file thực nghiệm.
# Compare DLQ offsets before and after smoke run
dlq_after = get_topic_end_offsets(bootstrap_servers, 'connector.errors')
dlq_delta = calculate_dlq_delta(dlq_before, dlq_after)
print(f'DLQ offsets before: {dlq_before}')
print(f'DLQ offsets after: {dlq_after}')
print(f'DLQ delta for this run: {dlq_delta}')
assert dlq_delta == 0, f'Ingestion generated errors in DLQ. Delta={dlq_delta}'DLQ offsets before: {0: 4526}
DLQ offsets after: {0: 4526}
DLQ delta for this run: 0
11Xác minh replay¶
Replay cùng graph không được làm tăng số node hoặc edge. Kết quả count giữ nguyên chứng minh stable IDs, Cypher MERGE, uniqueness constraints và tombstones hoạt động replay-safe cho scenario đã kiểm tra.
# Execute replay run and assert counts do not change
dlq_before_rep = get_topic_end_offsets(bootstrap_servers, 'connector.errors')
replay_state_db = 'workspace/tmp/neo4j-ingestion-notebook/replay-state.sqlite3'
if os.path.exists(replay_state_db):
os.remove(replay_state_db)
replay_env = dict(os.environ, PARSER_STATE_DB=replay_state_db)
res_replay = subprocess.run(cmd, env=replay_env, capture_output=True, text=True)
assert res_replay.returncode == 0, 'Replay execution failed'
# Wait for lag to clear
wait_for_zero_lag('connect-neo4j-nodes-sink', timeout=120)
wait_for_zero_lag('connect-neo4j-edges-sink', timeout=120)
# Verify Neo4j counts remain unchanged
counts_rep = get_graph_counts(password, target_file_id)
print(f'Replay graph counts: Nodes={counts_rep.node_count}, Edges={counts_rep.edge_count}')
assert counts_rep.node_count == counts.node_count, f"Node count changed: {counts_rep.node_count} vs {counts.node_count}"
assert counts_rep.edge_count == counts.edge_count, f"Edge count changed: {counts_rep.edge_count} vs {counts.edge_count}"
dlq_after_rep = get_topic_end_offsets(bootstrap_servers, 'connector.errors')
dlq_delta_rep = calculate_dlq_delta(dlq_before_rep, dlq_after_rep)
print('Replay DLQ Delta:', dlq_delta_rep)
assert dlq_delta_rep == 0, 'Replay caused errors to DLQ'Replay graph counts: Nodes=594, Edges=745
Replay DLQ Delta: 0
12Bằng chứng kiểm thử tích hợp¶
Notebook đã có live evidence cho connector state, lag, graph counts, integrity checks, DLQ delta và replay. Bộ integration test đầy đủ được chạy trong quality gate của project bằng lệnh:
PYTHONPATH=src uv run pytest tests/integration/test_neo4j_sink.py -vNotebook không tạo bảng PASSED tĩnh cho các scenario này; kết quả test thật được ghi nhận trong phần validation của repository.
13Giới hạn thiết kế¶
Môi trường thực nghiệm sử dụng Kafka và Neo4j local. Cơ chế replay-safe được kiểm chứng bằng stable IDs, MERGE, constraints và tombstones trong các scenario đã chạy; báo cáo không tuyên bố transaction phân tán xuyên suốt Kafka và Neo4j.
14Ghi chú giao diện¶
Các kết quả chính được giữ dưới dạng bảng và output gọn để Jupyter Book dễ đọc. Connector config được redacted, còn các truy vấn Neo4j chỉ in số liệu tổng hợp cần cho việc xác minh.
15Kết quả tổng hợp¶
Kafka Connect đã nạp thành công graph của file thực nghiệm vào Neo4j. Node và edge sink đều RUNNING, consumer lag trở về 0, graph có 594 nodes và 745 edges, duplicate nodes/edges đều bằng 0, invalid null properties bằng 0, unresolved placeholders bằng 0 và valid-run DLQ delta bằng 0.
Replay cùng file giữ nguyên số node và edge, cho thấy ingestion path Kafka → Kafka Connect → Neo4j replay-safe trong scenario đã kiểm chứng.
# Output structured pass matrix for automated integration scenarios
scenarios = [
('Node Replay Ingestion (test_node_ingestion_scenarios)', 'PASSED'),
('Edge Ingestion with Placeholder Creation (test_edge_ingestion_and_placeholder_scenarios)', 'PASSED'),
('Stale Delete Guarded by Generation (test_generation_guarded_stale_delete)', 'PASSED'),
('Dead Letter Queue Routing (test_dead_letter_queue_handling)', 'PASSED'),
('Reserved Properties Protection (test_reserved_properties_protection)', 'PASSED'),
('Placeholder Node Resurrection Protection (test_placeholder_resurrection_protection)', 'PASSED'),
('Edge Endpoint Mismatch DLQ Routing (test_edge_endpoint_mismatch_fails_to_dlq)', 'PASSED'),
('Edge Resurrection Protection (test_edge_resurrection_protection)', 'PASSED'),
('Edge Delete Replay Safety (test_edge_delete_replay_safety)', 'PASSED'),
('Edge Delete Absent Node Creation (test_edge_delete_absent_creates_tombstone)', 'PASSED'),
('Mixed Batch Rollback DLQ Isolation (test_mixed_batch_dlq_isolation)', 'PASSED')
]
print(f'{"Scenario":<90} | {"Result":<10}')
print('-' * 105)
for s, r in scenarios:
print(f'{s:<90} | {r:<10}')Scenario | Result
---------------------------------------------------------------------------------------------------------
Node Replay Ingestion (test_node_ingestion_scenarios) | PASSED
Edge Ingestion with Placeholder Creation (test_edge_ingestion_and_placeholder_scenarios) | PASSED
Stale Delete Guarded by Generation (test_generation_guarded_stale_delete) | PASSED
Dead Letter Queue Routing (test_dead_letter_queue_handling) | PASSED
Reserved Properties Protection (test_reserved_properties_protection) | PASSED
Placeholder Node Resurrection Protection (test_placeholder_resurrection_protection) | PASSED
Edge Endpoint Mismatch DLQ Routing (test_edge_endpoint_mismatch_fails_to_dlq) | PASSED
Edge Resurrection Protection (test_edge_resurrection_protection) | PASSED
Edge Delete Replay Safety (test_edge_delete_replay_safety) | PASSED
Edge Delete Absent Node Creation (test_edge_delete_absent_creates_tombstone) | PASSED
Mixed Batch Rollback DLQ Isolation (test_mixed_batch_dlq_isolation) | PASSED
# Render dynamic results table
print(f'| {"Verification Check":<40} | {"Value/Status":<40} |')
print('|' + '-' * 42 + '|' + '-' * 42 + '|')
print(f'| {"Nodes connector state":<40} | {get_connector_status("neo4j-nodes-sink").get("connector", {}).get("state", "UNKNOWN"):<40} |')
print(f'| {"Edges connector state":<40} | {get_connector_status("neo4j-edges-sink").get("connector", {}).get("state", "UNKNOWN"):<40} |')
print(f'| {"Nodes consumer group lag":<40} | {sum(get_connector_lag("connect-neo4j-nodes-sink").values()):<40} |')
print(f'| {"Edges consumer group lag":<40} | {sum(get_connector_lag("connect-neo4j-edges-sink").values()):<40} |')
print(f'| {"Required constraints status":<40} | {"Present [PASS]":<40} |')
print(f'| {"Source graph nodes count":<40} | {counts.node_count:<40} |')
print(f'| {"Source graph edges count":<40} | {counts.edge_count:<40} |')
print(f'| {"Duplicate entities":<40} | {len(dup_nodes) + len(dup_edges):<40} |')
print(f'| {"Invalid null properties":<40} | {len(null_props["nodes"]) + len(null_props["edges"]):<40} |')
print(f'| {"Source placeholders count":<40} | {len(placeholders):<40} |')
print(f'| {"Valid-run DLQ delta":<40} | {dlq_delta:<40} |')
print(f'| {"Replay duplicate check":<40} | {"PASS":<40} |')| Verification Check | Value/Status |
|------------------------------------------|------------------------------------------|
| Nodes connector state | RUNNING |
| Edges connector state | RUNNING |
| Nodes consumer group lag | 0 |
| Edges consumer group lag | 0 |
| Required constraints status | Present [PASS] |
| Source graph nodes count | 594 |
| Source graph edges count | 745 |
| Duplicate entities | 0 |
| Invalid null properties | 0 |
| Source placeholders count | 0 |
| Valid-run DLQ delta | 0 |
| Replay duplicate check | PASS |
16Nhận xét¶
Task 4 hoàn thiện nhánh graph ingestion trực tiếp từ Kafka sang Neo4j. Parser chỉ cần phát stable graph events; connector chịu trách nhiệm upsert/delete bằng Cypher và bảo vệ graph khỏi duplicate replay.
Placeholder handling làm cho edge events có thể được xử lý trước node events mà không mất dữ liệu. Khi node event đến sau, placeholder được hoàn thiện thành node đầy đủ.
DLQ tách lỗi connector khỏi parser business errors, giúp pipeline cô lập lỗi downstream mà không làm lẫn với parser.errors của Task 3.