Task 5. Nạp metadata nguồn vào MongoDB bằng Spark
1Mục tiêu¶
Task 5 xây dựng pipeline Apache Spark Structured Streaming đọc event FILE_METADATA_UPSERT từ Kafka topic source.metadata và ghi metadata vào MongoDB bằng MongoDB Spark Connector. Pipeline sử dụng checkpoint để khôi phục Kafka offsets sau khi restart.
Topology được xác định theo infra/docker-compose.yml: Kafka chạy KRaft mode bằng confluentinc/cp-kafka:7.4.0, không sử dụng ZooKeeper; MongoDB chạy độc lập và Mongo Express là UI phục vụ evidence screenshot. Môi trường thực nghiệm được tái hiện bằng Docker với Kafka KRaft, MongoDB và Spark 3.3.0. Password được đọc từ .env nhưng không in ra output.
2Tiêu chí xác minh¶
Event được tạo bởi Parser Service thật và được validate bằng JSON Schema.
Kafka message có key bằng
file_id.Spark chỉ đọc metadata event của run hiện tại, không xử lý lịch sử của topic.
MongoDB Spark Connector ghi document vào
cpg_metadata.file_statistics.Checkpoint khôi phục Kafka offsets và restart không đọc lại event cũ.
Replay cùng
file_idcập nhật document, không tạo duplicate theo cả hai khóa nghiệp vụ.Query MongoDB sau restart xác nhận document không đổi.
3Chuẩn bị runtime¶
Mỗi lần chạy tạo RUN_ID mới, thư mục source tạm, SQLite state và checkpoint riêng. Notebook không xóa checkpoint cũ. Trước khi Parser Service publish, notebook đọc Kafka latest offset; Spark được cấu hình startingOffsets tại đúng offset đó, nên event đầu tiên của run là event duy nhất được xử lý trong batch baseline.
import json
import os
import re
import subprocess
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
PROJECT_ROOT = Path.cwd()
if PROJECT_ROOT.name == 'lab04-book':
PROJECT_ROOT = PROJECT_ROOT.parent
SRC_ROOT = PROJECT_ROOT / 'src'
if str(SRC_ROOT) not in sys.path:
sys.path.insert(0, str(SRC_ROOT))
os.chdir(PROJECT_ROOT)
def load_local_env(path: Path) -> None:
if not path.exists():
raise FileNotFoundError('Missing .env. Copy .env.example to .env first.')
for raw_line in path.read_text(encoding='utf-8').splitlines():
line = raw_line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
name, value = line.split('=', 1)
os.environ.setdefault(name.strip(), value.strip().strip('\"').strip("'"))
load_local_env(PROJECT_ROOT / '.env')
RUN_ID = os.environ.get('TASK5_NOTEBOOK_RUN_ID') or datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + '-' + uuid.uuid4().hex[:8]
RUN_ROOT = PROJECT_ROOT / 'workspace' / 'tmp' / 'task5-notebook' / RUN_ID
CHECKPOINT_DIR = PROJECT_ROOT / 'workspace' / 'checkpoints' / 'task5-notebook' / RUN_ID
if RUN_ROOT.exists() or CHECKPOINT_DIR.exists():
raise RuntimeError(f'Run ID already exists: {RUN_ID}. Choose a new TASK5_NOTEBOOK_RUN_ID.')
KAFKA_BOOTSTRAP = os.environ['KAFKA_BOOTSTRAP_SERVERS']
MONGO_URI = os.environ['MONGODB_URI']
MONGO_DATABASE = os.environ['MONGODB_DATABASE']
MONGO_COLLECTION = os.environ['MONGODB_COLLECTION']
MASKED_MONGO_URI = re.sub(r'//([^:]+):[^@]+@', r'//\1:***@', MONGO_URI)
print(f'RUN_ID={RUN_ID}')
print('KAFKA_TOPOLOGY=KRaft (no ZooKeeper)')
print(f'KAFKA_BOOTSTRAP_SERVERS={KAFKA_BOOTSTRAP}')
print(f'MONGODB_URI={MASKED_MONGO_URI}')
print(f'CHECKPOINT_DIR={CHECKPOINT_DIR.relative_to(PROJECT_ROOT).as_posix()}')RUN_ID=20260725T065530Z-ee8b8709
KAFKA_TOPOLOGY=KRaft (no ZooKeeper)
KAFKA_BOOTSTRAP_SERVERS=localhost:9092
MONGODB_URI=mongodb://root:***@localhost:27017/?authSource=admin
CHECKPOINT_DIR=workspace/checkpoints/task5-notebook/20260725T065530Z-ee8b8709
compose = subprocess.run(
['docker', 'compose', '--env-file', '.env', '-f', 'infra/docker-compose.yml', 'ps'],
capture_output=True, text=True, check=True, cwd=PROJECT_ROOT,
)
print(compose.stdout)
assert 'cpg-kafka' in compose.stdout and 'cpg-mongodb' in compose.stdout
required_topics = [os.environ['TOPIC_NODES'], os.environ['TOPIC_EDGES'], os.environ['TOPIC_METADATA'], os.environ['TOPIC_ERRORS']]
for topic_name in required_topics:
subprocess.run(
['docker', 'exec', 'cpg-kafka', 'kafka-topics', '--bootstrap-server', 'kafka:29092', '--create', '--if-not-exists', '--topic', topic_name, '--partitions', '1', '--replication-factor', '1'],
capture_output=True, text=True, check=True,
)
topic = subprocess.run(
['docker', 'exec', 'cpg-kafka', 'kafka-topics', '--bootstrap-server', 'kafka:29092', '--describe', '--topic', os.environ['TOPIC_METADATA']],
capture_output=True, text=True, check=True,
)
print(topic.stdout)
assert 'PartitionCount: 1' in topic.stdout
mongo_indexes = subprocess.run(
['docker', 'exec', 'cpg-mongodb', 'mongosh', '--quiet', '--username', os.environ['MONGO_ROOT_USERNAME'], '--password', os.environ['MONGO_ROOT_PASSWORD'], '--authenticationDatabase', 'admin', '--eval', "db=db.getSiblingDB('cpg_metadata'); printjson(db.file_statistics.getIndexes())"],
capture_output=True, text=True, check=True,
)
print(mongo_indexes.stdout)
assert "name: 'file_id_1'" in mongo_indexes.stdout
assert "name: 'repository_id_1_file_path_1'" in mongo_indexes.stdout
assert mongo_indexes.stdout.count('unique: true') >= 2NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
cpg-kafka confluentinc/cp-kafka:7.4.0 "/etc/confluent/dock…" kafka About an hour ago Up About an hour (healthy) 0.0.0.0:9092->9092/tcp, [::]:9092->9092/tcp
cpg-mongo-express mongo-express:1.0.2-20 "/sbin/tini -- /dock…" mongo-express About an hour ago Up About an hour 0.0.0.0:8081->8081/tcp, [::]:8081->8081/tcp
cpg-mongodb mongo:6.0.4 "docker-entrypoint.s…" mongodb 3 days ago Up About an hour 0.0.0.0:27017->27017/tcp, [::]:27017->27017/tcp
Topic: source.metadata TopicId: TcpqT62wR2aJXz00kabagQ PartitionCount: 1 ReplicationFactor: 1 Configs:
Topic: source.metadata Partition: 0 Leader: 1 Replicas: 1 Isr: 1
[
{
v: 2,
key: {
_id: 1
},
name: '_id_'
},
{
v: 2,
key: {
file_id: 1
},
name: 'file_id_1',
unique: true
},
{
v: 2,
key: {
repository_id: 1,
file_path: 1
},
name: 'repository_id_1_file_path_1',
unique: true
}
]
4Publish metadata event¶
Không viết tay envelope metadata. Notebook khởi tạo CpgParser, ProcessFileService, EventValidator, SqliteStateStore và KafkaEventProducer thật của project. Parser Service tự sinh repository_id, deterministic file_id, SHA-256 content_hash, parse_status=SUCCESS, metadata counts và schema envelope. KafkaEventProducer publish với key=file_id.
from application.services.process_file import ProcessFileService
from domain.models import SourceFile
from infrastructure.filesystem.git_source_repository import GitSourceRepository
from infrastructure.messaging.event_validator import EventValidator
from infrastructure.messaging.kafka_producer import KafkaEventProducer
from infrastructure.state.sqlite_state_store import SqliteStateStore
from parsing.cpg_parser import CpgParser
from parsing.identifiers import IdentifierGenerator
source_root = RUN_ROOT / 'source'
state_db = RUN_ROOT / 'state.sqlite3'
target_file = Path('src/task5_runtime.py')
repository_id = f'notebook/task5/{RUN_ID}'
initial_source = b'def normalize(value):\n total = value + 1\n return total\n'
modified_source = b'def normalize(value):\n total = value + 1\n if total > 10:\n return total * 2\n return total\n\nclass Normalizer:\n def apply(self, value):\n return normalize(value)\n'
source_path = source_root / target_file
source_path.parent.mkdir(parents=True, exist_ok=True)
source_path.write_bytes(initial_source)
repo_adapter = GitSourceRepository(source_root, '', None)
validator = EventValidator(PROJECT_ROOT / 'schemas')
state_store = SqliteStateStore(state_db, repository_id=repository_id)
producer = KafkaEventProducer(bootstrap_servers=KAFKA_BOOTSTRAP)
parser = CpgParser(repository_id=repository_id)
process_service = ProcessFileService(
repo_adapter, parser, state_store, validator, producer,
topic_nodes=os.environ['TOPIC_NODES'],
topic_edges=os.environ['TOPIC_EDGES'],
topic_metadata=os.environ['TOPIC_METADATA'],
topic_errors=os.environ['TOPIC_ERRORS'],
)
def make_source_file() -> SourceFile:
return SourceFile(repository_id, str(source_root), target_file.as_posix(), f'notebook-{RUN_ID}', source_path.stat().st_size)
def kafka_latest_offset() -> int:
result = subprocess.run(['docker', 'exec', 'cpg-kafka', 'kafka-run-class', 'kafka.tools.GetOffsetShell', '--broker-list', 'kafka:29092', '--topic', 'source.metadata', '--time', '-1'], capture_output=True, text=True, check=True)
return int(result.stdout.strip().rsplit(':', 1)[1])
baseline_latest_offset = kafka_latest_offset()
first_result = process_service.execute(make_source_file())
file_id = first_result.file_id
first_content_hash = first_result.content_hash
assert first_result.status.value == 'SUCCESS'
assert first_result.emitted_event_counts.get('source.metadata') == 1
print(json.dumps({'repository_id': repository_id, 'file_id': file_id, 'content_hash': first_content_hash, 'parse_status': 'SUCCESS', 'event_offset': baseline_latest_offset, 'emitted_event_counts': first_result.emitted_event_counts}, indent=2)){
"repository_id": "notebook/task5/20260725T065530Z-ee8b8709",
"file_id": "5e649b5adc42d93972928fe8890150105b41e30e5ed6ef6af00ccdeaa2583573",
"content_hash": "e7f90f7021cc68ad02565af4c5c471a903088d09fc457f38dbcfb7b43e874b75",
"parse_status": "SUCCESS",
"event_offset": 6,
"emitted_event_counts": {
"cpg.nodes": 19,
"cpg.edges": 20,
"source.metadata": 1
}
}
def read_metadata_record(offset: int) -> tuple[str, dict]:
result = subprocess.run(
['docker', 'exec', 'cpg-kafka', 'kafka-console-consumer', '--bootstrap-server', 'kafka:29092', '--topic', 'source.metadata', '--partition', '0', '--offset', str(offset), '--max-messages', '1', '--property', 'print.key=true', '--property', 'print.value=true'],
capture_output=True, text=True, check=True,
)
key, raw_value = result.stdout.strip().split('\t', 1)
event = json.loads(raw_value)
return key, event
event_key, actual_event_v1 = read_metadata_record(baseline_latest_offset)
validator.validate('FILE_METADATA_UPSERT', actual_event_v1)
assert event_key == file_id
assert actual_event_v1['file_id'] == file_id
assert actual_event_v1['repository_id'] == repository_id
assert actual_event_v1['content_hash'] == first_content_hash
assert actual_event_v1['metadata']['parse_status'] == 'SUCCESS'
print(json.dumps({'kafka_key': event_key, 'event_id': actual_event_v1['event_id'], 'schema_valid': True, 'file_id_matches_key': True, 'metadata': actual_event_v1['metadata']}, indent=2)){
"kafka_key": "5e649b5adc42d93972928fe8890150105b41e30e5ed6ef6af00ccdeaa2583573",
"event_id": "c8331d92066ce399c5bbcee479a209eab57af8686443331026dc6f1d29f0a8a4",
"schema_valid": true,
"file_id_matches_key": true,
"metadata": {
"size_bytes": 61,
"line_count": 3,
"function_count": 1,
"class_count": 0,
"import_count": 0,
"node_count": 19,
"edge_count": 20,
"parse_duration_ms": 1,
"parse_status": "SUCCESS",
"parser": "python.ast"
}
}
5Spark batch baseline¶
baseline_latest_offset là Kafka next offset trước khi Parser Service publish. Vì topic có một partition, Spark bắt đầu đúng tại offset của metadata event vừa publish; dữ liệu lịch sử của topic không đi vào baseline batch.
NETWORK = subprocess.check_output(['docker', 'inspect', '-f', '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}', 'cpg-kafka'], text=True).strip()
INTERNAL_MONGO_URI = MONGO_URI.replace('localhost', 'cpg-mongodb')
SPARK_PACKAGES = 'org.mongodb.spark:mongo-spark-connector_2.12:10.1.1,org.apache.spark:spark-sql-kafka-0-10_2.12:3.3.0'
STARTING_OFFSETS = json.dumps({'source.metadata': {'0': baseline_latest_offset}})
def run_spark(starting_offsets: str | None = None) -> tuple[int, str]:
command = ['docker', 'run', '--rm', '--network', NETWORK, '-v', f'{PROJECT_ROOT}:/opt/project', '-w', '/opt/project', 'apache/spark-py:v3.3.0', '/opt/spark/bin/spark-submit', '--master', 'local[2]', '--conf', 'spark.jars.ivy=/tmp/ivy', '--packages', SPARK_PACKAGES, 'spark_jobs/metadata_to_mongodb.py', '--bootstrap-servers', 'kafka:29092', '--mongodb-uri', INTERNAL_MONGO_URI, '--checkpoint-dir', CHECKPOINT_DIR.relative_to(PROJECT_ROOT).as_posix(), '--app-name', f'cpg-task5-{RUN_ID}']
if starting_offsets is not None:
command.extend(['--starting-offsets', starting_offsets])
command.append('--available-now')
result = subprocess.run(command, capture_output=True, text=True, timeout=300)
return result.returncode, result.stdout + result.stderr
def input_rows(logs: str) -> list[int]:
return [int(value) for value in re.findall(r'"numInputRows"\s*:\s*(\d+)', logs)]
initial_exit, initial_logs = run_spark(STARTING_OFFSETS)
initial_input_rows = input_rows(initial_logs)
assert initial_exit == 0
assert initial_input_rows and initial_input_rows[-1] == 1
print(json.dumps({'spark_exit': initial_exit, 'num_input_rows': initial_input_rows[-1], 'starting_offsets': STARTING_OFFSETS, 'checkpoint': CHECKPOINT_DIR.as_posix()}, indent=2)){
"spark_exit": 0,
"num_input_rows": 1,
"starting_offsets": "{\"source.metadata\": {\"0\": 6}}",
"checkpoint": "D:/Schools/BigData/lab04-cpg-streaming/workspace/checkpoints/task5-notebook/20260725T065530Z-ee8b8709"
}
def mongo_json(javascript: str) -> dict | list:
result = subprocess.run(['docker', 'exec', 'cpg-mongodb', 'mongosh', '--quiet', '--username', os.environ['MONGO_ROOT_USERNAME'], '--password', os.environ['MONGO_ROOT_PASSWORD'], '--authenticationDatabase', 'admin', '--eval', javascript], capture_output=True, text=True, check=True)
return json.loads(result.stdout.strip().splitlines()[-1])
def document_state() -> dict:
return mongo_json(
f"db=db.getSiblingDB('{MONGO_DATABASE}'); const d=db.{MONGO_COLLECTION}.findOne({{file_id:'{file_id}'}}); print(JSON.stringify({{document_count:db.{MONGO_COLLECTION}.countDocuments({{file_id:'{file_id}'}}), content_hash:d ? d.content_hash : null, repository_id:d ? d.repository_id : null, file_path:d ? d.file_path : null}}));"
)
baseline_document = document_state()
baseline_checkpoint_files = len([path for path in CHECKPOINT_DIR.rglob('*') if path.is_file()])
print(json.dumps({'mongo': baseline_document, 'checkpoint_file_count': baseline_checkpoint_files}, indent=2))
assert baseline_document['document_count'] == 1
assert baseline_document['content_hash'] == first_content_hash
assert baseline_checkpoint_files > 0{
"mongo": {
"document_count": 1,
"content_hash": "e7f90f7021cc68ad02565af4c5c471a903088d09fc457f38dbcfb7b43e874b75",
"repository_id": "notebook/task5/20260725T065530Z-ee8b8709",
"file_path": "src\\task5_runtime.py"
},
"checkpoint_file_count": 8
}
6Xác minh checkpoint resume¶
Checkpoint Spark lưu Kafka offsets đã xử lý. Vì vậy restart không có event mới phải tạo input batch bằng 0; assertion bên dưới parse số nguyên từ field JSON numInputRows, không dùng kiểm tra chuỗi lỏng lẻo. Query MongoDB được chạy lại sau restart để xác nhận document và content hash không đổi.
restart_exit, restart_logs = run_spark()
restart_input_rows = input_rows(restart_logs)
restart_document = document_state()
assert restart_exit == 0
assert restart_input_rows and restart_input_rows[-1] == 0
assert restart_document == baseline_document
print(json.dumps({'spark_exit': restart_exit, 'num_input_rows': restart_input_rows[-1], 'mongo_after_restart': restart_document, 'document_unchanged': True}, indent=2)){
"spark_exit": 0,
"num_input_rows": 0,
"mongo_after_restart": {
"document_count": 1,
"content_hash": "e7f90f7021cc68ad02565af4c5c471a903088d09fc457f38dbcfb7b43e874b75",
"repository_id": "notebook/task5/20260725T065530Z-ee8b8709",
"file_path": "src\\task5_runtime.py"
},
"document_unchanged": true
}
7Xác minh MongoDB upsert¶
MongoDB upsert chống duplicate ở tầng document: replace + idFieldList=file_id + upsertDocument=true thay thế document hiện tại khi metadata event mới có cùng file_id. Đây là cơ chế khác với Spark checkpoint: checkpoint chống đọc lại Kafka offset, còn upsert xử lý an toàn khi event được publish lại hoặc có content mới.
before_replay_offset = kafka_latest_offset()
source_path.write_bytes(modified_source)
replay_result = process_service.execute(make_source_file())
after_replay_offset = kafka_latest_offset()
assert replay_result.status.value == 'SUCCESS'
assert after_replay_offset == before_replay_offset + 1
second_event_offset = after_replay_offset - 1
second_key, actual_event_v2 = read_metadata_record(second_event_offset)
validator.validate('FILE_METADATA_UPSERT', actual_event_v2)
assert second_key == file_id
assert actual_event_v2['file_id'] == file_id
assert actual_event_v2['content_hash'] != first_content_hash
print(json.dumps({'replay_status': replay_result.status.value, 'old_content_hash': first_content_hash, 'new_content_hash': actual_event_v2['content_hash'], 'kafka_key_matches_file_id': second_key == file_id, 'schema_valid': True}, indent=2)){
"replay_status": "SUCCESS",
"old_content_hash": "e7f90f7021cc68ad02565af4c5c471a903088d09fc457f38dbcfb7b43e874b75",
"new_content_hash": "5ba479d9bda9f3c3f15e02486959e3ae40b6eff02e26de95693d28485d9ef03b",
"kafka_key_matches_file_id": true,
"schema_valid": true
}
upsert_exit, upsert_logs = run_spark()
upsert_input_rows = input_rows(upsert_logs)
final_document = document_state()
assert upsert_exit == 0
assert upsert_input_rows and upsert_input_rows[-1] == 1
assert final_document['document_count'] == 1
assert final_document['content_hash'] == actual_event_v2['content_hash']
print(json.dumps({'spark_exit': upsert_exit, 'num_input_rows': upsert_input_rows[-1], 'mongo_after_upsert': final_document, 'upsert_kept_one_document': True}, indent=2)){
"spark_exit": 0,
"num_input_rows": 1,
"mongo_after_upsert": {
"document_count": 1,
"content_hash": "5ba479d9bda9f3c3f15e02486959e3ae40b6eff02e26de95693d28485d9ef03b",
"repository_id": "notebook/task5/20260725T065530Z-ee8b8709",
"file_path": "src\\task5_runtime.py"
},
"upsert_kept_one_document": true
}
duplicates = mongo_json(
f"db=db.getSiblingDB('{MONGO_DATABASE}'); const by_file_id=db.{MONGO_COLLECTION}.aggregate([{{$group:{{_id:'$file_id', count:{{$sum:1}}}}}}, {{$match:{{count:{{$gt:1}}}}}}]).toArray(); const by_repository_path=db.{MONGO_COLLECTION}.aggregate([{{$group:{{_id:{{repository_id:'$repository_id', file_path:'$file_path'}}, count:{{$sum:1}}}}}}, {{$match:{{count:{{$gt:1}}}}}}]).toArray(); print(JSON.stringify({{by_file_id:by_file_id, by_repository_path:by_repository_path}}));"
)
assert duplicates['by_file_id'] == []
assert duplicates['by_repository_path'] == []
print(json.dumps({'duplicate_by_file_id': duplicates['by_file_id'], 'duplicate_by_repository_path': duplicates['by_repository_path'], 'duplicate_checks_passed': True}, indent=2)){
"duplicate_by_file_id": [],
"duplicate_by_repository_path": [],
"duplicate_checks_passed": true
}
8Bằng chứng giao diện¶
Mongo Express là UI thật kết nối tới service MongoDB của Compose. Sau khi chạy docker compose ... up -d mongo-express, mở http://localhost:8081, chọn database cpg_metadata và collection file_statistics. Ảnh dưới đây phải được chụp từ UI thật, không phải sơ đồ minh họa.

9Lệnh xác minh end-to-end¶
9.1PowerShell¶
docker compose --env-file .env -f infra/docker-compose.yml up -d kafka mongodb mongo-express
$env:TASK5_NOTEBOOK_RUN_ID='task5-$(Get-Date -Format yyyyMMddTHHmmss)'
jupyter nbconvert --to notebook --execute lab04-book/task5_spark_mongodb.ipynb --inplace --ExecutePreprocessor.timeout=6009.2Linux / WSL / Git Bash¶
docker compose --env-file .env -f infra/docker-compose.yml up -d kafka mongodb mongo-express
export TASK5_NOTEBOOK_RUN_ID=task5-$(date -u +%Y%m%dT%H%M%S)
jupyter nbconvert --to notebook --execute lab04-book/task5_spark_mongodb.ipynb --inplace --ExecutePreprocessor.timeout=600Không xóa workspace/checkpoints/task5-notebook/<RUN_ID> khi chạy lại. Dùng TASK5_NOTEBOOK_RUN_ID mới để tạo một run độc lập.
10Nhận xét¶
Topology cuối cùng là Kafka KRaft, không có ZooKeeper. Evidence được cô lập theo run bằng repository_id, file_id, Kafka starting offset và checkpoint path riêng. Event metadata không còn viết tay: Parser Service thật tạo event, validate schema và publish với key bằng file_id.
Checkpoint Spark có ý nghĩa khôi phục Kafka offsets; nó làm restart bỏ qua event đã commit. MongoDB upsert có ý nghĩa chống duplicate ở tầng document; nó thay thế document theo file_id khi event được publish lại hoặc file có content mới. Hai cơ chế này giải quyết hai lớp khác nhau của tính idempotent và không thể thay thế cho nhau.
Giới hạn của notebook là nó kiểm chứng nhánh metadata độc lập. Neo4j Kafka Connect và replay node/edge thuộc Task 4 và Task 6, cần evidence riêng trong notebook tương ứng.