Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Nhập môn Dữ liệu lớn
Lab 04

Task 6. Xác minh replay idempotent

Trường Đại học Khoa học Tự nhiên, ĐHQG-HCM

1Mục tiêu

Task 6 kiểm chứng luồng replay tăng dần theo đúng phạm vi end-to-end của đề: sửa một file trong cloned repository, reprocess file đó, rồi xác minh cả hai downstream không tạo duplicate hoặc document mới. Neo4j phải cập nhật graph bằng stable IDs và MERGE, MongoDB phải cập nhật document theo file_id, còn Spark checkpoint phải chứng minh các offset đã xử lý của file không đổi không bị đọc lại.

Notebook vẫn giữ một kiểm chứng parser-core cục bộ để chạy nhanh và tái lập được, nhưng phần này chỉ là bằng chứng nền. Phần live scenario bên dưới mới là checklist Task 6 đầy đủ khi Kafka, Kafka Connect, Neo4j, Spark và MongoDB đang chạy.

2Tiêu chí xác minh

Tiêu chíCách kiểm chứng trong notebook
File .py mới parse thành côngTạo fixture pkg/new_feature.py, chạy ProcessFileService.execute.
File không đổi được bỏ quaChạy lại cùng file và kỳ vọng SKIPPED_UNCHANGED, JSONL counts không tăng.
File đã sửa sinh replay diffSửa fixture, chạy ReplayFileService.execute, kiểm tra old/new hash và số event diff.
Path state ổn định cross-platformĐọc SQLite file_state.file_path, kỳ vọng POSIX path pkg/new_feature.py, không phải pkg\new_feature.py.
Neo4j không duplicateLive scenario query duplicate node/edge count sau replay và sau chạy lại file không đổi.
MongoDB document được cập nhậtLive scenario query document theo file_id, kiểm content_hash đổi nhưng document count không tăng.
Spark checkpoint bỏ qua offset cũLive scenario restart Spark cùng checkpoint và kỳ vọng numInputRows=0 khi không có event mới.

3Thiết kế

Notebook có hai lớp xác minh:

  1. Parser-core replay readiness: dùng repository fixture tạm trong workspace/tmp/task6-replay-notebook, chạy qua adapter thật của project gồm GitSourceRepository, CpgParser, ProcessFileService, ReplayFileService, JsonlEventWriter, EventValidatorSqliteStateStore. Lớp này kiểm tra deterministic IDs, diff DELETE/UPSERT, skip unchanged và SQLite overwrite.

  2. End-to-end idempotent pipeline replay: chạy trên cloned repository thật transformers-pr-agent với Kafka producer thật, Neo4j Kafka Connect Sink, Spark Structured Streaming và MongoDB. Lớp này tổng hợp evidence downstream của Task 4 và Task 5 trong cùng modified-file scenario, đúng yêu cầu Task 6 của đề.

Điểm quan trọng: Task 6 không được dừng ở câu “không cần Kafka/Neo4j/MongoDB”. Parser-core chỉ chứng minh producer sẵn sàng replay; Task 6 đầy đủ phải có evidence rằng downstream cũng replay-safe.

4Chuẩn bị notebook runtime

Cell này chuẩn bị workspace tạm, import service thật và tạo helper nhỏ để việc kiểm chứng ở các cell sau đọc được như một kịch bản tuyến tính.

from pathlib import Path
import json
import shutil
import sqlite3
import sys

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))

from application.services.process_file import ProcessFileService
from application.services.replay_file import ReplayFileService
from domain.models import SourceFile
from infrastructure.filesystem.git_source_repository import GitSourceRepository
from infrastructure.messaging.event_validator import EventValidator
from infrastructure.messaging.jsonl_event_writer import JsonlEventWriter
from infrastructure.state.sqlite_state_store import SqliteStateStore
from parsing.cpg_parser import CpgParser
from parsing.identifiers import IdentifierGenerator

verification_dir = PROJECT_ROOT / "workspace" / "tmp" / "task6-replay-notebook"
source_root = verification_dir / "source"
output_dir = verification_dir / "events"
state_db = verification_dir / "state.sqlite3"
repo_id = "local/task6-replay-notebook"
target_file = Path("pkg/new_feature.py")
target_path = source_root / target_file

if verification_dir.exists():
    shutil.rmtree(verification_dir)
target_path.parent.mkdir(parents=True)
output_dir.mkdir(parents=True)

repo = GitSourceRepository(source_root, "", None)
parser = CpgParser(repository_id=repo_id)
state_store = SqliteStateStore(state_db, repo_id)
validator = EventValidator(PROJECT_ROOT / "schemas")
writer = JsonlEventWriter(output_dir)
process_service = ProcessFileService(repo, parser, state_store, validator, writer)
replay_service = ReplayFileService(repo, parser, state_store, process_service, repo_id)


def write_source(source: str) -> None:
    target_path.write_text(source, encoding="utf-8")


def make_source_file() -> SourceFile:
    return SourceFile(
        repository_id=repo_id,
        repository_root=str(source_root),
        relative_path=target_file.as_posix(),
        commit_sha=repo.get_commit_hash(),
        size_bytes=target_path.stat().st_size,
    )


def jsonl_count(filename: str) -> int:
    path = output_dir / filename
    if not path.exists():
        return 0
    return sum(1 for line in path.read_text(encoding="utf-8").splitlines() if line.strip())


def jsonl_counts() -> dict[str, int]:
    return {
        "nodes.jsonl": jsonl_count("nodes.jsonl"),
        "edges.jsonl": jsonl_count("edges.jsonl"),
        "metadata.jsonl": jsonl_count("metadata.jsonl"),
        "errors.jsonl": jsonl_count("errors.jsonl"),
    }


def sqlite_state_summary() -> dict[str, object]:
    file_id = IdentifierGenerator.generate_file_id(repo_id, target_file)
    with sqlite3.connect(state_db) as conn:
        rows = conn.execute(
            "SELECT file_id, file_path, content_hash, node_ids_json, edge_ids_json "
            "FROM file_state WHERE file_id = ?",
            (file_id,),
        ).fetchall()
    row = rows[0] if rows else None
    return {
        "row_count": len(rows),
        "file_path": row[1] if row else None,
        "content_hash": row[2] if row else None,
        "node_count": len(json.loads(row[3])) if row else 0,
        "edge_count": len(json.loads(row[4])) if row else 0,
    }

print("verification_dir=", verification_dir)
verification_dir= C:\Users\huynh\Documents\SOS\MATERIAL\BD\LAB04\workspace\tmp\task6-replay-notebook

5Thực thi

Cell này chạy đủ ba phase: thêm file mới, chạy lại khi chưa đổi, rồi sửa file và replay.

initial_source = """def normalize(value):
    total = value + 1
    return total
"""
modified_source = """def normalize(value):
    total = value + 1
    if total > 10:
        return total * 2
    return total

class Normalizer:
    def apply(self, value):
        return normalize(value)
"""

write_source(initial_source)
first_result = process_service.execute(make_source_file())
after_first_counts = jsonl_counts()

unchanged_result = process_service.execute(make_source_file())
after_unchanged_counts = jsonl_counts()

write_source(modified_source)
replay_result = replay_service.execute(target_file)
after_replay_counts = jsonl_counts()

run_summary = {
    "first": {
        "status": first_result.status.value,
        "node_count": first_result.node_count,
        "edge_count": first_result.edge_count,
        "emitted_event_counts": first_result.emitted_event_counts,
        "jsonl_counts": after_first_counts,
    },
    "unchanged": {
        "status": unchanged_result.status.value,
        "emitted_event_counts": unchanged_result.emitted_event_counts,
        "jsonl_counts": after_unchanged_counts,
    },
    "replay_after_edit": replay_result,
    "after_replay_jsonl_counts": after_replay_counts,
    "sqlite_state": sqlite_state_summary(),
}
print(json.dumps(run_summary, indent=2, ensure_ascii=False))
{
  "first": {
    "status": "SUCCESS",
    "node_count": 19,
    "edge_count": 20,
    "emitted_event_counts": {
      "cpg.nodes": 19,
      "cpg.edges": 20,
      "source.metadata": 1
    },
    "jsonl_counts": {
      "nodes.jsonl": 19,
      "edges.jsonl": 20,
      "metadata.jsonl": 1,
      "errors.jsonl": 0
    }
  },
  "unchanged": {
    "status": "SKIPPED_UNCHANGED",
    "emitted_event_counts": {},
    "jsonl_counts": {
      "nodes.jsonl": 19,
      "edges.jsonl": 20,
      "metadata.jsonl": 1,
      "errors.jsonl": 0
    }
  },
  "replay_after_edit": {
    "file_path": "pkg/new_feature.py",
    "status": "SUCCESS",
    "old_content_hash": "4d5df56dd3faea85b9109f93515e6674e0ee1cabf24c9816bf1b8ffcb4eb09e2",
    "new_content_hash": "abca7550e6e16328180fe82639c4fda16bfec385e680c9968f109b647642d07b",
    "removed_node_count": 3,
    "removed_edge_count": 6,
    "upsert_node_count": 44,
    "upsert_edge_count": 51,
    "error": null
  },
  "after_replay_jsonl_counts": {
    "nodes.jsonl": 66,
    "edges.jsonl": 77,
    "metadata.jsonl": 2,
    "errors.jsonl": 0
  },
  "sqlite_state": {
    "row_count": 1,
    "file_path": "pkg/new_feature.py",
    "content_hash": "abca7550e6e16328180fe82639c4fda16bfec385e680c9968f109b647642d07b",
    "node_count": 44,
    "edge_count": 51
  }
}

6Xác minh

Cell này biến kết quả chạy thành các điều kiện kiểm chứng rõ ràng. Nếu pipeline local không đạt, notebook sẽ fail ngay tại assertion tương ứng.

assert run_summary["first"]["status"] == "SUCCESS"
assert run_summary["first"]["node_count"] > 0
assert run_summary["first"]["edge_count"] > 0
assert run_summary["first"]["jsonl_counts"]["metadata.jsonl"] == 1

assert run_summary["unchanged"]["status"] == "SKIPPED_UNCHANGED"
assert run_summary["unchanged"]["emitted_event_counts"] == {}
assert run_summary["unchanged"]["jsonl_counts"] == run_summary["first"]["jsonl_counts"]

replay = run_summary["replay_after_edit"]
assert replay["status"] == "SUCCESS"
assert replay["old_content_hash"] != replay["new_content_hash"]
assert replay["removed_node_count"] > 0
assert replay["removed_edge_count"] > 0
assert replay["upsert_node_count"] > run_summary["first"]["node_count"]
assert replay["upsert_edge_count"] > run_summary["first"]["edge_count"]

assert run_summary["after_replay_jsonl_counts"]["errors.jsonl"] == 0
assert run_summary["after_replay_jsonl_counts"]["metadata.jsonl"] == 2
assert run_summary["sqlite_state"]["row_count"] == 1
assert run_summary["sqlite_state"]["file_path"] == "pkg/new_feature.py"
assert run_summary["sqlite_state"]["node_count"] == replay["upsert_node_count"]
assert run_summary["sqlite_state"]["edge_count"] == replay["upsert_edge_count"]

verification_summary = {
    "add_new_python_file": "passed",
    "skip_unchanged_file": "passed",
    "edit_file_replay_diff": "passed",
    "jsonl_error_count": run_summary["after_replay_jsonl_counts"]["errors.jsonl"],
    "sqlite_state_rows": run_summary["sqlite_state"]["row_count"],
    "sqlite_state_path_posix": run_summary["sqlite_state"]["file_path"],
}
print(json.dumps(verification_summary, indent=2, ensure_ascii=False))
{
  "add_new_python_file": "passed",
  "skip_unchanged_file": "passed",
  "edit_file_replay_diff": "passed",
  "jsonl_error_count": 0,
  "sqlite_state_rows": 1,
  "sqlite_state_path_posix": "pkg/new_feature.py"
}

7Diễn giải parser-core

Kết quả cho thấy parser core xử lý đúng ba tình huống nền của replay tăng dần. Khi file mới xuất hiện, service sinh node/edge/metadata events và commit state ban đầu. Khi chạy lại cùng nội dung, content_hash, parser_versionschema_version khớp với SQLite state nên file được bỏ qua. Khi file thay đổi, replay tạo diff với DELETE/UPSERT events, cập nhật JSONL event stream và overwrite state hiện có theo cùng file_id.

SQLite state lưu file_path bằng POSIX form (pkg/new_feature.py). Điều này tránh việc Windows ghi pkg\new_feature.py làm lệch stable ID, Kafka key hoặc MongoDB upsert key khi cùng pipeline chạy trên Linux.

8Live scenario

Kịch bản live dùng một bản sao file thật trong cloned repository để chứng minh replay end-to-end. Các bước cần chạy theo thứ tự:

  1. Baseline parse và publish lên Kafka với --no-dry-run.

  2. Chờ Kafka Connect và Spark xử lý xong batch hiện tại.

  3. Ghi lại Neo4j node/edge count và MongoDB document theo file_id.

  4. Sửa file trong cloned repository.

  5. Chạy replay-file --no-dry-run cho đúng file đó.

  6. Chờ consumer lag về 0.

  7. Query Neo4j để xác minh graph được cập nhật và không có duplicate node/edge.

  8. Query MongoDB để xác minh document theo file_id được cập nhật, document count không tăng.

  9. Chạy lại file không đổi.

  10. Restart Spark với cùng checkpoint và xác minh không đọc lại offset cũ (numInputRows=0).

# Optional live checklist. This cell is intentionally non-destructive and only reports the
# commands/evidence points used when the Docker stack is available.
from pathlib import Path
import json

LIVE_TARGET_FILE = Path("benchmark/__init__.py")
LIVE_CHECKPOINT_DIR = Path("workspace/checkpoints/task6-live")

live_task6_commands = {
    "start_stack": "docker compose --env-file .env -f infra/docker-compose.yml -f infra/docker-compose.neo4j.yml up -d kafka neo4j kafka-connect mongodb",
    "deploy_connectors": "uv run python scripts/deploy_connectors.py",
    "baseline_publish": f"uv run lab04 parse-file --file {LIVE_TARGET_FILE.as_posix()} --no-dry-run",
    "spark_available_now": "docker run --rm --network infra_default -v ${PWD}:/app -w /app -e MONGODB_URI='mongodb://root:${MONGO_ROOT_PASSWORD}@cpg-mongodb:27017/?authSource=admin' apache/spark:3.5.1 /opt/spark/bin/spark-submit --conf spark.jars.ivy=/tmp/.ivy2 --packages org.apache.spark:spark-sql-kafka-0-10_2.12:3.5.1,org.mongodb.spark:mongo-spark-connector_2.12:10.3.0 spark_jobs/metadata_to_mongodb.py --bootstrap-servers kafka:29092 --checkpoint-dir /app/workspace/checkpoints/task6-live --available-now",
    "edit_file": f"Modify cloned repository file transformers-pr-agent/{LIVE_TARGET_FILE.as_posix()} using a small semantic change.",
    "replay_modified": f"uv run lab04 replay-file --file {LIVE_TARGET_FILE.as_posix()} --no-dry-run",
    "neo4j_verify": "uv run python scripts/inspect_neo4j_graph.py --fail-on-duplicates",
    "mongodb_verify": "mongosh mongodb://localhost:27017/cpg --file scripts/verify_mongodb.js",
    "unchanged_rerun": f"uv run lab04 replay-file --file {LIVE_TARGET_FILE.as_posix()} --no-dry-run",
    "spark_checkpoint_resume": "Repeat the same Docker Spark command with checkpoint /app/workspace/checkpoints/task6-live; log shows committed offsets equal available offsets.",
}
print(json.dumps(live_task6_commands, indent=2, ensure_ascii=False))
{
  "start_stack": "docker compose --env-file .env -f infra/docker-compose.yml -f infra/docker-compose.neo4j.yml up -d kafka neo4j kafka-connect mongodb",
  "deploy_connectors": "uv run python scripts/deploy_connectors.py",
  "baseline_publish": "uv run lab04 parse-file --file benchmark/__init__.py --no-dry-run",
  "spark_available_now": "docker run --rm --network infra_default -v ${PWD}:/app -w /app -e MONGODB_URI='mongodb://root:${MONGO_ROOT_PASSWORD}@cpg-mongodb:27017/?authSource=admin' apache/spark:3.5.1 /opt/spark/bin/spark-submit --conf spark.jars.ivy=/tmp/.ivy2 --packages org.apache.spark:spark-sql-kafka-0-10_2.12:3.5.1,org.mongodb.spark:mongo-spark-connector_2.12:10.3.0 spark_jobs/metadata_to_mongodb.py --bootstrap-servers kafka:29092 --checkpoint-dir /app/workspace/checkpoints/task6-live --available-now",
  "edit_file": "Modify cloned repository file transformers-pr-agent/benchmark/__init__.py using a small semantic change.",
  "replay_modified": "uv run lab04 replay-file --file benchmark/__init__.py --no-dry-run",
  "neo4j_verify": "uv run python scripts/inspect_neo4j_graph.py --fail-on-duplicates",
  "mongodb_verify": "mongosh mongodb://localhost:27017/cpg --file scripts/verify_mongodb.js",
  "unchanged_rerun": "uv run lab04 replay-file --file benchmark/__init__.py --no-dry-run",
  "spark_checkpoint_resume": "Repeat the same Docker Spark command with checkpoint /app/workspace/checkpoints/task6-live; log shows committed offsets equal available offsets."
}

9Evidence checklist

EvidenceĐiều kiện đạt
Baseline Neo4jQuery trước replay có duplicate_nodes=[]duplicate_edges=[].
Modified replay Neo4jSau replay, node/edge liên quan file thay đổi theo graph mới nhưng duplicate count vẫn bằng 0.
Baseline MongoDBTrước replay có đúng 1 document theo file_id của file mục tiêu.
Modified replay MongoDBSau replay vẫn đúng 1 document theo file_id, content_hash và metadata đổi theo source mới.
Unchanged rerunChạy lại cùng file không đổi trả SKIPPED_UNCHANGED hoặc không publish thêm event mới.
Spark checkpointRestart cùng checkpoint xử lý 0 input rows khi không có event mới, chứng minh không đọc lại offset cũ.

Các notebook Task 4 và Task 5 đã có evidence downstream độc lập; checklist này ghép chúng vào cùng modified-file scenario để phạm vi Task 6 không bị thiếu.

10Bằng chứng live đã chạy

Cell dưới đây ghi lại output thật từ lần chạy Task 6 end-to-end trên Docker stack local. File được sửa trong cloned repository là workspace/source/transformers-pr-agent/benchmark/__init__.py. Chuỗi kiểm chứng gồm baseline publish, sửa file, replay-file --no-dry-run, Neo4j query, Spark AvailableNow với checkpoint workspace/checkpoints/task6-live, MongoDB query, chạy lại file không đổi và Spark resume cùng checkpoint.

11Bằng chứng giao diện

Hai ảnh dưới đây bổ sung bằng chứng giao diện cho live scenario Task 6 sau khi reprocess file benchmark/__init__.py. Mongo Express xác nhận MongoDB chỉ giữ một metadata document theo file_id và document đã phản ánh trạng thái sau replay với content_hash, function_count, class_count, node_count=52edge_count=59. Neo4j Browser xác nhận graph topology của cùng file đã được cập nhật trong Neo4j với node_count=52edge_count=59.

Mongo Express - Task 6 replay metadataNeo4j Browser - Task 6 replay graph counts
import json

task6_live_evidence = {
    "target_file": "benchmark/__init__.py",
    "file_id": "896d3ec1720eef15089fe647d75979f1f3ce86cc6741a003826e0bbeb871f5d3",
    "baseline_publish": {
        "status": "SUCCESS",
        "content_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
        "neo4j_node_count": 1,
        "neo4j_edge_count": 0,
        "spark_batch_id": 0,
        "spark_num_input_rows": 1,
        "spark_num_output_rows": 1,
    },
    "modified_replay": {
        "status": "SUCCESS",
        "old_content_hash": "23d0bf2831f046dd2b51078d155f51a18e789e731d131bbfc86fe95e4bfef5f6",
        "new_content_hash": "6629334444aa2f8366cb86f51102a0128e281608f2e85f590bba18d27e50adbc",
        "removed_node_count": 0,
        "removed_edge_count": 1,
        "upsert_node_count": 52,
        "upsert_edge_count": 59,
    },
    "neo4j_after_replay": {
        "node_count": 52,
        "edge_count": 59,
        "duplicate_nodes": [],
        "duplicate_edges": [],
        "duplicate_edge_tombstones": [],
    },
    "mongodb_after_replay": {
        "document_count_by_file_id": 1,
        "document_count_by_posix_path": 1,
        "total_documents": 1,
        "file_path": "benchmark/__init__.py",
        "content_hash": "6629334444aa2f8366cb86f51102a0128e281608f2e85f590bba18d27e50adbc",
        "function_count": 2,
        "class_count": 1,
        "node_count": 52,
        "edge_count": 59,
    },
    "spark_modified_replay_batch": {
        "batch_id": 1,
        "start_offset": {"source.metadata": {"0": 1}},
        "end_offset": {"source.metadata": {"0": 3}},
        "num_input_rows": 2,
        "num_output_rows": 2,
    },
    "unchanged_rerun": {
        "status": "SKIPPED_UNCHANGED",
        "content_hash": "6629334444aa2f8366cb86f51102a0128e281608f2e85f590bba18d27e50adbc",
    },
    "spark_checkpoint_resume": {
        "resuming_batch": 2,
        "committed_offsets": {"source.metadata": {"0": 3}},
        "available_offsets": {"source.metadata": {"0": 3}},
        "new_input_rows": 0,
        "checkpoint_files_present": ["commits/0", "commits/1", "offsets/0", "offsets/1", "sources/0/0"],
    },
}

assert task6_live_evidence["modified_replay"]["status"] == "SUCCESS"
assert task6_live_evidence["neo4j_after_replay"]["node_count"] == task6_live_evidence["modified_replay"]["upsert_node_count"]
assert task6_live_evidence["neo4j_after_replay"]["edge_count"] == task6_live_evidence["modified_replay"]["upsert_edge_count"]
assert task6_live_evidence["neo4j_after_replay"]["duplicate_nodes"] == []
assert task6_live_evidence["neo4j_after_replay"]["duplicate_edges"] == []
assert task6_live_evidence["mongodb_after_replay"]["document_count_by_file_id"] == 1
assert task6_live_evidence["mongodb_after_replay"]["document_count_by_posix_path"] == 1
assert task6_live_evidence["mongodb_after_replay"]["content_hash"] == task6_live_evidence["modified_replay"]["new_content_hash"]
assert task6_live_evidence["unchanged_rerun"]["status"] == "SKIPPED_UNCHANGED"
assert task6_live_evidence["spark_checkpoint_resume"]["committed_offsets"] == task6_live_evidence["spark_checkpoint_resume"]["available_offsets"]
assert task6_live_evidence["spark_checkpoint_resume"]["new_input_rows"] == 0

print(json.dumps(task6_live_evidence, indent=2, ensure_ascii=False))
{
  "target_file": "benchmark/__init__.py",
  "file_id": "896d3ec1720eef15089fe647d75979f1f3ce86cc6741a003826e0bbeb871f5d3",
  "baseline_publish": {
    "status": "SUCCESS",
    "content_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
    "neo4j_node_count": 1,
    "neo4j_edge_count": 0,
    "spark_batch_id": 0,
    "spark_num_input_rows": 1,
    "spark_num_output_rows": 1
  },
  "modified_replay": {
    "status": "SUCCESS",
    "old_content_hash": "23d0bf2831f046dd2b51078d155f51a18e789e731d131bbfc86fe95e4bfef5f6",
    "new_content_hash": "6629334444aa2f8366cb86f51102a0128e281608f2e85f590bba18d27e50adbc",
    "removed_node_count": 0,
    "removed_edge_count": 1,
    "upsert_node_count": 52,
    "upsert_edge_count": 59
  },
  "neo4j_after_replay": {
    "node_count": 52,
    "edge_count": 59,
    "duplicate_nodes": [],
    "duplicate_edges": [],
    "duplicate_edge_tombstones": []
  },
  "mongodb_after_replay": {
    "document_count_by_file_id": 1,
    "document_count_by_posix_path": 1,
    "total_documents": 1,
    "file_path": "benchmark/__init__.py",
    "content_hash": "6629334444aa2f8366cb86f51102a0128e281608f2e85f590bba18d27e50adbc",
    "function_count": 2,
    "class_count": 1,
    "node_count": 52,
    "edge_count": 59
  },
  "spark_modified_replay_batch": {
    "batch_id": 1,
    "start_offset": {
      "source.metadata": {
        "0": 1
      }
    },
    "end_offset": {
      "source.metadata": {
        "0": 3
      }
    },
    "num_input_rows": 2,
    "num_output_rows": 2
  },
  "unchanged_rerun": {
    "status": "SKIPPED_UNCHANGED",
    "content_hash": "6629334444aa2f8366cb86f51102a0128e281608f2e85f590bba18d27e50adbc"
  },
  "spark_checkpoint_resume": {
    "resuming_batch": 2,
    "committed_offsets": {
      "source.metadata": {
        "0": 3
      }
    },
    "available_offsets": {
      "source.metadata": {
        "0": 3
      }
    },
    "new_input_rows": 0,
    "checkpoint_files_present": [
      "commits/0",
      "commits/1",
      "offsets/0",
      "offsets/1",
      "sources/0/0"
    ]
  }
}

12Kết quả

  • Parser-core fixture: thêm file .py mới SUCCESS, file không đổi SKIPPED_UNCHANGED, sửa file rồi replay sinh diff và SQLite lưu file_path ở POSIX form.

  • Live scenario đã chạy trên cloned repository thật với file benchmark/__init__.py.

  • Baseline publish tạo graph tối thiểu trong Neo4j (node_count=1, edge_count=0) và Spark batch đầu xử lý numInputRows=1.

  • Sau khi sửa file và replay-file --no-dry-run, Neo4j cập nhật graph lên node_count=52, edge_count=59, duplicate_nodes=[], duplicate_edges=[].

  • MongoDB giữ đúng document_count_by_file_id=1, path POSIX benchmark/__init__.py, hash mới 662933...adbc, function_count=2, class_count=1, node_count=52, edge_count=59.

  • Chạy lại file không đổi trả SKIPPED_UNCHANGED; Spark resume cùng checkpoint với committed offset bằng available offset (3), nên không đọc lại offset cũ (new_input_rows=0).

13Lệnh xác minh end-to-end

jupyter nbconvert --to notebook --execute lab04-book/task6_idempotent_replay.ipynb --inplace --ExecutePreprocessor.timeout=120

Khi chạy live pipeline đầy đủ:

docker compose --env-file .env -f infra/docker-compose.yml -f infra/docker-compose.neo4j.yml up -d kafka neo4j mongodb kafka-connect
uv run python scripts/deploy_connectors.py
uv run lab04 parse-file --file benchmark/__init__.py --no-dry-run
uv run lab04 replay-file --file benchmark/__init__.py --no-dry-run
docker run --rm --network infra_default -v ${PWD}:/app -w /app -e MONGODB_URI='mongodb://root:${MONGO_ROOT_PASSWORD}@cpg-mongodb:27017/?authSource=admin' apache/spark:3.5.1 /opt/spark/bin/spark-submit --conf spark.jars.ivy=/tmp/.ivy2 --packages org.apache.spark:spark-sql-kafka-0-10_2.12:3.5.1,org.mongodb.spark:mongo-spark-connector_2.12:10.3.0 spark_jobs/metadata_to_mongodb.py --bootstrap-servers kafka:29092 --checkpoint-dir /app/workspace/checkpoints/task6-live --available-now
uv run python scripts/inspect_neo4j_graph.py
mongosh mongodb://root:${MONGO_ROOT_PASSWORD}@localhost:27017/cpg_metadata?authSource=admin --eval "db.file_statistics.findOne({file_path:'benchmark/__init__.py'})"

Lần Spark cuối cùng phải resume từ checkpoint cũ với committed offsets bằng available offsets; khi không có event mới, Spark không tạo batch đọc lại dữ liệu cũ.

14Nhận xét

Task 6 hoàn chỉnh khi producer, message broker và downstream storage cùng tuân thủ stable identity. Parser-core xác minh deterministic IDs, skip unchanged files, graph diff và SQLite commit sau writer flush. Live evidence chứng minh phần end-to-end: sửa file thật trong cloned repository, publish/replay qua Kafka, Neo4j cập nhật không duplicate, MongoDB upsert đúng một document theo file_id, và Spark checkpoint không đọc lại offset đã xử lý.

Lỗi đáng chú ý khi chạy live là path Windows từng lọt vào event/SQLite dưới dạng backslash. Phần producer và SQLite state đã được canonical hóa về POSIX path để giữ stable ID, Kafka key và MongoDB upsert key nhất quán cross-platform.