#!/usr/bin/env python3
"""Instrument vLLM V1 scheduler to emit per-request KV-block snapshots.

Snapshot is dumped from inside `Scheduler.schedule()` right before it
returns the SchedulerOutput. Each snapshot captures, for the calling
EngineCore instance:

  {t_unix, step,
   total_blocks, free_blocks, used_blocks,
   running: [{req_id, n_blocks, n_computed, n_prompt, status}, ...],
   waiting: [{req_id, n_prompt, status}, ...]}

We throttle to ~10 Hz (default; override via MB5_PERIOD_MS env var) so
the file size stays manageable over a 13-min trace replay.

Output path: $MB5_LOG_DIR/mb5_kv_snapshot_pid<pid>.jsonl (default
MB5_LOG_DIR=/tmp). One file per EngineCore PID — so for a multi-instance
launch each instance gets its own log even on the same host.

Apply / revert marker: `# MB5_INSTRUMENT_START` / `# MB5_INSTRUMENT_END`.

Usage:
    python instrument_kv_snapshot.py --apply  [--venv PATH]
    python instrument_kv_snapshot.py --revert [--venv PATH]
    python instrument_kv_snapshot.py --check  [--venv PATH]
"""
from __future__ import annotations

import argparse
import re
from pathlib import Path

DEFAULT_VENV = Path("/home/admin/cpfs/wjh/agentic-kv-fresh/.venv")
TARGET_REL = "lib/python3.12/site-packages/vllm/v1/core/sched/scheduler.py"
MOONCAKE_REL = (
    "lib/python3.12/site-packages/vllm/distributed/kv_transfer/"
    "kv_connector/v1/mooncake/mooncake_connector.py"
)
LOGGERS_REL = "lib/python3.12/site-packages/vllm/v1/metrics/loggers.py"

START_MARK = "# MB5_INSTRUMENT_START"
END_MARK = "# MB5_INSTRUMENT_END"

# ---------- Patch 1: header (helper + globals) -----------------
# Insert right after the existing `from collections import ...` block.
# We pick a sentinel that's guaranteed to be in the file: the import of
# logger.
HEADER_ANCHOR = "logger = init_logger(__name__)\n"

HEADER_INSERT = f"""

{START_MARK}
import json as _mb5_json
import os as _mb5_os
import threading as _mb5_threading
import time as _mb5_time
_MB5_LOG_DIR = _mb5_os.environ.get("MB5_LOG_DIR", "/tmp")
_MB5_PERIOD_S = float(_mb5_os.environ.get("MB5_PERIOD_MS", "100")) / 1000.0
try:
    _mb5_os.makedirs(_MB5_LOG_DIR, exist_ok=True)
except Exception:
    pass
_MB5_LOG_PATH = _mb5_os.path.join(
    _MB5_LOG_DIR, f"mb5_kv_snapshot_pid{{_mb5_os.getpid()}}.jsonl"
)
_MB5_LOG_FILE = None
_MB5_LOG_LOCK = _mb5_threading.Lock()
_MB5_LAST_LOG = 0.0
_MB5_STEP_COUNT = 0


def _mb5_write_event(d):
    global _MB5_LOG_FILE
    if _MB5_LOG_FILE is None:
        _MB5_LOG_FILE = open(_MB5_LOG_PATH, "a", buffering=1)
    with _MB5_LOG_LOCK:
        _MB5_LOG_FILE.write(_mb5_json.dumps(d) + "\\n")


def _mb5_blocks_for_req(kvm, req_id):
    \"\"\"Sum block count across all single-type managers in the coordinator.\"\"\"
    total = 0
    try:
        managers = getattr(kvm.coordinator, "single_type_managers", None)
        if managers is None:
            return 0
        for mgr in managers:
            blocks = getattr(mgr, "req_to_blocks", None)
            if blocks is None:
                continue
            try:
                total += len(blocks.get(req_id, ()))
            except Exception:
                pass
    except Exception:
        pass
    return total


def _mb5_snapshot(scheduler):
    global _MB5_LAST_LOG, _MB5_STEP_COUNT
    _MB5_STEP_COUNT += 1
    now = _mb5_time.time()
    if now - _MB5_LAST_LOG < _MB5_PERIOD_S:
        return
    _MB5_LAST_LOG = now
    try:
        kvm = scheduler.kv_cache_manager
        pool = kvm.block_pool
        total_blocks = pool.num_gpu_blocks
        free_blocks = pool.get_num_free_blocks()
    except Exception:
        total_blocks = -1
        free_blocks = -1

    running = []
    try:
        for r in scheduler.running:
            running.append({{
                "req_id": str(r.request_id),
                "n_blocks": _mb5_blocks_for_req(kvm, r.request_id),
                "n_computed": int(getattr(r, "num_computed_tokens", 0)),
                "n_prompt": int(getattr(r, "num_prompt_tokens", 0)),
                "n_tokens": int(getattr(r, "num_tokens", 0)),
                "status": str(getattr(r, "status", "")),
            }})
    except Exception:
        pass

    waiting = []
    try:
        # self.waiting is a queue; iterate without consuming.
        for r in list(scheduler.waiting):
            waiting.append({{
                "req_id": str(r.request_id),
                "n_prompt": int(getattr(r, "num_prompt_tokens", 0)),
                "status": str(getattr(r, "status", "")),
            }})
    except Exception:
        pass

    _mb5_write_event({{
        "t_unix": now,
        "step": _MB5_STEP_COUNT,
        "total_blocks": total_blocks,
        "free_blocks": free_blocks,
        "used_blocks": (total_blocks - free_blocks) if total_blocks >= 0 else -1,
        "running": running,
        "waiting": waiting,
    }})
{END_MARK}
"""

# ---------- Patch 2: hook into schedule() return -----------------
# The schedule() method ends with `return scheduler_output`. We insert a
# snapshot call right before that. We anchor on the unique line near the
# end (line ~964) — `return scheduler_output` inside the schedule method.
# There may be other `return` statements; we target the one at minimum
# indentation that follows the schedule body. Use a stable preceding line.

SCHED_RET_TARGET = """        return scheduler_output

    def _agentic_emit_step_log("""

SCHED_RET_REPLACE = f"""        {START_MARK}
        _mb5_snapshot(self)
        {END_MARK}
        return scheduler_output

    def _agentic_emit_step_log("""


SCHED_PATCHES = [
    ("header",            HEADER_ANCHOR,     HEADER_ANCHOR + HEADER_INSERT),
    ("schedule() return", SCHED_RET_TARGET,  SCHED_RET_REPLACE),
]

# ---------- Patch 3: vLLM 0.18.1 kv_consumer AttributeError fix --------------
# In MooncakeConnectorWorker.__init__, `self.bootstrap_server` is only assigned
# inside the `is_kv_producer` branch (around line 615). For kv_consumer roles
# the attribute is never set, but later code paths (e.g. line ~1060) check
# `if self.bootstrap_server is not None:` and AttributeError. We initialize it
# unconditionally just before the role-conditional branch.
MOONCAKE_ANCHOR = "        self.reqs_need_send: dict[TransferId, SendBlockMeta] = {}\n"
MOONCAKE_INSERT = (
    f"        {START_MARK}\n"
    f"        self.bootstrap_server = None  # vLLM 0.18.1 kv_consumer fix\n"
    f"        {END_MARK}\n"
)

MOONCAKE_PATCHES = [
    ("kv_consumer bootstrap_server init", MOONCAKE_ANCHOR,
     MOONCAKE_ANCHOR + MOONCAKE_INSERT),
]

# ---------- Patch 4: vLLM 0.18.1 PD-consumer metrics counter underflow ------
# On a kv_consumer, a KV-load failure (Mooncake transfer returns -1 when the
# D-pool is full) makes vLLM emit an iteration-stats "correction" with NEGATIVE
# token deltas: PromptTokenStats fields (local_cache_hit, cached_tokens, ...)
# AND iteration_stats.{num_prompt_tokens, num_generation_tokens} all go below
# zero. Every Counter.inc() in loggers.record() then trips prometheus_client's
# "Counters can only be incremented by non-negative amounts.", which kills the
# EngineCore — turning one failed request into a total config collapse.
#
# Clamp every field that feeds a Counter.inc() in record() to >= 0. We anchor
# right after the `if iteration_stats is None: return` guard so the clamp runs
# before the first inc() (the corrupted-requests / preempted / prompt-token
# counters at the top of the method).
LOGGERS_ANCHOR = "        if iteration_stats is None:\n            return\n"
LOGGERS_INSERT = (
    f"        {START_MARK}\n"
    f"        _mb5_pts = iteration_stats.prompt_token_stats\n"
    f"        for _mb5_o, _mb5_a in (\n"
    f"            (iteration_stats, 'num_prompt_tokens'),\n"
    f"            (iteration_stats, 'num_generation_tokens'),\n"
    f"            (iteration_stats, 'num_preempted_reqs'),\n"
    f"            (iteration_stats, 'num_corrupted_reqs'),\n"
    f"            (_mb5_pts, 'computed'),\n"
    f"            (_mb5_pts, 'local_cache_hit'),\n"
    f"            (_mb5_pts, 'external_kv_transfer'),\n"
    f"            (_mb5_pts, 'cached_tokens'),\n"
    f"            (_mb5_pts, 'recomputed_tokens'),\n"
    f"        ):\n"
    f"            if getattr(_mb5_o, _mb5_a, 0) < 0:\n"
    f"                setattr(_mb5_o, _mb5_a, 0)\n"
    f"        {END_MARK}\n"
)

LOGGERS_PATCHES = [
    ("PD-consumer counter underflow clamp", LOGGERS_ANCHOR,
     LOGGERS_ANCHOR + LOGGERS_INSERT),
]

PATCH_FILES = [
    (TARGET_REL, SCHED_PATCHES),
    (MOONCAKE_REL, MOONCAKE_PATCHES),
    (LOGGERS_REL, LOGGERS_PATCHES),
]


def find_target(venv_or_path: Path, rel_path: str) -> Path:
    candidates = [venv_or_path / rel_path, DEFAULT_VENV / rel_path]
    for c in candidates:
        if c.is_file():
            return c
    raise FileNotFoundError(
        f"cannot find {rel_path} under {venv_or_path}"
    )


def is_patched(text: str) -> bool:
    return START_MARK in text


def apply_one(target: Path, patches: list) -> None:
    text = target.read_text()
    if is_patched(text):
        print(f"[mb5-instr] already patched: {target}")
        return
    new = text
    for name, src, dst in patches:
        if src not in new:
            raise RuntimeError(
                f"patch {name!r}: anchor not found in {target}."
            )
        new = new.replace(src, dst, 1)
    target.write_text(new)
    print(f"[mb5-instr] applied {len(patches)} patches -> {target}")


def revert_one(target: Path) -> None:
    text = target.read_text()
    if not is_patched(text):
        print(f"[mb5-instr] not patched (nothing to revert): {target}")
        return
    pat = re.compile(
        r"[ \t]*" + re.escape(START_MARK) + r".*?" + re.escape(END_MARK) + r"\n?",
        flags=re.DOTALL,
    )
    new = pat.sub("", text)
    new = re.sub(r"\n{3,}", "\n\n", new)
    target.write_text(new)
    print(f"[mb5-instr] reverted: {target}")


def main() -> None:
    p = argparse.ArgumentParser()
    p.add_argument("--apply", action="store_true")
    p.add_argument("--revert", action="store_true")
    p.add_argument("--check", action="store_true")
    p.add_argument("--venv", type=Path, default=DEFAULT_VENV)
    args = p.parse_args()
    for rel_path, patches in PATCH_FILES:
        target = find_target(args.venv, rel_path)
        if args.apply:
            apply_one(target, patches)
        elif args.revert:
            revert_one(target)
        elif args.check:
            text = target.read_text()
            state = 'PATCHED' if is_patched(text) else 'CLEAN'
            print(f"[mb5-instr] {state}: {target}")
        else:
            p.error("specify --apply / --revert / --check")


if __name__ == "__main__":
    main()
