netx/netx_api/biz_migration/service.py
oliver 315539e0fd Optimize cutover compare/monitor per the 2026-10-12 plan.
Correctness (A): normalize port-map names with the template rules (snapshot v2,
v1 replays unchanged); protect mappings/templates that are referenced; stop
dropping edited mapping text; accept pulled-but-not-shutdown ports as migrated;
validate baseline ownership on project create; one canonical key form; fix
built-in template tolerances and upgrade old packaged values in place.

Cross-vendor identity (B): single identity pipeline for every path, per-side and
per-vendor interface rules, value maps, filters after normalization, mapping
pre-flight for compare and cutover projects, SQL eligibility fixes.

Data quality (V): no_data / suspect_parse / not_collected sheet states, command
slice coverage, directional compare modes, value-sorted duplicate pairing,
before-side raw names in results and CSV.

Robustness (H): bounded auto-run history, background evaluate/finish with
polling, manual route tables out of the acceptance gate, baseline row cache,
scoped duplicate-key failures, merged HF tasks with a per-NE session cap,
same-device cutover, explicit scope_by_port_map, read-only GETs, resolvable red
tickets.

Frontend: pre-flight report, mapping table view, template editor support for the
new rules, data-state banners. Docs: canonical field contract.

Not included: Huawei status parsers (no real samples), SQL-path route compare.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:51:55 +08:00

3225 lines
137 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""CRUD + evaluate for cutover migration monitor."""
from __future__ import annotations
import hashlib
import logging
import threading
from collections import OrderedDict
from collections import Counter
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy import and_, insert, or_
from sqlalchemy.orm import Session
from ..biz_state.compare_rules import apply_row_filters, scalar_text
from ..config import settings
from ..biz_state.compare_service import (
_load_metric_rows,
_port_map_dict,
sheet_key,
sheet_title,
template_metrics,
)
from ..models import (
BizCompareTemplate,
BizMigrationBatch,
BizMigrationDiff,
BizMigrationProject,
BizMigrationRedTicket,
BizMigrationRun,
BizMonitorTemplate,
BizPortMapping,
BizStateBatch,
BizStateBatchCommand,
BizStateTask,
)
from ..timeutil import utcnow_naive
from . import monitor_templates as mon_tpl
from .evaluate import (
PORT_METRIC_ID,
evaluate_metric_dual,
override_for_sheet,
parse_expect_set,
port_sheet_def,
)
PURPOSE_CUTOVER_HF = "cutover_hf"
_log = logging.getLogger("netx.biz_migration")
class BaselineRowCache:
"""Process-local LRU of immutable baseline rows, bounded by total row count."""
def __init__(self, max_rows: int = 500_000) -> None:
self.max_rows = max_rows
self._items: "OrderedDict[tuple[str, str], list[dict[str, Any]]]" = OrderedDict()
self._rows = 0
self._lock = threading.Lock()
def get_or_load(self, batch_id: str, metric_id: str, loader: Any) -> list[dict[str, Any]]:
key = (batch_id, metric_id)
with self._lock:
hit = self._items.get(key)
if hit is not None:
self._items.move_to_end(key)
return hit
rows = loader()
if self.max_rows > 0 and len(rows) <= self.max_rows:
with self._lock:
if key not in self._items:
self._items[key] = rows
self._rows += len(rows)
while self._rows > self.max_rows and self._items:
_, evicted = self._items.popitem(last=False)
self._rows -= len(evicted)
return rows
def invalidate(self, batch_id: str = "") -> None:
with self._lock:
for key in [k for k in self._items if not batch_id or k[0] == batch_id]:
self._rows -= len(self._items.pop(key))
BASELINE_ROWS = BaselineRowCache(int(getattr(settings, "biz_migration_baseline_cache_rows", 500_000)))
MANUAL_ROUTE_METRICS = frozenset({"ip_route", "ipv6_route", "bgp_route"})
def _parse_dt(raw: Any) -> datetime | None:
"""Parse ISO datetime to naive UTC for DB storage.
Accepts ``Z`` / offset-aware ISO (preferred from clients) or naive ISO
(treated as already-UTC). Comparisons use :func:`utcnow_naive`.
"""
if raw is None or raw == "":
return None
if isinstance(raw, datetime):
if raw.tzinfo is not None:
return raw.astimezone(timezone.utc).replace(tzinfo=None)
return raw
s = str(raw).strip()
if not s:
return None
try:
if s.endswith("Z"):
s = s[:-1] + "+00:00"
dt = datetime.fromisoformat(s)
if dt.tzinfo is not None:
return dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
except ValueError:
return None
def _dt_iso(dt: datetime | None) -> str | None:
"""Serialize naive-UTC datetime with explicit ``Z`` for global clients."""
if not dt:
return None
return dt.replace(tzinfo=None).isoformat() + "Z"
def _task_looks_like_hf(task: BizStateTask | None) -> bool:
"""Legacy recovery heuristic. Prefer purpose; note match only when purpose empty."""
if not task:
return False
purpose = str(getattr(task, "purpose", None) or "").strip()
if purpose == PURPOSE_CUTOVER_HF:
return True
if purpose:
return False
note = str(task.note or "")
return note.startswith("割接高频")
def _find_portrait_sibling(db: Session, hf_task: BizStateTask) -> BizStateTask | None:
"""Prefer a non-HF sibling task on the same NE (legacy recovery)."""
rows = (
db.query(BizStateTask)
.filter(
BizStateTask.source == hf_task.source,
BizStateTask.ne_id == hf_task.ne_id,
BizStateTask.id != hf_task.id,
)
.order_by(BizStateTask.created_at.asc())
.all()
)
for r in rows:
if not _task_looks_like_hf(r):
return r
return None
def _migrate_legacy_hf_slot(db: Session, proj: BizMigrationProject, side: str) -> None:
"""If portrait slot holds an HF task and hf slot empty, move HF id; restore portrait sibling if found."""
portrait_attr = f"{side}_task_id"
hf_attr = f"{side}_hf_task_id"
portrait_id = str(getattr(proj, portrait_attr, None) or "").strip()
hf_id = str(getattr(proj, hf_attr, None) or "").strip()
if hf_id or not portrait_id:
return
task = db.get(BizStateTask, portrait_id)
if not task or not _task_looks_like_hf(task):
return
setattr(proj, hf_attr, portrait_id)
sibling = _find_portrait_sibling(db, task)
if sibling:
setattr(proj, portrait_attr, sibling.id)
# else keep portrait_id pointing at HF until user rebinds — hf slot is correct
def migrate_project_hf_slots(db: Session, proj: BizMigrationProject, *, commit: bool = False) -> BizMigrationProject:
"""Idempotent legacy repair: split HF out of portrait slots when needed."""
before = (proj.old_task_id, proj.new_task_id, getattr(proj, "old_hf_task_id", None), getattr(proj, "new_hf_task_id", None))
_migrate_legacy_hf_slot(db, proj, "old")
_migrate_legacy_hf_slot(db, proj, "new")
after = (proj.old_task_id, proj.new_task_id, getattr(proj, "old_hf_task_id", None), getattr(proj, "new_hf_task_id", None))
if before != after:
proj.updated_at = utcnow_naive()
if commit:
db.commit()
db.refresh(proj)
return proj
def portrait_task_id(proj: BizMigrationProject, side: str) -> str:
return str(getattr(proj, f"{side}_task_id", None) or "").strip()
def hf_task_id(proj: BizMigrationProject, side: str) -> str:
return str(getattr(proj, f"{side}_hf_task_id", None) or "").strip()
def current_task_id(proj: BizMigrationProject, side: str) -> str:
"""Evaluate current batches: prefer HF, fall back to portrait."""
return hf_task_id(proj, side)
def _metric_interval_map(proj: BizMigrationProject) -> dict[str, int]:
raw = getattr(proj, "metric_interval_sec_json", None)
out: dict[str, int] = {}
if isinstance(raw, dict):
for k, v in raw.items():
mid = str(k).strip()
if not mid:
continue
try:
out[mid] = max(60, int(v))
except (TypeError, ValueError):
continue
return out
def _normalize_metric_intervals(raw: Any) -> dict[str, int]:
if not isinstance(raw, dict):
return {}
out: dict[str, int] = {}
for k, v in raw.items():
mid = str(k).strip()
if not mid:
continue
try:
out[mid] = max(60, int(v))
except (TypeError, ValueError):
continue
return out
def _group_metrics_by_interval(
metric_ids: list[str],
*,
default_interval: int,
by_metric: dict[str, int],
) -> list[tuple[int, list[str]]]:
"""Group metrics by effective interval (ascending)."""
groups: dict[int, list[str]] = {}
default_iv = max(60, int(default_interval or 60))
for mid in metric_ids:
m = str(mid).strip()
if not m:
continue
iv = max(60, int(by_metric.get(m) or default_iv))
groups.setdefault(iv, []).append(m)
return sorted(((iv, mids) for iv, mids in groups.items()), key=lambda x: x[0])
def _hf_bindings(proj: BizMigrationProject, side: str) -> list[dict[str, Any]]:
attr = f"{side}_hf_bindings_json"
raw = getattr(proj, attr, None)
if isinstance(raw, list) and raw:
out: list[dict[str, Any]] = []
for row in raw:
if not isinstance(row, dict):
continue
tid = str(row.get("task_id") or "").strip()
if not tid:
continue
mids = [str(x).strip() for x in (row.get("metric_ids") or []) if str(x).strip()]
try:
iv = max(60, int(row.get("interval_sec") or 60))
except (TypeError, ValueError):
iv = 60
out.append({"task_id": tid, "metric_ids": mids, "interval_sec": iv,
"collect_group_id": str(row.get("collect_group_id") or ""),
"manual_only": bool(row.get("manual_only"))})
if out:
return out
# Legacy single slot
tid = hf_task_id(proj, side)
if tid:
return [
{
"task_id": tid,
"metric_ids": _project_collect_override(proj),
"interval_sec": int(getattr(proj, "hf_interval_sec", None) or 60),
}
]
return []
def _all_hf_task_ids(proj: BizMigrationProject, side: str) -> list[str]:
seen: list[str] = []
for b in _hf_bindings(proj, side):
tid = str(b.get("task_id") or "").strip()
if tid and tid not in seen:
seen.append(tid)
return seen
def _hf_task_id_for_metric(proj: BizMigrationProject, side: str, metric_id: str) -> str:
mid = str(metric_id or "").strip()
for b in _hf_bindings(proj, side):
mids = b.get("metric_ids") or []
if mid and mid in mids:
return str(b.get("task_id") or "").strip()
if not mids:
# empty metric_ids = covers all for that binding
return str(b.get("task_id") or "").strip()
return hf_task_id(proj, side) or portrait_task_id(proj, side)
def _current_batch_for_metric(
db: Session,
proj: BizMigrationProject,
side: str,
metric_id: str,
*,
pinned_batch_id: str = "",
) -> BizStateBatch | None:
tid = _hf_task_id_for_metric(proj, side, metric_id)
pin = pinned_batch_id.strip()
if pin:
pinned = db.get(BizStateBatch, pin)
# Only honor pin when it belongs to this metric's HF task (multi-interval safe)
if pinned and (not tid or str(pinned.task_id or "") == tid):
# Pinned partial / failed metric → treat as missing (avoid false red)
if not _metric_sample_ok(db, pinned, metric_id):
return None
return pinned
batch = (
db.query(BizStateBatch)
.filter(BizStateBatch.task_id == tid, BizStateBatch.status.notin_(("running", "queued")))
.order_by(BizStateBatch.started_at.desc(), BizStateBatch.id.desc()).first()
) if tid else None
if batch and not _metric_sample_ok(db, batch, metric_id):
return None
return batch
def _metric_sample_ok(db: Session, batch: BizStateBatch, metric_id: str) -> bool:
"""An empty result requires a successful command receipt, just like a full one.
Batch success alone cannot prove this metric was collected. Unmatched and
skipped parsers are uncertainty, never evidence that a business disappeared.
"""
if batch.status not in ("success", "partial"):
return False
statuses = [str(s or "").lower() for (s,) in db.query(BizStateBatchCommand.parse_status)
.filter(BizStateBatchCommand.batch_id == batch.id,
BizStateBatchCommand.metric_id == metric_id).all()]
return bool(statuses) and all(s in ("ok", "ok_aux") for s in statuses)
def _uncovered_slice_sides(
db: Session, sheet: dict[str, Any], metric_id: str, old_batch_id: str, new_batch_id: str,
) -> list[str]:
from ..biz_state.compare_quality import covers_slice, side_receipts, sheet_slice
wanted = sheet_slice(sheet)
if not wanted:
return []
out = []
for side, bid in (("old", old_batch_id), ("new", new_batch_id)):
receipts = side_receipts(db, bid, metric_id)
if receipts and not covers_slice(receipts, wanted):
out.append(side)
return out
def _current_pair_for_metric(db: Session, proj: BizMigrationProject, mid: str,
pin_old: str = "", pin_new: str = "") -> tuple[BizStateBatch | None, BizStateBatch | None, dict[str, Any]]:
old_tid, new_tid = (_hf_task_id_for_metric(proj, side, mid) for side in ("old", "new"))
old_task = db.get(BizStateTask, old_tid) if old_tid else None
new_task = db.get(BizStateTask, new_tid) if new_tid else None
gid = old_task.collect_group_id if old_task else ""
meta: dict[str, Any] = {"collect_group_id": gid, "manual_only": bool(old_task and old_task.collect_manual_only)}
if gid and old_tid and old_tid == new_tid and not (pin_old or pin_new):
# Same device on both sides: one task, one batch, trivially the same round.
latest = db.query(BizStateBatch).filter(
BizStateBatch.collect_group_id == gid, BizStateBatch.task_id == old_tid).order_by(
BizStateBatch.queued_at.desc(), BizStateBatch.started_at.desc(), BizStateBatch.id.desc()).first()
if latest is None:
return None, None, {**meta, "sample_issues": ["collect_pair_missing"]}
meta.update({"collect_round_id": latest.collect_round_id, "old_collect_status": latest.status,
"new_collect_status": latest.status})
if latest.status in ("running", "queued"):
return None, None, {**meta, "sample_issues": ["collect_pair_pending"]}
if not _metric_sample_ok(db, latest, mid):
return None, None, {**meta, "sample_issues": ["collect_pair_failed"]}
return latest, latest, meta
if gid and not (pin_old or pin_new):
if not new_task or new_task.collect_group_id != gid:
return None, None, {**meta, "sample_issues": ["collect_pair_invalid"]}
latest = db.query(BizStateBatch).filter(BizStateBatch.collect_group_id == gid,
BizStateBatch.task_id.in_((old_tid, new_tid))).order_by(
BizStateBatch.queued_at.desc(), BizStateBatch.started_at.desc(), BizStateBatch.id.desc()).first()
peers = db.query(BizStateBatch).filter(BizStateBatch.collect_round_id == latest.collect_round_id,
BizStateBatch.task_id.in_((old_tid, new_tid))).all() if latest and latest.collect_round_id else []
meta.update({"collect_round_id": latest.collect_round_id if latest else "",
"old_collect_status": next((b.status for b in peers if b.task_id == old_tid), "missing"),
"new_collect_status": next((b.status for b in peers if b.task_id == new_tid), "missing")})
if len(peers) != 2:
return None, None, {**meta, "sample_issues": ["collect_pair_missing"]}
if any(b.status in ("running", "queued") for b in peers):
return None, None, {**meta, "sample_issues": ["collect_pair_pending"]}
by_task = {b.task_id: b for b in peers}
if not all(_metric_sample_ok(db, b, mid) for b in peers):
return None, None, {**meta, "sample_issues": ["collect_pair_failed"]}
return by_task[old_tid], by_task[new_tid], meta
old = _current_batch_for_metric(db, proj, "old", mid, pinned_batch_id=pin_old)
new = _current_batch_for_metric(db, proj, "new", mid, pinned_batch_id=pin_new)
if old and new and (old.collect_round_id or new.collect_round_id) and old.collect_round_id != new.collect_round_id:
return None, None, {**meta, "sample_issues": ["collect_round_mismatch"]}
return old, new, meta
def _sample_timing(proj: BizMigrationProject, mid: str, old: BizStateBatch,
new: BizStateBatch, *, check_age: bool) -> dict[str, Any]:
interval = max(60, _metric_interval_map(proj).get(mid) or proj.hf_interval_sec or 60)
old_at, new_at = old.ended_at or old.started_at, new.ended_at or new.started_at
durations = [max(0, ((b.ended_at or b.started_at) - b.started_at).total_seconds()) for b in (old, new)]
age_limit, skew_limit = interval * 3 + max(durations), interval * 2
reasons: list[str] = []
if not old_at or not new_at:
reasons.append("sample_time_missing")
skew = None
else:
skew = abs((old_at - new_at).total_seconds())
if skew > skew_limit:
reasons.append("sample_skew")
if check_age and max((utcnow_naive() - old_at).total_seconds(),
(utcnow_naive() - new_at).total_seconds()) > age_limit:
reasons.append("sample_stale")
start_skew = abs((old.started_at - new.started_at).total_seconds())
if old.collect_round_id and start_skew > min(30, interval / 2):
reasons.append("sample_start_skew")
return {"old_collected_at": _dt_iso(old_at), "new_collected_at": _dt_iso(new_at),
"age_limit_sec": age_limit, "skew_limit_sec": skew_limit,
"skew_sec": skew, "start_skew_sec": start_skew, "sample_issues": reasons,
"old_duration_sec": durations[0], "new_duration_sec": durations[1],
"old_queue_sec": max(0, (old.started_at - (old.queued_at or old.started_at)).total_seconds()),
"new_queue_sec": max(0, (new.started_at - (new.queued_at or new.started_at)).total_seconds())}
def find_portrait_task_for_ne(db: Session, *, source: str, ne_id: str) -> BizStateTask | None:
"""Best portrait (non-HF) biz_state task for an NE — for baseline binding."""
src = str(source or "managed").strip().lower() or "managed"
nid = str(ne_id or "").strip()
if not nid:
return None
rows = (
db.query(BizStateTask)
.filter(BizStateTask.source == src, BizStateTask.ne_id == nid)
.order_by(BizStateTask.created_at.asc())
.all()
)
for r in rows:
if not _task_looks_like_hf(r):
return r
return None
def list_ne_portrait_options(db: Session, *, source: str, ne_id: str, limit: int = 30) -> dict[str, Any]:
"""Portrait task + recent batches for baseline picker when NE is selected."""
task = find_portrait_task_for_ne(db, source=source, ne_id=ne_id)
if not task:
return {
"source": source,
"ne_id": ne_id,
"task": None,
"batches": [],
"hint": "no_portrait_task",
}
batches = (
db.query(BizStateBatch)
.filter(
BizStateBatch.task_id == task.id,
BizStateBatch.status.in_(("success", "partial")),
)
.order_by(BizStateBatch.started_at.desc())
.limit(max(1, min(100, int(limit or 30))))
.all()
)
return {
"source": source,
"ne_id": ne_id,
"task": _task_brief(db, task.id),
"batches": [_batch_brief(db, b.id) for b in batches],
"hint": "",
}
def _project_collect_override(proj: BizMigrationProject) -> list[str]:
raw = getattr(proj, "collect_metric_ids_json", None)
if isinstance(raw, list):
return [str(x).strip() for x in raw if str(x).strip()]
return []
def _task_brief(db: Session, task_id: str) -> dict[str, Any]:
t = db.get(BizStateTask, task_id) if task_id else None
if not t:
return {
"id": task_id or "",
"ne_id": "",
"ne_name": "",
"ne_ip": "",
"vendor": "",
"purpose": "",
}
return {
"id": t.id,
"ne_id": t.ne_id or "",
"ne_name": t.ne_name,
"ne_ip": t.ne_ip,
"vendor": t.vendor,
"status": t.status,
"note": t.note,
"purpose": str(getattr(t, "purpose", None) or ""),
"interval_sec": t.interval_sec,
"collect_running": bool(t.collect_running),
}
def _batch_brief(db: Session, batch_id: str) -> dict[str, Any]:
b = db.get(BizStateBatch, batch_id) if batch_id else None
if not b:
return {"id": batch_id or "", "status": "", "started_at": None, "alias": ""}
return {
"id": b.id,
"status": b.status,
"started_at": b.started_at.isoformat() if b.started_at else None,
"row_count": b.row_count,
"alias": str(getattr(b, "alias", "") or ""),
}
def _monitor_template_brief(db: Session, template_id: str) -> dict[str, Any]:
tid = str(template_id or "").strip()
if not tid:
return {
"id": "",
"name": "",
"compare_template_id": "",
"compare_template_name": "",
"collect_metric_ids": [],
"collect_metric_ids_effective": [],
}
row = db.get(BizMonitorTemplate, tid)
if not row:
return {
"id": tid,
"name": "",
"compare_template_id": "",
"compare_template_name": "",
"collect_metric_ids": [],
"collect_metric_ids_effective": [],
}
cmp_name = ""
if row.compare_template_id:
ct = db.get(BizCompareTemplate, row.compare_template_id)
cmp_name = (ct.name if ct else "") or ""
return mon_tpl._out(row, cmp_name, db=db) # noqa: SLF001
def resolve_project_monitor_template(
db: Session, proj: BizMigrationProject, *, persist: bool = True,
) -> BizMonitorTemplate:
"""Return monitor template for project; seed default port template if unbound.
``persist=False`` is for read-only callers (GET): it never seeds, upgrades or binds,
it only reports which template the project would use.
"""
if persist:
mon_tpl.ensure_default_monitor_templates(db)
tid = str(getattr(proj, "monitor_template_id", None) or "").strip()
row = db.get(BizMonitorTemplate, tid) if tid else None
if row:
return row
default_id = mon_tpl.default_port_monitor_template_id(db, seed=persist)
row = db.get(BizMonitorTemplate, default_id) if default_id else None
if not row:
raise HTTPException(status_code=400, detail="monitor_template_required")
# Persist default on first use so UI shows binding
if not tid and persist:
proj.monitor_template_id = row.id
proj.updated_at = utcnow_naive()
db.commit()
db.refresh(proj)
return row
def resolve_evaluate_sheets(
db: Session, mt: BizMonitorTemplate
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any], list[dict[str, str]]]:
"""Return (sheets, sheet_overrides, defaults, iface_normalize_rules)."""
from ..biz_state.compare_service import template_iface_normalize
overrides = list(mt.sheet_overrides_json or []) if isinstance(mt.sheet_overrides_json, list) else []
defaults = dict(mt.defaults_json or {}) if isinstance(mt.defaults_json, dict) else {}
cid = str(mt.compare_template_id or "").strip()
if cid:
ct = db.get(BizCompareTemplate, cid)
if ct:
sheets = template_metrics(ct)
if sheets:
return sheets, overrides, defaults, template_iface_normalize(ct)
# Fallback: built-in port sheet (legacy)
return [port_sheet_def()], overrides, defaults, []
def resolve_collect_metric_ids(db: Session, proj: BizMigrationProject, *, persist: bool = True) -> list[str]:
"""Project-level override wins; else monitor template; else sheet metric ids."""
override = _project_collect_override(proj)
if override:
return override
mt = resolve_project_monitor_template(db, proj, persist=persist)
collect = [str(x).strip() for x in (mt.collect_metric_ids_json or []) if str(x).strip()]
if collect:
return collect
sheets, _, _, _ = resolve_evaluate_sheets(db, mt)
seen: list[str] = []
for s in sheets:
mid = str(s.get("metric_id") or "").strip()
if mid and mid not in seen:
seen.append(mid)
return seen
def project_to_dict(db: Session, p: BizMigrationProject) -> dict[str, Any]:
"""Serialize project. Does not migrate slots (GET stays read-only)."""
collect_override = _project_collect_override(p)
mt_brief = _monitor_template_brief(db, getattr(p, "monitor_template_id", None) or "")
# Same resolution as ensure/evaluate (empty → compare sheets)
effective_collect = resolve_collect_metric_ids(db, p, persist=False)
return {
"id": p.id,
"name": p.name,
"old_task_id": p.old_task_id,
"new_task_id": p.new_task_id,
"old_hf_task_id": getattr(p, "old_hf_task_id", None) or "",
"new_hf_task_id": getattr(p, "new_hf_task_id", None) or "",
"old_hf_bindings": _hf_bindings(p, "old"),
"new_hf_bindings": _hf_bindings(p, "new"),
"old_baseline_batch_id": p.old_baseline_batch_id,
"new_baseline_batch_id": p.new_baseline_batch_id,
"mapping_id": p.mapping_id,
"monitor_template_id": getattr(p, "monitor_template_id", None) or "",
"monitor_template": mt_brief,
"collect_metric_ids": collect_override,
"collect_metric_ids_effective": effective_collect,
"metric_interval_sec": _metric_interval_map(p),
"hf_interval_sec": int(getattr(p, "hf_interval_sec", None) or 60),
"hf_start_at": _dt_iso(getattr(p, "hf_start_at", None)),
"hf_end_at": _dt_iso(getattr(p, "hf_end_at", None)),
"status": p.status,
"note": p.note,
"old_task": _task_brief(db, p.old_task_id),
"new_task": _task_brief(db, p.new_task_id),
"old_hf_task": _task_brief(db, getattr(p, "old_hf_task_id", None) or ""),
"new_hf_task": _task_brief(db, getattr(p, "new_hf_task_id", None) or ""),
"old_baseline": _batch_brief(db, p.old_baseline_batch_id),
"new_baseline": _batch_brief(db, p.new_baseline_batch_id),
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
def batch_to_dict(b: BizMigrationBatch) -> dict[str, Any]:
return {
"id": b.id,
"project_id": b.project_id,
"batch_label": b.batch_label,
"status": b.status,
"expect_set": dict(b.expect_set_json or {}),
"started_at": b.started_at.isoformat() if b.started_at else None,
"ended_at": b.ended_at.isoformat() if b.ended_at else None,
"accept_status": getattr(b, "accept_status", None) or "none",
"accept_run_id": getattr(b, "accept_run_id", None) or "",
"accept_summary": dict(getattr(b, "accept_summary_json", None) or {}),
"note": b.note,
"created_at": b.created_at.isoformat() if b.created_at else None,
"updated_at": b.updated_at.isoformat() if b.updated_at else None,
}
def _preload_project_refs(db: Session, rows: list[BizMigrationProject]) -> list[Any]:
"""Load everything ``project_to_dict`` looks up with a handful of IN queries.
``Session.get`` answers from the identity map afterwards, so a list of N projects
costs a constant number of queries instead of ~10 per project. The identity map is
weak: the caller must keep the returned objects alive while it serializes.
"""
keep: list[Any] = []
def ids(*attrs: str) -> set[str]:
out: set[str] = set()
for p in rows:
for a in attrs:
v = getattr(p, a, None)
if v:
out.add(str(v))
return out
task_ids = ids("old_task_id", "new_task_id", "old_hf_task_id", "new_hf_task_id")
for side in ("old", "new"):
for p in rows:
task_ids.update(b["task_id"] for b in _hf_bindings(p, side) if b.get("task_id"))
if task_ids:
keep.append(db.query(BizStateTask).filter(BizStateTask.id.in_(task_ids)).all())
batch_ids = ids("old_baseline_batch_id", "new_baseline_batch_id")
if batch_ids:
keep.append(db.query(BizStateBatch).filter(BizStateBatch.id.in_(batch_ids)).all())
template_ids = ids("monitor_template_id")
if template_ids:
monitors = db.query(BizMonitorTemplate).filter(BizMonitorTemplate.id.in_(template_ids)).all()
keep.append(monitors)
compare_ids = {m.compare_template_id for m in monitors if m.compare_template_id}
if compare_ids:
keep.append(db.query(BizCompareTemplate).filter(BizCompareTemplate.id.in_(compare_ids)).all())
return keep
def list_projects(db: Session) -> list[dict[str, Any]]:
rows = db.query(BizMigrationProject).order_by(BizMigrationProject.created_at.desc()).all()
preloaded = _preload_project_refs(db, rows) # noqa: F841 — keeps the identity map populated
return [project_to_dict(db, p) for p in rows]
def _reject_ambiguous_mapping(
port_map: dict[str, str], *, conflicts: list[dict[str, str]] | None = None,
raw: dict[str, str] | None = None,
) -> None:
"""Cutover monitoring needs a 1:1 map. Say which rows collide, not just "ambiguous"."""
by_target: dict[str, list[str]] = {}
for before, after in port_map.items():
by_target.setdefault(after, []).append(before)
n_to_1 = [{"target": t, "sources": sorted(src)} for t, src in sorted(by_target.items()) if len(src) > 1]
collided = bool(conflicts) or (raw is not None and len(port_map) != len(raw))
if not n_to_1 and not collided:
return
parts = [f"{', '.join(c['sources'])} -> {c['target']}" for c in n_to_1]
parts += [f"{c['dropped_from']} vs {c['kept_from']}" for c in (conflicts or [])]
raise HTTPException(status_code=400, detail={
"code": "mapping_target_ambiguous",
"message": "mapping_target_ambiguous: " + "; ".join(parts),
"n_to_1": n_to_1, "key_conflicts": list(conflicts or []),
})
def _validate_baseline_ownership(db: Session, side: str, batch_id: str, task_id: str) -> None:
"""A baseline batch must be a finished sample of that side's portrait task."""
bid = str(batch_id or "").strip()
if not bid:
return
sample = db.get(BizStateBatch, bid)
if not sample or sample.task_id != str(task_id or "").strip() or sample.status not in ("success", "partial"):
raise HTTPException(status_code=400, detail=f"{side}_baseline_invalid")
def create_project(db: Session, body: dict[str, Any]) -> dict[str, Any]:
"""One-shot cutover project: NE + monitor template + metrics + schedule + baseline + mapping.
Auto-binds portrait tasks when found for the NEs (baselines come from them).
Always creates HF biz_state tasks from the monitor template collect set.
"""
name = str(body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
old_ne = body.get("old_ne") if isinstance(body.get("old_ne"), dict) else None
new_ne = body.get("new_ne") if isinstance(body.get("new_ne"), dict) else None
old_task_id = str(body.get("old_task_id") or body.get("old_portrait_task_id") or "").strip()
new_task_id = str(body.get("new_task_id") or body.get("new_portrait_task_id") or "").strip()
# Auto-bind portrait by NE when not explicitly provided
if not old_task_id and old_ne and old_ne.get("ne_id"):
pt = find_portrait_task_for_ne(
db,
source=str(old_ne.get("source") or "managed"),
ne_id=str(old_ne.get("ne_id") or ""),
)
if pt:
old_task_id = pt.id
if not new_task_id and new_ne and new_ne.get("ne_id"):
pt = find_portrait_task_for_ne(
db,
source=str(new_ne.get("source") or "managed"),
ne_id=str(new_ne.get("ne_id") or ""),
)
if pt:
new_task_id = pt.id
if old_task_id and not db.get(BizStateTask, old_task_id):
raise HTTPException(status_code=404, detail="old_task_not_found")
if new_task_id and not db.get(BizStateTask, new_task_id):
raise HTTPException(status_code=404, detail="new_task_not_found")
has_old = bool(old_task_id or (old_ne and old_ne.get("ne_id")))
has_new = bool(new_task_id or (new_ne and new_ne.get("ne_id")))
if not has_old or not has_new:
raise HTTPException(status_code=400, detail="old_new_ne_required")
mapping_id = str(body.get("mapping_id") or "").strip()
if mapping_id and not db.get(BizPortMapping, mapping_id):
raise HTTPException(status_code=404, detail="mapping_not_found")
# Inline mapping rows → create BizPortMapping
mapping_rows = body.get("mapping_rows")
mapping_name = str(body.get("mapping_name") or "").strip()
if not mapping_id and isinstance(mapping_rows, list) and mapping_rows:
from ..biz_state import compare_service as cmp_svc
created_map = cmp_svc.create_mapping(
db,
{
"name": mapping_name or f"{name}-ports",
"rows": mapping_rows,
},
)
mapping_id = str(created_map.get("id") or "")
monitor_template_id = str(body.get("monitor_template_id") or "").strip()
if monitor_template_id:
if not db.get(BizMonitorTemplate, monitor_template_id):
raise HTTPException(status_code=404, detail="monitor_template_not_found")
else:
monitor_template_id = mon_tpl.default_port_monitor_template_id(db)
if not monitor_template_id:
raise HTTPException(status_code=400, detail="monitor_template_required")
collect_ids = body.get("collect_metric_ids")
if not isinstance(collect_ids, list):
collect_ids = []
collect_ids = [str(x).strip() for x in collect_ids if str(x).strip()]
metric_intervals = _normalize_metric_intervals(body.get("metric_interval_sec"))
hf_interval = max(60, int(body.get("hf_interval_sec") or body.get("interval_sec") or 60))
hf_start = _parse_dt(body.get("hf_start_at"))
hf_end = _parse_dt(body.get("hf_end_at"))
old_baseline = str(body.get("old_baseline_batch_id") or "").strip()
new_baseline = str(body.get("new_baseline_batch_id") or "").strip()
if old_baseline and not db.get(BizStateBatch, old_baseline):
raise HTTPException(status_code=404, detail="old_baseline_not_found")
if new_baseline and not db.get(BizStateBatch, new_baseline):
raise HTTPException(status_code=404, detail="new_baseline_not_found")
_validate_baseline_ownership(db, "old", old_baseline, old_task_id)
_validate_baseline_ownership(db, "new", new_baseline, new_task_id)
p = BizMigrationProject(
id=uuid4().hex,
name=name,
old_task_id=old_task_id,
new_task_id=new_task_id,
old_hf_task_id="",
new_hf_task_id="",
old_baseline_batch_id=old_baseline,
new_baseline_batch_id=new_baseline,
mapping_id=mapping_id,
monitor_template_id=monitor_template_id,
collect_metric_ids_json=collect_ids,
metric_interval_sec_json=metric_intervals,
old_hf_bindings_json=[],
new_hf_bindings_json=[],
hf_interval_sec=hf_interval,
hf_start_at=hf_start,
hf_end_at=hf_end,
status=str(body.get("status") or "active").strip() or "active",
note=str(body.get("note") or "")[:500],
)
db.add(p)
db.commit()
db.refresh(p)
# Always create HF from NE / portrait — this is the cutover collect path
ensure_highfreq(
db,
p.id,
interval_sec=hf_interval,
retention_days=int(body.get("retention_days") or 7),
collect_now=bool(body.get("collect_now", False)),
old_ne=old_ne,
new_ne=new_ne,
)
p = db.get(BizMigrationProject, p.id) or p
return project_to_dict(db, p)
def get_project(db: Session, project_id: str) -> dict[str, Any]:
p = db.get(BizMigrationProject, project_id)
if not p:
raise HTTPException(status_code=404, detail="project_not_found")
return project_to_dict(db, p)
def patch_project(db: Session, project_id: str, body: dict[str, Any]) -> dict[str, Any]:
p = db.get(BizMigrationProject, project_id)
if not p:
raise HTTPException(status_code=404, detail="project_not_found")
# Validate incoming baseline ownership before mutating any project field.
for side in ("old", "new"):
bid = str(body.get(f"{side}_baseline_batch_id", getattr(p, f"{side}_baseline_batch_id")) or "").strip()
tid = str(body.get(f"{side}_task_id", getattr(p, f"{side}_task_id")) or "").strip()
_validate_baseline_ownership(db, side, bid, tid)
if "name" in body and body["name"] is not None:
p.name = str(body["name"]).strip() or p.name
if "note" in body and body["note"] is not None:
p.note = str(body["note"])[:500]
if "status" in body and body["status"] is not None:
p.status = str(body["status"]).strip() or p.status
if "mapping_id" in body and body["mapping_id"] is not None:
mid = str(body["mapping_id"] or "").strip()
if mid and not db.get(BizPortMapping, mid):
raise HTTPException(status_code=404, detail="mapping_not_found")
p.mapping_id = mid
if "old_baseline_batch_id" in body and body["old_baseline_batch_id"] is not None:
bid = str(body["old_baseline_batch_id"] or "").strip()
if bid and not db.get(BizStateBatch, bid):
raise HTTPException(status_code=404, detail="batch_not_found")
BASELINE_ROWS.invalidate(p.old_baseline_batch_id)
p.old_baseline_batch_id = bid
if "new_baseline_batch_id" in body and body["new_baseline_batch_id"] is not None:
bid = str(body["new_baseline_batch_id"] or "").strip()
if bid and not db.get(BizStateBatch, bid):
raise HTTPException(status_code=404, detail="batch_not_found")
BASELINE_ROWS.invalidate(p.new_baseline_batch_id)
p.new_baseline_batch_id = bid
if "monitor_template_id" in body and body["monitor_template_id"] is not None:
mid = str(body["monitor_template_id"] or "").strip()
if mid and not db.get(BizMonitorTemplate, mid):
raise HTTPException(status_code=404, detail="monitor_template_not_found")
p.monitor_template_id = mid
if "old_task_id" in body and body["old_task_id"] is not None:
tid = str(body["old_task_id"] or "").strip()
if tid and not db.get(BizStateTask, tid):
raise HTTPException(status_code=404, detail="task_not_found")
p.old_task_id = tid
if "new_task_id" in body and body["new_task_id"] is not None:
tid = str(body["new_task_id"] or "").strip()
if tid and not db.get(BizStateTask, tid):
raise HTTPException(status_code=404, detail="task_not_found")
p.new_task_id = tid
if "old_hf_task_id" in body and body["old_hf_task_id"] is not None:
tid = str(body["old_hf_task_id"] or "").strip()
if tid and not db.get(BizStateTask, tid):
raise HTTPException(status_code=404, detail="task_not_found")
p.old_hf_task_id = tid
if "new_hf_task_id" in body and body["new_hf_task_id"] is not None:
tid = str(body["new_hf_task_id"] or "").strip()
if tid and not db.get(BizStateTask, tid):
raise HTTPException(status_code=404, detail="task_not_found")
p.new_hf_task_id = tid
if "collect_metric_ids" in body and body["collect_metric_ids"] is not None:
raw = body["collect_metric_ids"]
if not isinstance(raw, list):
raise HTTPException(status_code=400, detail="collect_metric_ids_must_be_list")
p.collect_metric_ids_json = [str(x).strip() for x in raw if str(x).strip()]
if "metric_interval_sec" in body and body["metric_interval_sec"] is not None:
p.metric_interval_sec_json = _normalize_metric_intervals(body["metric_interval_sec"])
if "hf_interval_sec" in body and body["hf_interval_sec"] is not None:
p.hf_interval_sec = max(60, int(body["hf_interval_sec"] or 60))
if "hf_start_at" in body:
p.hf_start_at = _parse_dt(body.get("hf_start_at"))
if "hf_end_at" in body:
p.hf_end_at = _parse_dt(body.get("hf_end_at"))
sampling_changed = any(k in body for k in ("collect_metric_ids", "metric_interval_sec", "hf_interval_sec", "monitor_template_id"))
has_hf = bool(_all_hf_task_ids(p, "old") and _all_hf_task_ids(p, "new"))
if sampling_changed and has_hf:
# Validate newly selected commands before saving the project selection.
for side in ("old", "new"):
portrait = db.get(BizStateTask, portrait_task_id(p, side))
existing = db.get(BizStateTask, hf_task_id(p, side))
fields = _template_from_task_or_ne(db, portrait=portrait, existing_hf=existing, ne=None)
for mid in resolve_collect_metric_ids(db, p):
task = db.get(BizStateTask, _hf_task_id_for_metric(p, side, mid))
if (task and mid in _enabled_metric_ids(db, task.id)) or _portrait_metric_items(db, portrait, mid):
continue
_catalog_item_for_metric(vendor=fields["vendor"], device_type=fields["device_type"], metric_id=mid)
p.updated_at = utcnow_naive()
db.commit()
db.refresh(p)
# Status / schedule changes should stop or resume HF without waiting for scheduler tick
if any(k in body for k in ("status", "hf_start_at", "hf_end_at")):
_apply_hf_window_to_tasks(db, p)
db.refresh(p)
if sampling_changed and has_hf:
ensure_highfreq(db, p.id, collect_now=False)
db.refresh(p)
return project_to_dict(db, p)
def delete_project(db: Session, project_id: str) -> dict[str, Any]:
from ..biz_state import service as biz_svc
p = db.get(BizMigrationProject, project_id)
if not p:
raise HTTPException(status_code=404, detail="project_not_found")
# Stop cutover HF collects before dropping the project
for tid in _all_hf_task_ids(p, "old") + _all_hf_task_ids(p, "new"):
t = db.get(BizStateTask, tid)
if t and t.status == "running":
try:
biz_svc.update_task(db, tid, {"status": "paused"})
except Exception: # noqa: BLE001
pass
db.query(BizMigrationRedTicket).filter(BizMigrationRedTicket.project_id == project_id).delete()
batches = db.query(BizMigrationBatch).filter(BizMigrationBatch.project_id == project_id).all()
for b in batches:
runs = db.query(BizMigrationRun).filter(BizMigrationRun.batch_id == b.id).all()
for r in runs:
db.query(BizMigrationDiff).filter(BizMigrationDiff.run_id == r.id).delete()
db.delete(r)
db.delete(b)
db.delete(p)
db.commit()
BASELINE_ROWS.invalidate(p.old_baseline_batch_id)
BASELINE_ROWS.invalidate(p.new_baseline_batch_id)
return {"ok": True}
def list_batches(db: Session, project_id: str) -> list[dict[str, Any]]:
if not db.get(BizMigrationProject, project_id):
raise HTTPException(status_code=404, detail="project_not_found")
rows = (
db.query(BizMigrationBatch)
.filter(BizMigrationBatch.project_id == project_id)
.order_by(BizMigrationBatch.created_at.desc())
.all()
)
return [batch_to_dict(b) for b in rows]
def create_batch(db: Session, project_id: str, body: dict[str, Any]) -> dict[str, Any]:
if not db.get(BizMigrationProject, project_id):
raise HTTPException(status_code=404, detail="project_not_found")
label = str(body.get("batch_label") or "").strip() or "batch"
expect = body.get("expect_set") if isinstance(body.get("expect_set"), dict) else {}
status = str(body.get("status") or "pending").strip()
_validate_batch_status(db, project_id, "", status)
b = BizMigrationBatch(
id=uuid4().hex,
project_id=project_id,
batch_label=label,
status=status,
expect_set_json=dict(expect or {}),
note=str(body.get("note") or "")[:500],
)
db.add(b)
db.commit()
db.refresh(b)
out = batch_to_dict(b)
out["open_red_count"] = count_open_red_tickets(db, project_id)
return out
def _latest_success_batch(db: Session, task_id: str) -> BizStateBatch | None:
"""Latest fully successful batch only — never use ``partial`` as current.
Partial batches omit failed-command metrics and cause mass false ``removed``.
"""
return (
db.query(BizStateBatch)
.filter(
BizStateBatch.task_id == task_id,
BizStateBatch.status == "success",
)
.order_by(BizStateBatch.started_at.desc())
.first()
)
def pinned_baseline_batch_ids(db: Session) -> set[str]:
"""Batch IDs that must survive retention purge."""
ids: set[str] = set()
for p in db.query(BizMigrationProject).all():
if p.old_baseline_batch_id:
ids.add(p.old_baseline_batch_id)
if p.new_baseline_batch_id:
ids.add(p.new_baseline_batch_id)
return ids
def run_evaluate(
db: Session,
*,
batch_id: str,
old_batch_id: str = "",
new_batch_id: str = "",
acceptance: bool = False,
purpose: str = "manual",
_commit: bool = True,
_metric_ids: set[str] | None = None,
_run_id: str = "",
) -> dict[str, Any]:
"""Evaluate a wave.
Synchronous callers (auto monitor, tests) hold the project row lock for the whole
computation. With ``_run_id`` (background evaluate/finish) the caller already
claimed the project through a ``running`` run row, so the lock is only taken for the
short write at the end.
"""
mb = db.get(BizMigrationBatch, batch_id)
if not mb:
raise HTTPException(status_code=404, detail="batch_not_found")
proj_query = db.query(BizMigrationProject).filter(BizMigrationProject.id == mb.project_id)
if not _run_id:
# PostgreSQL serializes evaluate/finish for this project across API workers.
proj_query = proj_query.with_for_update().populate_existing()
proj = proj_query.first()
if not proj:
raise HTTPException(status_code=404, detail="project_not_found")
db.refresh(mb)
if acceptance and mb.status not in ("active", "review"):
raise HTTPException(status_code=409, detail="monitor_window_closed")
if purpose == "auto" and (mb.status != "active" or proj.status == "done"):
raise HTTPException(status_code=409, detail="monitor_window_closed")
if not proj.old_baseline_batch_id:
raise HTTPException(status_code=400, detail="old_baseline_required")
if not proj.new_baseline_batch_id:
raise HTTPException(status_code=400, detail="new_baseline_required")
for side in ("old", "new"):
baseline = db.get(BizStateBatch, getattr(proj, f"{side}_baseline_batch_id"))
if not baseline or baseline.task_id != portrait_task_id(proj, side):
raise HTTPException(status_code=400, detail=f"{side}_baseline_task_mismatch")
if baseline.status not in ("success", "partial"):
raise HTTPException(status_code=400, detail=f"{side}_baseline_not_complete")
pin = (old_batch_id if side == "old" else new_batch_id).strip()
if pin:
pinned = db.get(BizStateBatch, pin)
if not pinned or pinned.task_id not in _all_hf_task_ids(proj, side):
raise HTTPException(status_code=400, detail=f"{side}_current_batch_task_mismatch")
old_batch = db.get(BizStateBatch, old_batch_id.strip()) if old_batch_id.strip() else None
new_batch = db.get(BizStateBatch, new_batch_id.strip()) if new_batch_id.strip() else None
# Primary current batch ids (for run record / pin UX); per-metric may differ when multi-HF
if not old_batch:
for tid in _all_hf_task_ids(proj, "old") or [current_task_id(proj, "old")]:
old_batch = db.query(BizStateBatch).filter(BizStateBatch.task_id == tid).order_by(
BizStateBatch.started_at.desc(), BizStateBatch.id.desc()).first()
if old_batch:
break
if not new_batch:
for tid in _all_hf_task_ids(proj, "new") or [current_task_id(proj, "new")]:
new_batch = db.query(BizStateBatch).filter(BizStateBatch.task_id == tid).order_by(
BizStateBatch.started_at.desc(), BizStateBatch.id.desc()).first()
if new_batch:
break
if not old_batch:
raise HTTPException(status_code=400, detail="old_current_batch_required")
if not new_batch:
raise HTTPException(status_code=400, detail="new_current_batch_required")
old_cur = old_batch.id
new_cur = new_batch.id
pin_old = old_batch_id.strip()
pin_new = new_batch_id.strip()
port_map = _port_map_dict(db, proj.mapping_id)
if proj.mapping_id and not db.get(BizPortMapping, proj.mapping_id):
raise HTTPException(status_code=400, detail="mapping_missing")
_reject_ambiguous_mapping(port_map)
expect = parse_expect_set(mb.expect_set_json if isinstance(mb.expect_set_json, dict) else {})
previous_expect: dict[str, set[str]] = {}
for prior in db.query(BizMigrationBatch).filter(
BizMigrationBatch.project_id == proj.id, BizMigrationBatch.id != mb.id,
BizMigrationBatch.status.in_(("review", "done")),
BizMigrationBatch.created_at <= mb.created_at,
).all():
for mid, keys in parse_expect_set(prior.expect_set_json).items():
previous_expect.setdefault(mid, set()).update(keys)
# Final acceptance: window closed → unfinished expect = red
window_active = (mb.status == "active") and (not acceptance)
# Evaluations never seed or commit default configuration inside the lock.
mt = db.get(BizMonitorTemplate, proj.monitor_template_id) if proj.monitor_template_id else None
if not mt:
raise HTTPException(status_code=400, detail="monitor_template_missing")
if mt.compare_template_id and not db.get(BizCompareTemplate, mt.compare_template_id):
raise HTTPException(status_code=400, detail="compare_template_missing")
sheets, sheet_overrides, defaults, iface_norm = resolve_evaluate_sheets(db, mt)
from .validation import validate_monitor_rules
validate_monitor_rules(defaults, sheet_overrides)
from ..biz_state.compare_validation import validate_template_body
validate_template_body({"metrics": sheets, "iface_normalize_rules": iface_norm})
from ..biz_state.compare_service import _batch_vendor_key
from ..biz_state.identity_pipeline import build_pipeline
from ..biz_state.iface_normalize import normalize_port_map
raw_port_map = dict(port_map)
old_vendor = _batch_vendor_key(db, proj.old_baseline_batch_id)
new_vendor = _batch_vendor_key(db, proj.new_baseline_batch_id)
normalized_map, map_conflicts = normalize_port_map(port_map, iface_norm)
_reject_ambiguous_mapping(normalized_map, conflicts=map_conflicts, raw=port_map)
port_map = normalized_map
known_sheets = {sheet_key(s) for s in sheets}
known_metrics = {str(s.get("metric_id") or "") for s in sheets}
for override in sheet_overrides:
if (override.get("sheet_id") and override["sheet_id"] not in known_sheets) or (
not override.get("sheet_id") and override.get("metric_id") not in known_metrics
):
raise HTTPException(status_code=400, detail="monitor_override_sheet_missing")
out_of_expect = str((defaults or {}).get("out_of_expect") or "strict").strip().lower()
collect_ids = set(resolve_collect_metric_ids(db, proj))
sheet_cards: list[dict[str, Any]] = []
all_rows: list[dict[str, Any]] = []
metric_batches: dict[str, dict[str, str]] = {}
seq = 0
rows_cache: dict[tuple[str, str], list[dict[str, Any]]] = {}
command_cache: dict[str, dict[str, Any]] = {}
enabled_sheets = [s for s in sheets if str(s.get("metric_id") or "") in collect_ids and not
override_for_sheet(sheet_overrides, sheet_id=sheet_key(s), metric_id=s.get("metric_id", "")).get("skip_dual")]
scope_ids = {str(s.get("metric_id") or "") for s in enabled_sheets} | {sheet_key(s) for s in enabled_sheets}
if any(s.get("iface_fields") for s in enabled_sheets):
scope_ids.add("_ports")
uncovered_scope = sorted({k for scope in (expect, previous_expect) for k, values in scope.items()
if values and k not in scope_ids})
config_snapshot = {"identity_version": 2, "vendors": {"old": old_vendor, "new": new_vendor},
"sheets": sheets, "sheet_overrides": sheet_overrides,
"defaults": defaults, "iface_normalize": iface_norm, "port_map": port_map,
"expect_set": mb.expect_set_json, "previous_expect": {k: sorted(v) for k, v in previous_expect.items()},
"baseline_ids": [proj.old_baseline_batch_id, proj.new_baseline_batch_id],
"collect_metric_ids": sorted(collect_ids), "window_active": window_active,
"bindings": [_hf_bindings(proj, "old"), _hf_bindings(proj, "new")]}
prior_run = db.query(BizMigrationRun).filter(
BizMigrationRun.batch_id == batch_id, BizMigrationRun.status == "success").order_by(
BizMigrationRun.created_at.desc()).first() if _metric_ids is not None else None
reusable = (prior_run.summary_json or {}).get("config_snapshot") == config_snapshot if prior_run else False
prior_cards = {sheet_key(c): c for c in (prior_run.summary_json or {}).get("sheet_cards", [])} if reusable else {}
pair_cache: dict[str, tuple[BizStateBatch | None, BizStateBatch | None, dict[str, Any]]] = {}
baseline_ids = {proj.old_baseline_batch_id, proj.new_baseline_batch_id}
def metric_rows(bid: str, mid: str) -> list[dict[str, Any]]:
key = (bid, mid)
if key not in rows_cache:
if bid in baseline_ids:
# Baselines never change: share them across the minute-by-minute evaluations.
rows_cache[key] = BASELINE_ROWS.get_or_load(
bid, mid, lambda: _load_metric_rows(db, batch_id=bid, metric_id=mid))
else:
rows_cache[key] = _load_metric_rows(db, batch_id=bid, metric_id=mid)
return rows_cache[key]
for sheet_index, sheet in enumerate(sheets, start=1):
mid = str(sheet.get("metric_id") or "").strip()
sid = sheet_key(sheet)
key_fields = list(sheet.get("key_fields") or [])
if not mid or not key_fields:
continue
if _run_id:
_report_eval_progress(_run_id, sheet_index, len(sheets), sheet_title(sheet))
iface_fields = list(sheet.get("iface_fields") or [])
compare_fields = list(sheet.get("compare_fields") or [])
field_rules = list(sheet.get("field_rules") or [])
sheet_ov = override_for_sheet(sheet_overrides, sheet_id=sid, metric_id=mid)
if sheet_ov.get("skip_dual"):
sheet_cards.append({"metric_id": mid, "sheet_id": sid, "title": sheet_title(sheet),
"progress_ok": 0, "progress_total": 0, "anomaly": 0,
"collect_skipped": True})
continue
# Metric not in HF collect set → skip dual eval (avoid false red)
if collect_ids and mid not in collect_ids:
sheet_cards.append(
{
"metric_id": mid,
"sheet_id": sid,
"title": sheet_title(sheet),
"progress_ok": 0,
"progress_total": 0,
"anomaly": 0,
"anomaly_in_expect": 0,
"old_summary": {},
"new_summary": {},
"new_baseline_mode": "skipped",
"new_baseline_missing": False,
"collect_skipped": True,
}
)
continue
if _metric_ids is not None and mid not in _metric_ids and sid in prior_cards:
cached = {**prior_cards[sid], "source_run_id": prior_cards[sid].get("source_run_id") or prior_run.id}
times = [_parse_dt(cached.get(k)) for k in ("old_collected_at", "new_collected_at")]
if any(at and (utcnow_naive() - at).total_seconds() > cached.get("age_limit_sec", 180) for at in times):
cached.update({"current_missing": True, "sample_issues": ["sample_stale"]})
sheet_cards.append(cached)
metric_batches[mid] = (prior_run.summary_json or {}).get("metric_batches", {}).get(mid, {})
continue
if mid not in pair_cache:
pair_cache[mid] = _current_pair_for_metric(db, proj, mid, pin_old, pin_new)
old_cur_batch, new_cur_batch, pair_meta = pair_cache[mid]
# Do NOT fall back to another HF task's batch — empty/wrong metric → false red
if (not old_cur_batch or not new_cur_batch) and mid in MANUAL_ROUTE_METRICS:
# Route tables are collected by hand; until someone does, they are not part of the gate.
sheet_cards.append({
"metric_id": mid, "sheet_id": sid, "title": sheet_title(sheet),
"progress_ok": 0, "progress_total": 0, "anomaly": 0, "anomaly_in_expect": 0,
"old_summary": {}, "new_summary": {}, "new_baseline_mode": "manual_pending",
"new_baseline_missing": False, "manual_pending": True, "collect_skipped": False,
"current_missing": False, **pair_meta,
})
continue
if not old_cur_batch or not new_cur_batch:
sheet_cards.append(
{
"metric_id": mid,
"sheet_id": sid,
"title": sheet_title(sheet),
"progress_ok": 0,
"progress_total": 0,
"anomaly": 0,
"anomaly_in_expect": 0,
"old_summary": {},
"new_summary": {},
"new_baseline_mode": "missing_current",
"new_baseline_missing": False,
"current_missing": True,
"collect_incomplete": True,
"collect_skipped": False,
**pair_meta,
}
)
continue
old_cur_mid = old_cur_batch.id
new_cur_mid = new_cur_batch.id
# A slice (address family) one device never collected is a collection gap, not a lost business.
uncovered = _uncovered_slice_sides(db, sheet, mid, old_cur_mid, new_cur_mid)
if uncovered:
sheet_cards.append({
"metric_id": mid, "sheet_id": sid, "title": sheet_title(sheet),
"progress_ok": 0, "progress_total": 0, "anomaly": 0, "anomaly_in_expect": 0,
"old_summary": {}, "new_summary": {}, "new_baseline_mode": "not_collected",
"new_baseline_missing": False, "not_collected": True, "data_state": "not_collected",
"data_state_reasons": [f"{side}:slice_not_collected" for side in uncovered],
"collect_skipped": False, "current_missing": False,
"old_batch_id": old_cur_mid, "new_batch_id": new_cur_mid, **pair_meta,
})
continue
timing = {**pair_meta, **_sample_timing(proj, mid, old_cur_batch, new_cur_batch,
check_age=acceptance or not (pin_old and pin_new))}
baseline_missing = [side for side in ("old", "new") if not _metric_sample_ok(
db, db.get(BizStateBatch, getattr(proj, f"{side}_baseline_batch_id")), mid)]
if timing["sample_issues"] or baseline_missing:
sheet_cards.append({"metric_id": mid, "sheet_id": sid, "title": sheet_title(sheet),
"progress_ok": 0, "progress_total": 0, "anomaly": 0,
"current_missing": True, "collect_incomplete": True,
"baseline_missing_sides": baseline_missing, **timing})
continue
# Old device = "before", new device = "after"; filters see normalized rows.
pipe = build_pipeline(
sheet, iface_rules=iface_norm, port_map=raw_port_map,
before_vendor=old_vendor, after_vendor=new_vendor,
)
old_base = [raw for raw, _ in pipe.prepare(metric_rows(proj.old_baseline_batch_id, mid), "before")]
old_now = [raw for raw, _ in pipe.prepare(metric_rows(old_cur_mid, mid), "before")]
new_base_rows = [raw for raw, _ in pipe.prepare(metric_rows(proj.new_baseline_batch_id, mid), "after")]
new_now = [raw for raw, _ in pipe.prepare(metric_rows(new_cur_mid, mid), "after")]
one = evaluate_metric_dual(
metric_id=mid,
sheet_id=sid,
key_fields=key_fields,
iface_fields=iface_fields,
compare_fields=compare_fields,
old_baseline_rows=old_base,
old_current_rows=old_now,
new_baseline_rows=new_base_rows,
new_current_rows=new_now,
port_map=pipe.port_map if iface_fields else {},
expect=expect,
window_active=window_active,
acceptance=acceptance,
field_rules=field_rules,
sheet_override=sheet_ov,
out_of_expect=out_of_expect,
iface_normalize_rules=iface_norm,
previous_expect=previous_expect,
allow_mapping_scope=False,
pipeline=pipe,
)
# A target snapshot taken before the source's disappearance cannot prove
# loss. Wait for the next target receipt; final acceptance stays blocked.
awaiting = 0
if (new_cur_batch.ended_at or new_cur_batch.started_at) < old_cur_batch.started_at:
for row in one["rows"]:
if row["in_expect"] and row["rule_hit"] == "service_not_received":
row.update(verdict="awaiting_peer", color="gray", rule_hit="target_sample_predates_source")
one["anomaly"] -= 1
one["anomaly_in_expect"] -= 1
awaiting += 1
old_tid = _hf_task_id_for_metric(proj, "old", mid) or current_task_id(proj, "old")
new_tid = _hf_task_id_for_metric(proj, "new", mid) or current_task_id(proj, "new")
old_task_brief = _task_brief(db, old_tid)
new_task_brief = _task_brief(db, new_tid)
metric_batches[mid] = {
"old_batch_id": old_cur_mid,
"new_batch_id": new_cur_mid,
"old_task_id": old_tid,
"new_task_id": new_tid,
"old_status": old_cur_batch.status,
"new_status": new_cur_batch.status,
**timing,
}
sheet_cards.append(
{
"metric_id": mid,
"sheet_id": sid,
"title": sheet_title(sheet),
"progress_ok": one["progress_ok"],
"progress_total": one["progress_total"],
"anomaly": one["anomaly"],
"anomaly_in_expect": int(one.get("anomaly_in_expect") or 0),
"steady_outside": int(one.get("steady_outside") or 0),
"old_summary": one["old_summary"],
"new_summary": one["new_summary"],
"new_baseline_mode": one.get("new_baseline_mode") or "provided",
"new_baseline_missing": bool(one.get("new_baseline_missing")),
"collect_skipped": False,
"old_batch_id": old_cur_mid,
"new_batch_id": new_cur_mid,
"duplicate_key_list": list(one.get("duplicate_key_list") or []),
"duplicate_keys_before": int(one.get("duplicate_keys_before") or 0),
"duplicate_keys_after": int(one.get("duplicate_keys_after") or 0),
"duplicate_in_expect": list(one.get("duplicate_in_expect") or []),
**timing,
"awaiting_peer": awaiting,
"verdict_counts": dict(Counter(r["verdict"] for r in one["rows"] if r.get("verdict"))),
"expected_count": sum(bool(r.get("in_expect")) for r in one["rows"]),
}
)
for r in one["rows"]:
_enrich_row_evidence(
db,
r,
old_batch_id=old_cur_mid,
new_batch_id=new_cur_mid,
old_task=old_task_brief,
new_task=new_task_brief,
command_cache=command_cache,
)
r["seq"] = seq
r["sheet_id"] = sid
seq += 1
all_rows.append(r)
verdict_counts = {}
for card in sheet_cards:
if not card.get("current_missing"):
for verdict, count in card.get("verdict_counts", {}).items():
verdict_counts[verdict] = verdict_counts.get(verdict, 0) + count
active_cards = [
c for c in sheet_cards
if not c.get("collect_skipped") and not c.get("current_missing") and not c.get("manual_pending")
and not c.get("not_collected")
]
run = BizMigrationRun(
id=_run_id or uuid4().hex,
project_id=proj.id,
batch_id=mb.id,
old_batch_id=old_cur,
new_batch_id=new_cur,
purpose=str(purpose or ("acceptance" if acceptance else "manual"))[:32],
status="success",
summary_json={
"metric_focus": sheets[0].get("metric_id") if sheets else PORT_METRIC_ID,
"monitor_template_id": mt.id,
"compare_template_id": mt.compare_template_id or "",
"acceptance": acceptance,
"metric_batches": metric_batches,
"sheet_cards": sheet_cards,
"progress": {
"ok": sum(c["progress_ok"] for c in active_cards),
"total": sum(c["progress_total"] for c in active_cards),
},
"anomaly": sum(c["anomaly"] for c in active_cards),
"new_baseline_missing": any(bool(c.get("new_baseline_missing")) for c in active_cards),
"missing_metrics": [
str(c.get("title") or c.get("sheet_id") or c.get("metric_id") or "")
for c in active_cards
if c.get("new_baseline_missing")
],
"current_missing_metrics": [
str(c.get("title") or c.get("sheet_id") or c.get("metric_id") or "")
for c in sheet_cards
if c.get("current_missing")
],
"collect_skipped_metrics": [
str(c.get("title") or c.get("sheet_id") or c.get("metric_id") or "")
for c in sheet_cards
if c.get("collect_skipped")
],
"verdict_counts": verdict_counts,
"anomaly_outside_expect": sum(int(c.get("anomaly") or 0) - int(c.get("anomaly_in_expect") or 0) for c in active_cards),
"steady_outside": sum(int(c.get("steady_outside") or 0) for c in active_cards),
# Only duplicate keys inside the wave's scope fail an acceptance; the rest are warnings.
"duplicate_keys": sum(len(c.get("duplicate_in_expect") or []) for c in active_cards),
"duplicate_warnings": [
{"sheet_id": c.get("sheet_id"), "title": c.get("title"),
"count": int(c.get("duplicate_keys_before") or 0) + int(c.get("duplicate_keys_after") or 0),
"examples": list(c.get("duplicate_key_list") or [])[:5]}
for c in active_cards
if (c.get("duplicate_keys_before") or c.get("duplicate_keys_after"))
and not c.get("duplicate_in_expect")
],
"manual_pending_metrics": [
str(c.get("title") or c.get("sheet_id") or c.get("metric_id") or "")
for c in sheet_cards if c.get("manual_pending")
],
"not_collected_metrics": [
str(c.get("title") or c.get("sheet_id") or c.get("metric_id") or "")
for c in sheet_cards if c.get("not_collected")
],
"uncovered_scope": uncovered_scope,
"coverage_complete": bool(enabled_sheets) and not uncovered_scope and not any(
c.get("current_missing") or c.get("awaiting_peer")
or c.get("duplicate_in_expect")
for c in sheet_cards
if not c.get("collect_skipped") and not c.get("manual_pending") and not c.get("not_collected")
),
"config_snapshot": config_snapshot,
"window_active": window_active,
"expect_ports": sorted(expect.get("_ports") or ()),
"old_current_task_id": current_task_id(proj, "old"),
"new_current_task_id": current_task_id(proj, "new"),
},
message="",
)
if _run_id:
# Claim the project only for the write; the computation above ran unlocked.
db.query(BizMigrationProject).filter(BizMigrationProject.id == proj.id).with_for_update().first()
existing = db.get(BizMigrationRun, _run_id)
if existing is None or existing.status != "running":
raise HTTPException(status_code=409, detail="evaluation_cancelled")
run = db.merge(run)
else:
db.add(run)
db.flush()
for card in sheet_cards:
card.setdefault("source_run_id", run.id)
run.summary_json = {**run.summary_json, "sheet_cards": sheet_cards}
pending_diffs: list[dict[str, Any]] = []
for r in all_rows:
key_list = r.get("key") or []
ev = r.get("evidence") if isinstance(r.get("evidence"), dict) else {}
pm = ev.get("port_map") if isinstance(ev.get("port_map"), dict) else {}
search = " ".join(
[
str(r.get("old_key") or r.get("key_str") or ""),
str(r.get("new_key") or r.get("new_key_str") or ""),
str(r.get("match_old_key") or ""),
str(r.get("match_new_key") or ""),
str(pm.get("match_before") or ""),
str(pm.get("match_after") or ""),
str(r.get("verdict") or ""),
str(r.get("old_status") or ""),
str(r.get("new_status") or ""),
str(r.get("metric_id") or ""),
]
)
pending_diffs.append(
dict(
id=uuid4().hex,
run_id=run.id,
metric_id=str(r.get("metric_id") or ""),
seq=int(r.get("seq") or 0),
verdict=str(r.get("verdict") or ""),
color=str(r.get("color") or ""),
key_json={
"key": key_list,
"old_key": r.get("old_key") or r.get("key_str"),
"new_key": r.get("new_key") or r.get("new_key_str"),
"key_str": r.get("old_key") or r.get("key_str"),
"new_key_str": r.get("new_key") or r.get("new_key_str"),
"match_old_key": r.get("match_old_key") or "",
"match_new_key": r.get("match_new_key") or "",
"old_status": r.get("old_status"),
"new_status": r.get("new_status"),
"rule_hit": r.get("rule_hit") or "",
"integrity_changes": r.get("integrity_changes") or [],
"previous_batch": bool(r.get("previous_batch")),
"sheet_id": r.get("sheet_id") or "",
"evidence": r.get("evidence") or {},
},
old_kind=str(r.get("old_kind") or ""),
new_kind=str(r.get("new_kind") or ""),
old_json=dict(r.get("old") or {}),
new_json=dict(r.get("new") or {}),
in_expect=bool(r.get("in_expect")),
search_text=search[:2000],
)
)
if len(pending_diffs) >= 500:
db.execute(insert(BizMigrationDiff), pending_diffs)
pending_diffs.clear()
if pending_diffs:
db.execute(insert(BizMigrationDiff), pending_diffs)
db.flush()
if _commit:
db.commit()
db.refresh(run)
return run_to_dict(db, run)
def run_to_dict(db: Session, run: BizMigrationRun, *, include_diffs: bool = False) -> dict[str, Any]:
out: dict[str, Any] = {
"id": run.id,
"project_id": run.project_id,
"batch_id": run.batch_id,
"old_batch_id": run.old_batch_id,
"new_batch_id": run.new_batch_id,
"purpose": getattr(run, "purpose", None) or "manual",
"status": run.status,
"summary": dict(run.summary_json or {}),
"message": run.message,
"created_at": run.created_at.isoformat() if run.created_at else None,
"old_batch": _batch_brief(db, run.old_batch_id),
"new_batch": _batch_brief(db, run.new_batch_id),
}
if include_diffs:
diffs = (
db.query(BizMigrationDiff)
.filter(BizMigrationDiff.run_id == run.id)
.order_by(BizMigrationDiff.seq.asc())
.limit(5000)
.all()
)
out["diffs"] = [diff_to_dict(d) for d in diffs]
return out
def _command_brief(db: Session, command_id: str) -> dict[str, Any]:
from ..models import BizStateBatchCommand
cid = str(command_id or "").strip()
if not cid:
return {}
# Select metadata only: raw CLI output can be several MB per command.
c = db.query(BizStateBatchCommand.id, BizStateBatchCommand.raw_command,
BizStateBatchCommand.parse_status, BizStateBatchCommand.row_count,
BizStateBatchCommand.profile_id, BizStateBatchCommand.parser_id,
BizStateBatchCommand.metric_id, BizStateBatchCommand.message).filter(
BizStateBatchCommand.id == cid).first()
if not c:
return {"command_id": cid}
return {
"command_id": c.id,
"raw_command": c.raw_command or "",
"parse_status": c.parse_status or "",
"row_count": int(c.row_count or 0),
"profile_id": c.profile_id or "",
"parser_id": c.parser_id or "",
"metric_id": c.metric_id or "",
"message": (c.message or "")[:300],
}
def _enrich_row_evidence(
db: Session,
row: dict[str, Any],
*,
old_batch_id: str,
new_batch_id: str,
old_task: dict[str, Any] | None,
new_task: dict[str, Any] | None,
command_cache: dict[str, dict[str, Any]] | None = None,
) -> None:
"""Attach device / collect / show-command onto evaluate evidence (in-place)."""
ev = dict(row.get("evidence") or {})
for side, brief, batch_id in (
("old", old_task or {}, old_batch_id),
("new", new_task or {}, new_batch_id),
):
side_ev = dict(ev.get(side) or {})
netx = dict(side_ev.get("netx") or {})
cid = str(netx.get("batch_command_id") or "")
cache = command_cache if command_cache is not None else {}
if cid not in cache:
cache[cid] = _command_brief(db, cid)
cmd = cache[cid]
side_ev["device"] = {
"ne_id": str(brief.get("ne_id") or netx.get("ne_id") or ""),
"ne_name": str(brief.get("ne_name") or ""),
"ne_ip": str(brief.get("ne_ip") or ""),
"side": side,
}
# Prefer the metric row's own batch (baseline vs current); fall back to
# the side's evaluate batch id used for this sheet.
side_ev["collect"] = {
"task_id": str(netx.get("task_id") or brief.get("id") or ""),
"batch_id": str(netx.get("batch_id") or batch_id or ""),
"side_batch_id": str(batch_id or ""),
"collected_at": netx.get("collected_at"),
"parse_status": str(cmd.get("parse_status") or ""),
}
side_ev["command"] = cmd
ev[side] = side_ev
row["evidence"] = ev
def diff_to_dict(d: BizMigrationDiff) -> dict[str, Any]:
kj = d.key_json if isinstance(d.key_json, dict) else {}
old_key = str(kj.get("old_key") or kj.get("key_str") or "")
new_key = str(kj.get("new_key") or kj.get("new_key_str") or "")
return {
"id": d.id,
"metric_id": d.metric_id,
"sheet_id": kj.get("sheet_id") or d.metric_id or "",
"seq": d.seq,
"verdict": d.verdict,
"color": d.color,
"key": kj,
"old_key": old_key,
"new_key": new_key,
"key_str": old_key,
"new_key_str": new_key,
"match_old_key": str(kj.get("match_old_key") or ""),
"match_new_key": str(kj.get("match_new_key") or ""),
"old_status": kj.get("old_status") or "",
"new_status": kj.get("new_status") or "",
"rule_hit": kj.get("rule_hit") or "",
"integrity_changes": kj.get("integrity_changes") or [],
"previous_batch": bool(kj.get("previous_batch")),
"evidence": dict(kj.get("evidence") or {}),
"old_kind": d.old_kind,
"new_kind": d.new_kind,
"old": d.old_json,
"new": d.new_json,
"in_expect": d.in_expect,
}
def list_baseline_ports(db: Session, project_id: str) -> dict[str, Any]:
"""List interface names from project old baseline for expect-set picking."""
p = db.get(BizMigrationProject, project_id)
if not p:
raise HTTPException(status_code=404, detail="project_not_found")
if not p.old_baseline_batch_id:
return {"batch_id": "", "ports": [], "mapped": {}}
rows = _load_metric_rows(
db, batch_id=p.old_baseline_batch_id, metric_id=PORT_METRIC_ID
)
port_map = _port_map_dict(db, p.mapping_id)
ports: list[dict[str, Any]] = []
for r in rows:
name = str(r.get("interface") or "").strip()
if not name:
continue
ports.append(
{
"interface": name,
"admin": r.get("admin") or "",
"phy": r.get("phy") or "",
"prot": r.get("prot") or "",
"description": r.get("description") or "",
"mapped_to": port_map.get(name) or "",
}
)
ports.sort(key=lambda x: str(x["interface"]))
return {
"batch_id": p.old_baseline_batch_id,
"metric_id": PORT_METRIC_ID,
"ports": ports,
"mapped": port_map,
}
def list_baseline_expect_objects(db: Session, project_id: str) -> dict[str, Any]:
"""Per-sheet baseline keys for multi-metric expect picking."""
p = db.get(BizMigrationProject, project_id)
if not p:
raise HTTPException(status_code=404, detail="project_not_found")
if not p.old_baseline_batch_id:
return {"batch_id": "", "sheets": [], "mapped": {}}
mt = resolve_project_monitor_template(db, p)
sheets, sheet_overrides, _defaults, _norm = resolve_evaluate_sheets(db, mt)
collect_ids = set(resolve_collect_metric_ids(db, p))
raw_port_map = _port_map_dict(db, p.mapping_id)
out_sheets: list[dict[str, Any]] = []
from ..biz_state.compare_service import _batch_vendor_key
from ..biz_state.identity_pipeline import build_pipeline
from ..biz_state.iface_normalize import normalize_port_map, resolve_mapped_iface
from .evaluate import KEY_SEP
rows_cache: dict[str, list[dict[str, Any]]] = {}
old_vendor = _batch_vendor_key(db, p.old_baseline_batch_id)
new_vendor = _batch_vendor_key(db, p.new_baseline_batch_id)
port_map, _ = normalize_port_map(raw_port_map, _norm)
for sheet in sheets:
mid = str(sheet.get("metric_id") or "").strip()
sid = sheet_key(sheet)
key_fields = [str(k) for k in (sheet.get("key_fields") or []) if str(k).strip()]
if not mid or not key_fields:
continue
# Only offer expect keys for metrics that are actually HF-collected
if collect_ids and mid not in collect_ids:
continue
if mid in MANUAL_ROUTE_METRICS:
# Never enumerate million-row route tables into batch scope pickers.
continue
sheet_ov = override_for_sheet(sheet_overrides, sheet_id=sid, metric_id=mid)
if sheet_ov.get("skip_dual"):
continue
iface_fields = [str(k) for k in (sheet.get("iface_fields") or []) if str(k).strip()]
if mid not in rows_cache:
rows_cache[mid] = _load_metric_rows(db, batch_id=p.old_baseline_batch_id, metric_id=mid)
pipe = build_pipeline(
sheet, iface_rules=_norm, port_map=raw_port_map,
before_vendor=old_vendor, after_vendor=new_vendor,
)
items: list[dict[str, Any]] = []
seen_keys: set[str] = set()
for r, normalized in pipe.prepare(rows_cache[mid], "before"):
keys = [scalar_text(normalized.get(f)).strip() for f in key_fields]
if not any(keys):
continue
key_str = KEY_SEP.join(keys)
if key_str in seen_keys:
continue
seen_keys.add(key_str)
mapped = ""
if iface_fields and key_fields and key_fields[0] in iface_fields:
mapped = resolve_mapped_iface(keys[0], pipe.port_map) if pipe.port_map else ""
items.append(
{
"key": key_str,
"keys": keys,
"mapped_to": mapped,
"label": "|".join(scalar_text(r.get(f)).strip() for f in key_fields),
"row": {f: r.get(f) for f in list(dict.fromkeys([*key_fields, *iface_fields, "description", "admin", "phy", "prot"])) if f in r},
}
)
items.sort(key=lambda x: str(x["key"]))
out_sheets.append(
{
"metric_id": mid,
"sheet_id": sid,
"title": sheet_title(sheet),
"key_fields": key_fields,
"iface_fields": iface_fields,
"items": items,
}
)
return {
"batch_id": p.old_baseline_batch_id,
"monitor_template_id": mt.id,
"sheets": out_sheets,
"mapped": port_map,
}
def preflight_project(db: Session, project_id: str) -> dict[str, Any]:
"""Mapping coverage + baseline data quality of a cutover project, before the window.
Judged on the two portrait baselines (full collections of both devices), so a
missing/ambiguous mapping row shows up before monitoring starts.
"""
from ..biz_state.compare_preflight import preflight
from ..biz_state.compare_service import _batch_vendor_key
from ..biz_state.iface_normalize import normalize_port_map
p = db.get(BizMigrationProject, project_id)
if not p:
raise HTTPException(status_code=404, detail="project_not_found")
if not p.old_baseline_batch_id or not p.new_baseline_batch_id:
raise HTTPException(status_code=400, detail="baseline_required")
mt = resolve_project_monitor_template(db, p)
sheets, sheet_overrides, _defaults, iface_norm = resolve_evaluate_sheets(db, mt)
collect_ids = set(resolve_collect_metric_ids(db, p))
scoped = [
s for s in sheets
if str(s.get("metric_id") or "") and (not collect_ids or str(s.get("metric_id")) in collect_ids)
and not override_for_sheet(sheet_overrides, sheet_id=sheet_key(s), metric_id=str(s.get("metric_id"))).get("skip_dual")
]
raw_map = _port_map_dict(db, p.mapping_id)
cache: dict[tuple[str, str], list[dict[str, Any]]] = {}
def load(side: str, sheet: dict[str, Any]) -> list[dict[str, Any]]:
bid = p.old_baseline_batch_id if side == "before" else p.new_baseline_batch_id
key = (bid, str(sheet["metric_id"]))
if key not in cache:
cache[key] = _load_metric_rows(db, batch_id=bid, metric_id=key[1])
return cache[key]
report = preflight(
db, sheets=scoped, iface_rules=iface_norm, raw_port_map=raw_map,
before_batch_id=p.old_baseline_batch_id, after_batch_id=p.new_baseline_batch_id,
before_vendor=_batch_vendor_key(db, p.old_baseline_batch_id),
after_vendor=_batch_vendor_key(db, p.new_baseline_batch_id),
load_rows=load,
)
normalized, _ = normalize_port_map(raw_map, iface_norm)
# Monitoring needs 1:1; the report lists the rows that collide.
if report["mapping"]["n_to_1_targets"] or report["mapping"]["conflicts"]:
report["risk_reasons"] = sorted({*report["risk_reasons"], "mapping_target_ambiguous"})
report["verdict"] = "risk"
report["mapping"]["pairs_normalized"] = len(normalized)
return report
def _catalog_item_for_metric(*, vendor: str, device_type: str, metric_id: str) -> dict[str, Any]:
from ..biz_state.profiles import profiles_for_vendor
from ..lldp_shared import resolve_vendor_key
mid = str(metric_id or "").strip()
vkey = resolve_vendor_key(vendor, device_type)
candidates = [p for p in profiles_for_vendor(vkey) if p.metric_id == mid and p.kind == "collect"]
for p in sorted(candidates, key=lambda p: bool(p.placeholders)):
if not any(getattr(ph, "required", True) for ph in (p.placeholders or [])):
return {
"source_profile_id": p.profile_id,
"kind": "catalog",
"enabled": True,
"title": p.title or mid,
}
if candidates:
ph = ",".join(x.name for x in candidates[0].placeholders if getattr(x, "required", True))
raise HTTPException(status_code=400, detail=f"metric_needs_bindings:{mid}:{ph}")
raise HTTPException(
status_code=400,
detail=f"no_profile_for_metric:{mid}:{vkey or vendor or 'unknown'}",
)
def _iface_brief_item(*, vendor: str, device_type: str) -> dict[str, Any]:
return _catalog_item_for_metric(vendor=vendor, device_type=device_type, metric_id=PORT_METRIC_ID)
def _enabled_metric_ids(db: Session, task_id: str) -> set[str]:
from ..biz_state.profiles import get_profile
from ..models import BizStateTaskItem
out: set[str] = set()
items = (
db.query(BizStateTaskItem)
.filter(BizStateTaskItem.task_id == task_id, BizStateTaskItem.enabled.is_(True))
.all()
)
for it in items:
if it.kind == "custom_raw":
out.add("__custom__")
continue
p = get_profile(str(it.source_profile_id or ""))
if p and p.metric_id:
out.add(p.metric_id)
return out
def _is_highfreq_task(db: Session, task: BizStateTask, want_metrics: set[str]) -> bool:
"""True when task is cutover HF with matching metrics (or legacy short-interval match)."""
if str(getattr(task, "purpose", None) or "").strip() == PURPOSE_CUTOVER_HF:
metrics = _enabled_metric_ids(db, task.id)
return metrics == set(want_metrics)
metrics = _enabled_metric_ids(db, task.id)
return metrics == set(want_metrics) and int(task.interval_sec or 0) <= 300
def _is_port_highfreq_task(db: Session, task: BizStateTask) -> bool:
"""Back-compat: interface_brief-only HF."""
return _is_highfreq_task(db, task, {PORT_METRIC_ID})
def _ne_spec_to_template_fields(db: Session, ne: dict[str, Any]) -> dict[str, Any]:
"""Resolve NE dict into fields suitable for create_task."""
from ..biz_state import service as biz_svc
source = str(ne.get("source") or "managed").strip().lower() or "managed"
ne_id = str(ne.get("ne_id") or "").strip()
if not ne_id:
raise HTTPException(status_code=400, detail="ne_id_required")
meta = biz_svc._ne_meta(db, source=source, ne_id=ne_id) # noqa: SLF001
return {
"source": source,
"ne_id": ne_id,
"ne_name": str(ne.get("ne_name") or meta.get("ne_name") or ""),
"ne_ip": str(ne.get("ne_ip") or meta.get("ne_ip") or ""),
"vendor": str(ne.get("vendor") or meta.get("vendor") or ""),
"device_type": str(ne.get("device_type") or meta.get("device_type") or ""),
}
def _template_from_task_or_ne(
db: Session,
*,
portrait: BizStateTask | None,
existing_hf: BizStateTask | None,
ne: dict[str, Any] | None,
) -> dict[str, Any]:
"""Build NE identity for HF create: prefer portrait, else existing HF, else NE spec."""
if portrait and not _task_looks_like_hf(portrait):
return {
"source": portrait.source,
"ne_id": portrait.ne_id,
"ne_name": portrait.ne_name,
"ne_ip": portrait.ne_ip,
"vendor": portrait.vendor,
"device_type": portrait.device_type,
}
if existing_hf:
return {
"source": existing_hf.source,
"ne_id": existing_hf.ne_id,
"ne_name": existing_hf.ne_name,
"ne_ip": existing_hf.ne_ip,
"vendor": existing_hf.vendor,
"device_type": existing_hf.device_type,
}
if portrait:
return {
"source": portrait.source,
"ne_id": portrait.ne_id,
"ne_name": portrait.ne_name,
"ne_ip": portrait.ne_ip,
"vendor": portrait.vendor,
"device_type": portrait.device_type,
}
if ne:
return _ne_spec_to_template_fields(db, ne)
raise HTTPException(status_code=400, detail="old_new_ne_or_task_required")
def _portrait_metric_items(db: Session, portrait: BizStateTask | None, mid: str) -> list[dict[str, Any]]:
"""Preserve CLI variants and bindings already used by the device baseline."""
from ..biz_state.profiles import get_profile
from ..models import BizStateTaskItem, BizStateTaskItemBinding
if not portrait:
return []
out = []
for item in db.query(BizStateTaskItem).filter(BizStateTaskItem.task_id == portrait.id,
BizStateTaskItem.enabled.is_(True)).order_by(BizStateTaskItem.sort_order).all():
profile = get_profile(item.source_profile_id) if item.kind == "catalog" else None
if not profile or profile.metric_id != mid:
continue
bindings = db.query(BizStateTaskItemBinding).filter(BizStateTaskItemBinding.item_id == item.id).all()
out.append({"source_profile_id": item.source_profile_id, "kind": item.kind,
"enabled": True, "title": item.title, "command_override": item.command_override,
"bindings": [{"placeholder": b.placeholder, "value": b.value} for b in bindings]})
return out
def _group_metrics(metric_ids: list[str], by_metric: dict[str, int], default_iv: int) -> list[tuple[int, list[str]]]:
"""Collect groups: one task pair (one SSH login per device) per interval + lane.
Manual route tables stay on their own; heavy dumps never share a task with light
status tables.
"""
from ..biz_state.profiles import metric_lane
groups: list[tuple[int, list[str]]] = []
buckets: dict[tuple[int, str], list[str]] = {}
for mid in metric_ids:
iv = max(60, by_metric.get(mid) or default_iv)
if mid in MANUAL_ROUTE_METRICS:
groups.append((iv, [mid]))
continue
key = (iv, metric_lane(mid))
if key not in buckets:
buckets[key] = []
groups.append((iv, buckets[key]))
buckets[key].append(mid)
return groups
def _group_id(project_id: str, metrics: list[str]) -> str:
return "cutover:" + project_id + ":" + hashlib.sha256("+".join(sorted(metrics)).encode()).hexdigest()[:16]
def _slice_key(item: dict[str, Any]) -> tuple[tuple[str, str], ...]:
from ..biz_state.profiles import get_profile
prof = get_profile(str(item.get("source_profile_id") or ""))
return tuple(sorted((prof.slice or {}).items())) if prof is not None else ()
def _union_slice_items(
metric_id: str, old_items: list[dict[str, Any]], new_items: list[dict[str, Any]],
old_vendor: str, new_vendor: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Both devices must collect the same slices (afi / address family) of a metric.
A slice one portrait collects and the other does not would otherwise look like
business loss. The missing side gets this vendor's profile for the slice, or a
clear ``no_profile_for_slice`` error.
"""
from ..biz_state.profiles import get_profile, profiles_for_vendor
from ..lldp_shared import resolve_vendor_key
if not old_items or not new_items:
return old_items, new_items
def fill(items: list[dict[str, Any]], other: list[dict[str, Any]], vendor: str) -> list[dict[str, Any]]:
have = {_slice_key(i) for i in items}
out = list(items)
vkey = resolve_vendor_key(vendor, "") or vendor
for want in [i for i in other if _slice_key(i) not in have and _slice_key(i)]:
wanted_slice = dict(_slice_key(want))
profile = next((p for p in profiles_for_vendor(vkey, kind="collect")
if p.metric_id == metric_id and dict(p.slice or {}) == wanted_slice), None)
if profile is None:
label = ",".join(f"{k}={v}" for k, v in sorted(wanted_slice.items()))
raise HTTPException(
status_code=400, detail=f"no_profile_for_slice:{metric_id}:{label}:{vkey or vendor or 'unknown'}")
names = {ph.name for ph in profile.placeholders}
source = get_profile(str(want.get("source_profile_id") or ""))
copied = [b for b in want.get("bindings") or [] if b.get("placeholder") in names]
if any(getattr(ph, "required", True) for ph in profile.placeholders) and not copied:
raise HTTPException(
status_code=400, detail=f"metric_needs_bindings:{metric_id}:" + ",".join(sorted(names)))
out.append({"source_profile_id": profile.profile_id, "kind": "catalog", "enabled": True,
"title": profile.title or (source.title if source else metric_id), "bindings": copied})
have.add(_slice_key(out[-1]))
return out
return fill(old_items, new_items, old_vendor), fill(new_items, old_items, new_vendor)
def _group_items(
db: Session, portrait: BizStateTask | None, template_fields: dict[str, Any], metrics: list[str],
portrait_items: dict[str, list[dict[str, Any]]],
) -> list[dict[str, Any]]:
"""Every metric of a merged task keeps its own portrait-configured commands."""
out: list[dict[str, Any]] = []
for mid in metrics:
items = portrait_items.get(mid) or [_catalog_item_for_metric(
vendor=str(template_fields.get("vendor") or ""),
device_type=str(template_fields.get("device_type") or ""), metric_id=mid)]
out.extend(items)
return out
def _ensure_side_highfreq(
db: Session,
*,
existing_hf: BizStateTask | None,
template_fields: dict[str, Any],
project_name: str,
interval_sec: int,
retention_days: int,
metric_ids: list[str],
status: str = "running",
configured_items: list[dict[str, Any]] | None = None,
) -> tuple[BizStateTask, bool]:
"""Return (hf_task, created). Reuse existing HF if metrics match; never mutate portrait."""
from ..biz_state import service as biz_svc
want = {str(m).strip() for m in metric_ids if str(m).strip()}
if not want:
want = {PORT_METRIC_ID}
if existing_hf and existing_hf.collect_running and _enabled_metric_ids(db, existing_hf.id) != want:
# A running collection owns its item configuration until completion.
existing_hf = None
if existing_hf and _is_highfreq_task(db, existing_hf, want):
patch: dict[str, Any] = {
"interval_sec": interval_sec,
"retention_days": retention_days,
"purpose": PURPOSE_CUTOVER_HF,
}
if status:
patch["status"] = status
biz_svc.update_task(db, existing_hf.id, patch)
refreshed = db.get(BizStateTask, existing_hf.id)
return refreshed or existing_hf, False
# Existing HF (purpose already cutover_hf) with wrong metrics → update items in place.
# Never rewrite a task that only "looks like" HF by note — create a sibling instead.
if existing_hf and str(getattr(existing_hf, "purpose", None) or "").strip() == PURPOSE_CUTOVER_HF:
items = configured_items or [
_catalog_item_for_metric(
vendor=str(template_fields.get("vendor") or existing_hf.vendor),
device_type=str(template_fields.get("device_type") or existing_hf.device_type),
metric_id=mid,
)
for mid in sorted(want)
]
note = f"割接高频/{'+'.join(sorted(want)[:3])}/{project_name}"[:256]
biz_svc.update_task(
db,
existing_hf.id,
{
"note": note,
"purpose": PURPOSE_CUTOVER_HF,
"status": status or "running",
"interval_sec": interval_sec,
"retention_days": retention_days,
"items": items,
},
)
refreshed = db.get(BizStateTask, existing_hf.id)
return refreshed or existing_hf, False
items = configured_items or [
_catalog_item_for_metric(
vendor=str(template_fields.get("vendor") or ""),
device_type=str(template_fields.get("device_type") or ""),
metric_id=mid,
)
for mid in sorted(want)
]
note = f"割接高频/{'+'.join(sorted(want)[:3])}/{project_name}"[:256]
created = biz_svc.create_task(
db,
{
"source": template_fields["source"],
"ne_id": template_fields["ne_id"],
"ne_name": template_fields.get("ne_name") or "",
"ne_ip": template_fields.get("ne_ip") or "",
"vendor": template_fields.get("vendor") or "",
"device_type": template_fields.get("device_type") or "",
"note": note,
"purpose": PURPOSE_CUTOVER_HF,
"status": status or "running",
"interval_sec": interval_sec,
"retention_days": retention_days,
"items": items,
},
)
task = db.get(BizStateTask, str(created.get("id") or ""))
if not task:
raise HTTPException(status_code=500, detail="highfreq_task_create_failed")
return task, True
def _hf_window_status(proj: BizMigrationProject) -> str:
"""Return desired HF task status based on project window: running|paused."""
status = str(getattr(proj, "status", None) or "").strip().lower()
if status in ("done", "completed", "archived", "cancelled"):
return "paused"
now = utcnow_naive()
start = getattr(proj, "hf_start_at", None)
end = getattr(proj, "hf_end_at", None)
if end and now > end:
return "paused"
if start and now < start:
return "paused"
return "running"
def _apply_hf_window_to_tasks(db: Session, proj: BizMigrationProject) -> None:
"""Immediately pause/resume bound cutover HF tasks to match project window/status."""
import logging
from ..biz_state import service as biz_svc
log = logging.getLogger("netx.biz_migration.hf_window")
window_status = _hf_window_status(proj)
for tid in _all_hf_task_ids(proj, "old") + _all_hf_task_ids(proj, "new"):
task = db.get(BizStateTask, tid)
if not task:
continue
want = "paused" if task.collect_manual_only else window_status
purpose = str(getattr(task, "purpose", None) or "").strip()
if purpose and purpose != PURPOSE_CUTOVER_HF:
continue
patch: dict[str, Any] = {}
# Legacy empty-purpose HF: stamp purpose so resume skips bindings assert
if purpose != PURPOSE_CUTOVER_HF:
patch["purpose"] = PURPOSE_CUTOVER_HF
if want == "paused" and task.status == "running":
patch["status"] = "paused"
elif want == "running" and task.status == "paused":
patch["status"] = "running"
if not patch:
continue
try:
biz_svc.update_task(db, tid, patch)
except Exception: # noqa: BLE001
log.exception("hf window sync failed project=%s task=%s want=%s", proj.id, tid, want)
def ensure_port_highfreq(
db: Session,
project_id: str,
*,
interval_sec: int = 60,
retention_days: int = 7,
collect_now: bool = True,
) -> dict[str, Any]:
"""Create/bind high-freq biz_state tasks for old/new NEs using monitor collect_metric_ids."""
return ensure_highfreq(
db,
project_id,
interval_sec=interval_sec,
retention_days=retention_days,
collect_now=collect_now,
)
def ensure_highfreq(
db: Session,
project_id: str,
*,
interval_sec: int | None = None,
retention_days: int = 7,
collect_now: bool = True,
old_ne: dict[str, Any] | None = None,
new_ne: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""One task per metric/device, paired by project; route tables are manual-only."""
proj = db.get(BizMigrationProject, project_id)
if not proj:
raise HTTPException(status_code=404, detail="project_not_found")
migrate_project_hf_slots(db, proj, commit=False)
if interval_sec is not None:
proj.hf_interval_sec = max(60, int(interval_sec))
iv_default = max(60, int(getattr(proj, "hf_interval_sec", None) or interval_sec or 60))
ret = max(1, int(retention_days or 7))
metric_ids = resolve_collect_metric_ids(db, proj)
if not metric_ids:
metric_ids = [PORT_METRIC_ID]
by_metric = _metric_interval_map(proj)
groups = _group_metrics(metric_ids, by_metric, iv_default)
want_status = _hf_window_status(proj)
old_portrait = db.get(BizStateTask, proj.old_task_id) if proj.old_task_id else None
new_portrait = db.get(BizStateTask, proj.new_task_id) if proj.new_task_id else None
# Reuse a single-metric binding; a legacy combined task can be assigned
# once, while the remaining metrics receive independent sibling tasks.
def _existing_by_metric(side: str) -> dict[str, BizStateTask]:
out: dict[str, BizStateTask] = {}
for binding in _hf_bindings(proj, side):
task = db.get(BizStateTask, binding["task_id"])
if task:
for mid in binding.get("metric_ids") or metric_ids:
if mid in metric_ids:
out.setdefault(mid, task)
return out
old_existing = _existing_by_metric("old")
new_existing = _existing_by_metric("new")
old_prev_ids = set(_all_hf_task_ids(proj, "old"))
new_prev_ids = set(_all_hf_task_ids(proj, "new"))
old_fields = _template_from_task_or_ne(
db,
portrait=old_portrait,
existing_hf=next(iter(old_existing.values()), None),
ne=old_ne,
)
new_fields = _template_from_task_or_ne(
db,
portrait=new_portrait,
existing_hf=next(iter(new_existing.values()), None),
ne=new_ne,
)
old_bindings: list[dict[str, Any]] = []
new_bindings: list[dict[str, Any]] = []
old_any_created = False
new_any_created = False
# Same physical device on both sides (board swap, same-chassis port move): one task
# per group serves as "old" and "new"; the port map tells the two ports apart.
same_device = (old_fields.get("source"), old_fields.get("ne_id")) == (new_fields.get("source"), new_fields.get("ne_id"))
for g_iv, g_metrics in groups:
manual_only = any(m in MANUAL_ROUTE_METRICS for m in g_metrics)
gid = _group_id(proj.id, g_metrics)
old_candidate = next((old_existing[m] for m in g_metrics if m in old_existing), None)
new_candidate = next((new_existing[m] for m in g_metrics if m in new_existing), None)
if old_candidate and old_candidate.collect_group_id not in ("", gid):
old_candidate = None
if new_candidate and new_candidate.collect_group_id not in ("", gid):
new_candidate = None
old_by_metric = {m: _portrait_metric_items(db, old_portrait, m) for m in g_metrics}
new_by_metric = {m: _portrait_metric_items(db, new_portrait, m) for m in g_metrics}
if not same_device:
for m in g_metrics:
old_by_metric[m], new_by_metric[m] = _union_slice_items(
m, old_by_metric[m], new_by_metric[m],
str(old_fields.get("vendor") or ""), str(new_fields.get("vendor") or ""))
old_task, old_created = _ensure_side_highfreq(
db,
existing_hf=old_candidate,
template_fields=old_fields,
project_name=proj.name,
interval_sec=g_iv,
retention_days=ret,
metric_ids=g_metrics,
status="paused",
configured_items=_group_items(db, old_portrait, old_fields, g_metrics, old_by_metric),
)
if same_device:
new_task, new_created = old_task, False
else:
new_task, new_created = _ensure_side_highfreq(
db,
existing_hf=new_candidate,
template_fields=new_fields,
project_name=proj.name,
interval_sec=g_iv,
retention_days=ret,
metric_ids=g_metrics,
status="paused",
configured_items=_group_items(db, new_portrait, new_fields, g_metrics, new_by_metric),
)
if not same_device and old_task.ne_id == new_task.ne_id:
raise HTTPException(status_code=400, detail="collect_pair_same_device")
for task in {old_task.id: old_task, new_task.id: new_task}.values():
task.collect_group_id = gid
task.collect_manual_only = manual_only
task.status = "paused" if task.collect_manual_only else want_status
db.commit()
old_any_created = old_any_created or old_created
new_any_created = new_any_created or new_created
binding = {"metric_ids": list(g_metrics), "interval_sec": g_iv, "collect_group_id": gid,
"manual_only": manual_only}
old_bindings.append({"task_id": old_task.id, **binding})
new_bindings.append({"task_id": new_task.id, **binding})
proj = db.get(BizMigrationProject, project_id)
if not proj:
raise HTTPException(status_code=404, detail="project_not_found")
proj.old_hf_bindings_json = old_bindings
proj.new_hf_bindings_json = new_bindings
proj.old_hf_task_id = old_bindings[0]["task_id"] if old_bindings else ""
proj.new_hf_task_id = new_bindings[0]["task_id"] if new_bindings else ""
proj.hf_interval_sec = iv_default
proj.updated_at = utcnow_naive()
db.commit()
# Pause HF tasks dropped from bindings (interval regroup / metric shrink)
from ..biz_state import service as biz_svc
keep_ids = {b["task_id"] for b in old_bindings} | {b["task_id"] for b in new_bindings}
for tid in (old_prev_ids | new_prev_ids) - keep_ids:
t = db.get(BizStateTask, tid)
if not t:
continue
if str(getattr(t, "purpose", None) or "").strip() not in ("", PURPOSE_CUTOVER_HF):
continue
if t.collect_group_id and not t.collect_group_id.startswith("cutover:" + proj.id + ":"):
continue
t.collect_group_id = ""
t.collect_manual_only = False
db.commit()
if t.status == "running":
try:
biz_svc.update_task(db, tid, {"status": "paused"})
except Exception: # noqa: BLE001
pass
if t.collect_running:
from ..biz_state.collect_stop import request_stop_collect
request_stop_collect(tid)
collect: dict[str, Any] = {"old": [], "new": []}
if collect_now:
collect = collect_project_now(db, project_id)
return {
"project": project_to_dict(db, proj),
"old_task": _task_brief(db, proj.old_hf_task_id),
"new_task": _task_brief(db, proj.new_hf_task_id),
"old_hf_task": _task_brief(db, proj.old_hf_task_id),
"new_hf_task": _task_brief(db, proj.new_hf_task_id),
"old_hf_bindings": old_bindings,
"new_hf_bindings": new_bindings,
"old_created": old_any_created,
"new_created": new_any_created,
"interval_sec": iv_default,
"hf_status": want_status,
"collect_metric_ids": metric_ids,
"collect": collect,
}
def collect_project_now(db: Session, project_id: str) -> dict[str, Any]:
"""Trigger immediate collect on all HF binding tasks (never portrait)."""
from ..biz_state.collect_runner import dispatch_collect
proj = db.get(BizMigrationProject, project_id)
if not proj:
raise HTTPException(status_code=404, detail="project_not_found")
migrate_project_hf_slots(db, proj, commit=True)
want_status = _hf_window_status(proj)
all_ids = _all_hf_task_ids(proj, "old") + _all_hf_task_ids(proj, "new")
if want_status != "running":
from ..biz_state import service as biz_svc
for tid in all_ids:
t = db.get(BizStateTask, tid)
if t and t.status == "running":
biz_svc.update_task(db, tid, {"status": "paused"})
return {
"old": [{"ok": False, "error": "hf_window_inactive"}],
"new": [{"ok": False, "error": "hf_window_inactive"}],
"hf_status": want_status,
}
out: dict[str, Any] = {"old": [], "new": [], "hf_status": want_status}
old_bindings, new_bindings = _hf_bindings(proj, "old"), _hf_bindings(proj, "new")
handled: set[str] = set()
for old_binding in old_bindings:
gid = old_binding.get("collect_group_id")
if not gid or gid in handled:
continue
handled.add(gid)
new_binding = next((b for b in new_bindings if b.get("collect_group_id") == gid), None)
if not new_binding:
result = {"ok": False, "error": "collect_pair_invalid"}
else:
result = dispatch_collect(old_binding["task_id"], manual=True)
result = {**result, "ok": bool(result.get("queued")), "error": result.get("reason", "")}
for side, binding in (("old", old_binding), ("new", new_binding)):
out[side].append({**result, "task_id": binding["task_id"] if binding else "",
"metric_ids": old_binding.get("metric_ids", [])})
# Legacy projects remain operable until their bindings are upgraded.
if not handled:
for side in ("old", "new"):
tids = _all_hf_task_ids(proj, side)
if not tids:
out[side] = [{"ok": False, "error": "hf_task_missing"}]
for tid in tids:
result = dispatch_collect(tid, manual=True)
out[side].append({**result, "ok": bool(result.get("queued")), "task_id": tid,
"error": result.get("reason", "")})
return out
def _red_ticket_to_dict(t: BizMigrationRedTicket) -> dict[str, Any]:
detail = dict(t.detail_json or {})
old_key = str(detail.get("old_key") or t.key_str or "")
new_key = str(detail.get("new_key") or t.new_key_str or "")
return {
"id": t.id,
"project_id": t.project_id,
"batch_id": t.batch_id,
"run_id": t.run_id,
"metric_id": t.metric_id,
"old_key": old_key,
"new_key": new_key,
"key_str": old_key,
"new_key_str": new_key,
"match_key_str": str(getattr(t, "match_key_str", "") or detail.get("match_old_key") or ""),
"match_old_key": str(detail.get("match_old_key") or ""),
"match_new_key": str(detail.get("match_new_key") or ""),
"verdict": t.verdict,
"color": t.color,
"old_status": t.old_status,
"new_status": t.new_status,
"detail": detail,
"evidence": dict(detail.get("evidence") or {}),
"resolvable": bool(detail.get("resolvable")),
"status": t.status,
"carried_to_batch_id": t.carried_to_batch_id,
"note": t.note,
"created_at": t.created_at.isoformat() if t.created_at else None,
"resolved_at": t.resolved_at.isoformat() if t.resolved_at else None,
}
def count_open_red_tickets(db: Session, project_id: str) -> int:
return (
db.query(BizMigrationRedTicket)
.filter(
BizMigrationRedTicket.project_id == project_id,
BizMigrationRedTicket.status.in_(("open", "carried")),
)
.count()
)
def list_red_tickets(
db: Session,
project_id: str,
*,
status: str = "",
limit: int = 200,
) -> dict[str, Any]:
if not db.get(BizMigrationProject, project_id):
raise HTTPException(status_code=404, detail="project_not_found")
q = db.query(BizMigrationRedTicket).filter(BizMigrationRedTicket.project_id == project_id)
if status.strip():
q = q.filter(BizMigrationRedTicket.status == status.strip())
rows = (
q.order_by(BizMigrationRedTicket.created_at.desc())
.limit(min(500, max(1, limit)))
.all()
)
return {
"open_count": count_open_red_tickets(db, project_id),
"items": [_red_ticket_to_dict(t) for t in rows],
}
def resolve_red_ticket(db: Session, ticket_id: str, *, note: str = "") -> dict[str, Any]:
t = db.get(BizMigrationRedTicket, ticket_id)
if not t:
raise HTTPException(status_code=404, detail="red_ticket_not_found")
t.status = "resolved"
t.resolved_at = utcnow_naive()
if note:
t.note = str(note)[:500]
db.commit()
db.refresh(t)
return _red_ticket_to_dict(t)
def _persist_red_tickets_from_run(
db: Session,
*,
project_id: str,
batch_id: str,
run_id: str,
) -> list[BizMigrationRedTicket]:
"""Upsert open/carried red tickets by (project, metric, match_key).
Same issue across batches / re-acceptance updates one ticket instead of
spawning duplicates. ``carried`` tickets are reopened when the key is still red.
"""
diffs = (
db.query(BizMigrationDiff)
.filter(BizMigrationDiff.run_id == run_id, BizMigrationDiff.color == "red")
.order_by(BizMigrationDiff.seq.asc())
.all()
)
existing = (
db.query(BizMigrationRedTicket)
.filter(
BizMigrationRedTicket.project_id == project_id,
BizMigrationRedTicket.status.in_(("open", "carried")),
)
.all()
)
by_id: dict[tuple[str, str, str], BizMigrationRedTicket] = {}
for t in existing:
detail = t.detail_json if isinstance(t.detail_json, dict) else {}
mk = str(getattr(t, "match_key_str", "") or "").strip()
if not mk:
detail = t.detail_json if isinstance(t.detail_json, dict) else {}
mk = str(detail.get("match_old_key") or t.key_str or "").strip()
if mk:
by_id[(str(t.metric_id or ""), str(detail.get("sheet_id") or t.metric_id or ""),
str(detail.get("match_old_key") or mk))] = t
created_or_updated: list[BizMigrationRedTicket] = []
seen: set[tuple[str, str, str]] = set()
for d in diffs:
kj = d.key_json if isinstance(d.key_json, dict) else {}
mid = str(d.metric_id or "")
match_key = str(
kj.get("match_old_key") or kj.get("key_str") or kj.get("old_key") or ""
).strip()
if not match_key:
match_key = str(kj.get("key_str") or "")[:256]
ident = (mid, str(kj.get("sheet_id") or mid), match_key)
from hashlib import sha256
stored_key = match_key if len(match_key) <= 256 else "sha256:" + sha256(match_key.encode()).hexdigest()
detail = {
"old_kind": d.old_kind,
"new_kind": d.new_kind,
"in_expect": d.in_expect,
"old": d.old_json,
"new": d.new_json,
"old_key": kj.get("old_key") or kj.get("key_str") or "",
"new_key": kj.get("new_key") or kj.get("new_key_str") or "",
"match_old_key": kj.get("match_old_key") or match_key,
"match_new_key": kj.get("match_new_key") or "",
"sheet_id": kj.get("sheet_id") or mid,
"integrity_changes": kj.get("integrity_changes") or [],
"evidence": dict(kj.get("evidence") or {}),
}
prev = by_id.get(ident)
if prev is not None:
prev.batch_id = batch_id
prev.run_id = run_id
prev.key_str = str(kj.get("key_str") or "")[:256]
prev.new_key_str = str(kj.get("new_key_str") or "")[:256]
prev.match_key_str = stored_key
prev.verdict = d.verdict
prev.color = d.color or "red"
prev.old_status = str(kj.get("old_status") or "")[:64]
prev.new_status = str(kj.get("new_status") or "")[:64]
prev.detail_json = detail
prev.status = "open"
prev.carried_to_batch_id = ""
prev.resolved_at = None
created_or_updated.append(prev)
seen.add(ident)
continue
t = BizMigrationRedTicket(
id=uuid4().hex,
project_id=project_id,
batch_id=batch_id,
run_id=run_id,
metric_id=mid,
key_str=str(kj.get("key_str") or "")[:256],
new_key_str=str(kj.get("new_key_str") or "")[:256],
match_key_str=stored_key,
verdict=d.verdict,
color=d.color or "red",
old_status=str(kj.get("old_status") or "")[:64],
new_status=str(kj.get("new_status") or "")[:64],
detail_json=detail,
status="open",
)
db.add(t)
by_id[ident] = t
created_or_updated.append(t)
seen.add(ident)
# Tickets whose key is now green stay open (an operator decides) but are flagged so the
# UI can offer a one-click close. Keys absent from this run are left alone.
pending = {ident: t for ident, t in by_id.items() if ident not in seen and t.status in ("open", "carried")}
if pending:
metrics = {ident[0] for ident in pending}
greens: dict[tuple[str, str, str], str] = {}
for g in db.query(BizMigrationDiff).filter(
BizMigrationDiff.run_id == run_id, BizMigrationDiff.color == "green",
BizMigrationDiff.metric_id.in_(metrics),
):
gk = g.key_json if isinstance(g.key_json, dict) else {}
mk = str(gk.get("match_old_key") or gk.get("key_str") or gk.get("old_key") or "").strip()
greens[(str(g.metric_id or ""), str(gk.get("sheet_id") or g.metric_id or ""), mk)] = str(g.verdict or "")
for ident, t in pending.items():
verdict = greens.get(ident)
if verdict is not None:
t.detail_json = {**dict(t.detail_json or {}), "resolvable": {"run_id": run_id, "verdict": verdict}}
return created_or_updated
def resolve_resolvable_red_tickets(db: Session, project_id: str) -> dict[str, Any]:
"""Close every ticket the latest acceptance saw turn green (explicit operator action)."""
if not db.get(BizMigrationProject, project_id):
raise HTTPException(status_code=404, detail="project_not_found")
closed = []
for t in db.query(BizMigrationRedTicket).filter(
BizMigrationRedTicket.project_id == project_id,
BizMigrationRedTicket.status.in_(("open", "carried")),
):
mark = (t.detail_json or {}).get("resolvable") if isinstance(t.detail_json, dict) else None
if mark:
t.status = "resolved"
t.resolved_at = utcnow_naive()
t.note = (t.note or f"auto-closed: green in run {mark.get('run_id', '')}")[:500]
closed.append(t.id)
db.commit()
return {"closed": len(closed), "ids": closed, "open_red_count": count_open_red_tickets(db, project_id)}
def finish_batch(
db: Session,
batch_id: str,
*,
mark_done: bool = False,
_run_id: str = "",
) -> dict[str, Any]:
"""本批完成:关窗 → 终验 → 验收小结 → 红单留痕(不硬卡下一批)."""
mb = db.get(BizMigrationBatch, batch_id)
if not mb:
raise HTTPException(status_code=404, detail="batch_not_found")
if mb.status not in ("active", "review"):
raise HTTPException(
status_code=400,
detail=f"batch_not_finishable:{mb.status}",
)
# acceptance=True closes the logical window. Persist batch closure only after
# evaluation succeeds, so an API/collection failure keeps the batch operable.
run = run_evaluate(
db, batch_id=batch_id, acceptance=True, purpose="acceptance", _commit=False, _run_id=_run_id,
)
summary = dict(run.get("summary") or {})
progress = dict(summary.get("progress") or {})
anomaly = int(summary.get("anomaly") or 0)
ok = int(progress.get("ok") or 0)
total = int(progress.get("total") or 0)
missing_metrics = [str(x) for x in (summary.get("missing_metrics") or []) if str(x)]
current_missing = [str(x) for x in (summary.get("current_missing_metrics") or []) if str(x)]
fail_reasons: list[str] = []
if total <= 0:
fail_reasons.append("empty_expect")
if missing_metrics:
fail_reasons.append("new_baseline_missing")
if current_missing:
fail_reasons.append("current_missing")
if not summary.get("coverage_complete", False):
fail_reasons.append("coverage_incomplete")
if summary.get("duplicate_keys"):
fail_reasons.append("duplicate_keys")
if anomaly:
fail_reasons.append("anomaly")
if total > 0 and ok < total:
fail_reasons.append("incomplete")
passed = not fail_reasons
accept_summary = {
"passed": passed,
"fail_reasons": fail_reasons,
"missing_metrics": missing_metrics,
"current_missing_metrics": current_missing,
"progress_ok": ok,
"progress_total": total,
"anomaly": anomaly,
"verdict_counts": dict(summary.get("verdict_counts") or {}),
"expect_ports": list(summary.get("expect_ports") or []),
"warnings": {
"duplicate_keys_outside_scope": list(summary.get("duplicate_warnings") or []),
"manual_pending_metrics": list(summary.get("manual_pending_metrics") or []),
"not_collected_metrics": list(summary.get("not_collected_metrics") or []),
},
"run_id": run.get("id"),
"old_batch_id": run.get("old_batch_id"),
"new_batch_id": run.get("new_batch_id"),
"finished_at": utcnow_naive().isoformat(),
}
mb = db.get(BizMigrationBatch, batch_id)
if not mb:
raise HTTPException(status_code=404, detail="batch_not_found")
if mb.status == "active":
mb.ended_at = utcnow_naive()
mb.status = "done" if mark_done else "review"
mb.accept_run_id = str(run.get("id") or "")
mb.accept_status = "passed" if passed else "failed"
mb.accept_summary_json = accept_summary
mb.updated_at = utcnow_naive()
# Upsert reds (no wipe — merge by metric+match_key across re-acceptance)
reds = _persist_red_tickets_from_run(
db,
project_id=mb.project_id,
batch_id=batch_id,
run_id=str(run.get("id") or ""),
)
db.commit()
db.refresh(mb)
return {
"batch": batch_to_dict(mb),
"run": run,
"accept_summary": accept_summary,
"red_tickets": [_red_ticket_to_dict(t) for t in reds],
"open_red_count": count_open_red_tickets(db, mb.project_id),
"can_continue": True, # 带红继续:永不硬卡
}
_EVAL_THREADS_LOCK = threading.Lock()
def _report_eval_progress(run_id: str, index: int, total: int, title: str) -> None:
"""Progress visible to pollers: separate short transaction, never fatal."""
from ..db import SessionLocal
try:
s = SessionLocal()
try:
run = s.get(BizMigrationRun, run_id)
if run is not None and run.status == "running":
summary = dict(run.summary_json or {})
summary["progress"] = {"phase": "evaluating", "sheet_index": index,
"sheet_total": total, "sheet_title": title}
run.summary_json = summary
s.commit()
finally:
s.close()
except Exception: # noqa: BLE001 — progress is best effort
_log.debug("evaluate progress write failed run=%s", run_id, exc_info=True)
def _claim_evaluation(
db: Session, batch_id: str, *, acceptance: bool, purpose: str,
) -> BizMigrationRun:
"""Create the ``running`` run that owns the project. 409 when one is already in flight."""
mb = db.get(BizMigrationBatch, batch_id)
if not mb:
raise HTTPException(status_code=404, detail="batch_not_found")
proj = db.query(BizMigrationProject).filter(BizMigrationProject.id == mb.project_id).with_for_update().first()
if not proj:
raise HTTPException(status_code=404, detail="project_not_found")
db.refresh(mb)
if acceptance and mb.status not in ("active", "review"):
raise HTTPException(status_code=409, detail="monitor_window_closed")
if not proj.old_baseline_batch_id:
raise HTTPException(status_code=400, detail="old_baseline_required")
if not proj.new_baseline_batch_id:
raise HTTPException(status_code=400, detail="new_baseline_required")
busy = db.query(BizMigrationRun.id).filter(
BizMigrationRun.project_id == proj.id, BizMigrationRun.status == "running").first()
if busy:
raise HTTPException(status_code=409, detail="evaluation_in_progress")
run = BizMigrationRun(
id=uuid4().hex, project_id=proj.id, batch_id=batch_id, purpose=purpose, status="running",
summary_json={"progress": {"phase": "queued", "sheet_index": 0, "sheet_total": 0}},
message="queued",
)
db.add(run)
db.commit()
db.refresh(run)
return run
def _spawn_evaluation(run_id: str, work: Any, label: str) -> None:
from ..db import SessionLocal
def _bg() -> None:
s = SessionLocal()
try:
work(s)
except Exception as exc: # noqa: BLE001 — recorded on the run for the poller
s.rollback()
detail = getattr(exc, "detail", None)
_log.warning("background %s failed run=%s: %s", label, run_id, detail or exc)
run = s.get(BizMigrationRun, run_id)
if run is not None and run.status == "running":
run.status = "failed"
run.message = str(detail if detail is not None else exc)[:1000]
s.commit()
finally:
s.close()
threading.Thread(target=_bg, name=f"biz-mig-{label}-{run_id[:8]}", daemon=True).start()
def enqueue_evaluate(
db: Session, batch_id: str, *, old_batch_id: str = "", new_batch_id: str = "",
) -> dict[str, Any]:
"""Start a manual evaluation in the background; poll ``GET /runs/{id}`` for the result."""
run = _claim_evaluation(db, batch_id, acceptance=False, purpose="manual")
_spawn_evaluation(
run.id,
lambda s: run_evaluate(s, batch_id=batch_id, old_batch_id=old_batch_id, new_batch_id=new_batch_id,
purpose="manual", _run_id=run.id),
"evaluate",
)
return run_to_dict(db, run)
def enqueue_finish(db: Session, batch_id: str, *, mark_done: bool = False) -> dict[str, Any]:
"""Final acceptance in the background. The wave closes only if the evaluation succeeds."""
mb = db.get(BizMigrationBatch, batch_id)
if not mb:
raise HTTPException(status_code=404, detail="batch_not_found")
if mb.status not in ("active", "review"):
raise HTTPException(status_code=400, detail=f"batch_not_finishable:{mb.status}")
run = _claim_evaluation(db, batch_id, acceptance=True, purpose="acceptance")
_spawn_evaluation(
run.id, lambda s: finish_batch(s, batch_id, mark_done=mark_done, _run_id=run.id), "finish",
)
return {"run_id": run.id, "status": "running", "run": run_to_dict(db, run),
"batch": batch_to_dict(db.get(BizMigrationBatch, batch_id))}
def recover_interrupted_evaluations_on_startup(db: Session) -> int:
"""A restart kills background evaluations; release the projects they were holding."""
rows = db.query(BizMigrationRun).filter(BizMigrationRun.status == "running").all()
for r in rows:
r.status = "cancelled"
r.message = "interrupted_by_restart"
if rows:
db.commit()
return len(rows)
def _validate_batch_status(db: Session, project_id: str, batch_id: str, status: str) -> None:
if status not in ("pending", "active", "review", "done"):
raise HTTPException(status_code=400, detail="batch_status_invalid")
if status == "active":
db.query(BizMigrationProject).filter(BizMigrationProject.id == project_id).with_for_update().first()
if db.query(BizMigrationBatch.id).filter(BizMigrationBatch.project_id == project_id,
BizMigrationBatch.id != batch_id, BizMigrationBatch.status == "active").first():
raise HTTPException(status_code=409, detail="another_batch_active")
def patch_batch(db: Session, batch_id: str, body: dict[str, Any]) -> dict[str, Any]:
b = db.get(BizMigrationBatch, batch_id)
if not b:
raise HTTPException(status_code=404, detail="batch_not_found")
if body.get("status") is not None:
_validate_batch_status(db, b.project_id, b.id, str(body["status"]).strip())
if "batch_label" in body and body["batch_label"] is not None:
b.batch_label = str(body["batch_label"]).strip() or b.batch_label
if "note" in body and body["note"] is not None:
b.note = str(body["note"])[:500]
if "expect_set" in body and isinstance(body["expect_set"], dict):
b.expect_set_json = dict(body["expect_set"])
if "status" in body and body["status"] is not None:
st = str(body["status"]).strip()
# Prefer finish_batch for review/done from active (auto acceptance)
if st in ("review", "done") and b.status == "active":
return finish_batch(db, batch_id, mark_done=(st == "done"))["batch"]
prev = b.status
b.status = st or b.status
if st == "active" and prev != "active":
b.started_at = utcnow_naive()
# 带红:标记既有 open 红单为 carried(不关闭)
open_reds = (
db.query(BizMigrationRedTicket)
.filter(
BizMigrationRedTicket.project_id == b.project_id,
BizMigrationRedTicket.status == "open",
)
.all()
)
for t in open_reds:
t.status = "carried"
t.carried_to_batch_id = b.id
b.updated_at = utcnow_naive()
db.commit()
db.refresh(b)
return batch_to_dict(b)
def get_run(db: Session, run_id: str) -> dict[str, Any]:
run = db.get(BizMigrationRun, run_id)
if not run:
raise HTTPException(status_code=404, detail="run_not_found")
return run_to_dict(db, run, include_diffs=False)
def list_run_diffs(
db: Session,
run_id: str,
*,
metric_id: str = "",
sheet_id: str = "",
only_expect: bool = False,
verdict: str = "",
color: str = "",
kw: str = "",
offset: int = 0,
limit: int = 100,
) -> dict[str, Any]:
run = db.get(BizMigrationRun, run_id)
if not run:
raise HTTPException(status_code=404, detail="run_not_found")
sources = [and_(BizMigrationDiff.run_id == c["source_run_id"],
BizMigrationDiff.key_json["sheet_id"].as_string() == sheet_key(c))
for c in (run.summary_json or {}).get("sheet_cards", [])
if c.get("source_run_id") and c["source_run_id"] != run_id
and not (only_expect and c.get("expected_count") == 0)
and (not metric_id or c.get("metric_id") == metric_id)
and (not sheet_id or sheet_key(c) == sheet_id)]
q = db.query(BizMigrationDiff).filter(or_(BizMigrationDiff.run_id == run_id, *sources))
if metric_id:
q = q.filter(BizMigrationDiff.metric_id == metric_id)
if sheet_id:
q = q.filter(BizMigrationDiff.key_json["sheet_id"].as_string() == sheet_id)
if only_expect:
q = q.filter(BizMigrationDiff.in_expect.is_(True))
if verdict:
q = q.filter(BizMigrationDiff.verdict == verdict)
if color:
q = q.filter(BizMigrationDiff.color == color)
if kw.strip():
literal = kw.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
q = q.filter(BizMigrationDiff.search_text.ilike(f"%{literal}%", escape="\\"))
total = q.count()
rows = q.order_by(BizMigrationDiff.metric_id.asc(), BizMigrationDiff.seq.asc(), BizMigrationDiff.id.asc()).offset(max(0, offset)).limit(min(500, max(1, limit))).all()
return {
"total": total,
"offset": max(0, offset),
"limit": min(500, max(1, limit)),
"items": [diff_to_dict(d) for d in rows],
}
def list_runs(db: Session, batch_id: str, limit: int = 20) -> list[dict[str, Any]]:
rows = (
db.query(BizMigrationRun)
.filter(BizMigrationRun.batch_id == batch_id)
.order_by(BizMigrationRun.created_at.desc())
.limit(min(100, max(1, limit)))
.all()
)
return [run_to_dict(db, r) for r in rows]
def board(db: Session, batch_id: str, run_id: str = "") -> dict[str, Any]:
"""Progress / anomaly board for a migration batch (latest or given run)."""
mb = db.get(BizMigrationBatch, batch_id)
if not mb:
raise HTTPException(status_code=404, detail="batch_not_found")
proj = get_project(db, mb.project_id)
run = None
if run_id:
run = db.get(BizMigrationRun, run_id)
if not run or run.batch_id != batch_id:
raise HTTPException(status_code=404, detail="run_not_found")
else:
# Latest *finished* run; a run still being computed has nothing to show yet.
run = (
db.query(BizMigrationRun)
.filter(BizMigrationRun.batch_id == batch_id, BizMigrationRun.status == "success")
.order_by(BizMigrationRun.created_at.desc())
.first()
)
return {
"project": proj,
"batch": batch_to_dict(mb),
"run": run_to_dict(db, run) if run else None,
"sampling": sampling_state(db, db.get(BizMigrationProject, mb.project_id)) if not run_id else [],
}
def sampling_state(db: Session, proj: BizMigrationProject) -> list[dict[str, Any]]:
"""Small live read independent of evaluation and route row counts."""
out = []
for binding in _hf_bindings(proj, "old"):
gid = binding.get("collect_group_id")
if not gid:
continue
latest = db.query(BizStateBatch).filter(BizStateBatch.collect_group_id == gid).order_by(
BizStateBatch.queued_at.desc(), BizStateBatch.started_at.desc()).first()
peers = db.query(BizStateBatch).filter(BizStateBatch.collect_round_id == latest.collect_round_id).all() if latest else []
old = next((b for b in peers if b.task_id == binding["task_id"]), None)
new = next((b for b in peers if b.task_id != binding["task_id"]), None)
card: dict[str, Any] = {"metric_id": (binding.get("metric_ids") or [""])[0],
"collect_group_id": gid, "collect_round_id": latest.collect_round_id if latest else "",
"manual_only": binding.get("manual_only", False), "interval_sec": binding["interval_sec"]}
for side, batch in (("old", old), ("new", new)):
card[f"{side}_collect_status"] = batch.status if batch else "idle"
card[f"{side}_duration_sec"] = max(0, ((batch.ended_at or utcnow_naive()) - batch.started_at).total_seconds()) if batch and batch.status != "queued" else 0
card[f"{side}_queue_sec"] = max(0, ((utcnow_naive() if batch.status == "queued" else batch.started_at) - (batch.queued_at or batch.started_at)).total_seconds()) if batch else 0
if old and new and all(b.status != "queued" for b in (old, new)):
card["start_skew_sec"] = abs((old.started_at - new.started_at).total_seconds())
out.append(card)
return out
def get_monitor_context(
db: Session,
*,
project_id: str = "",
task_id: str = "",
) -> dict[str, Any]:
"""Fat read for AI / ops: project definition + templates + mapping + tasks.
Prefer ``project_id`` (cutover). ``task_id`` alone returns that biz_state task
and any cutover project that references it.
"""
from ..biz_state import compare_service as cmp_svc
from ..biz_state import service as biz_svc
pid = str(project_id or "").strip()
tid = str(task_id or "").strip()
if not pid and not tid:
raise HTTPException(status_code=400, detail="project_id_or_task_id_required")
proj_row: BizMigrationProject | None = None
if pid:
proj_row = db.get(BizMigrationProject, pid)
if not proj_row:
raise HTTPException(status_code=404, detail="project_not_found")
elif tid:
proj_row = (
db.query(BizMigrationProject)
.filter(
(BizMigrationProject.old_task_id == tid)
| (BizMigrationProject.new_task_id == tid)
| (BizMigrationProject.old_hf_task_id == tid)
| (BizMigrationProject.new_hf_task_id == tid)
)
.order_by(BizMigrationProject.updated_at.desc())
.first()
)
out: dict[str, Any] = {
"project": None,
"monitor_template": None,
"compare_template": None,
"port_mapping": None,
"tasks": {},
"task": None,
}
if tid:
try:
out["task"] = biz_svc.get_task(db, tid)
except HTTPException:
if not proj_row:
raise
if not proj_row:
return out
migrate_project_hf_slots(db, proj_row, commit=False)
out["project"] = project_to_dict(db, proj_row)
mt_id = str(getattr(proj_row, "monitor_template_id", None) or "").strip()
if mt_id:
try:
out["monitor_template"] = mon_tpl.get_monitor_template(db, mt_id)
except HTTPException:
out["monitor_template"] = None
ct_id = ""
if isinstance(out.get("monitor_template"), dict):
ct_id = str(out["monitor_template"].get("compare_template_id") or "").strip()
if ct_id:
ct = db.get(BizCompareTemplate, ct_id)
if ct:
out["compare_template"] = cmp_svc._template_out(ct) # noqa: SLF001
map_id = str(proj_row.mapping_id or "").strip()
if map_id:
m = db.get(BizPortMapping, map_id)
if m:
out["port_mapping"] = cmp_svc._mapping_out(db, m) # noqa: SLF001
task_ids = {
"old_portrait": proj_row.old_task_id or "",
"new_portrait": proj_row.new_task_id or "",
"old_hf": getattr(proj_row, "old_hf_task_id", None) or "",
"new_hf": getattr(proj_row, "new_hf_task_id", None) or "",
}
for side in ("old", "new"):
for i, b in enumerate(_hf_bindings(proj_row, side)):
bid = str(b.get("task_id") or "").strip()
if bid:
task_ids[f"{side}_hf_{i}"] = bid
tasks: dict[str, Any] = {}
for label, task_ref in task_ids.items():
if not task_ref or task_ref in {t.get("id") for t in tasks.values() if isinstance(t, dict)}:
# still key by label even if duplicate id
pass
if not task_ref:
continue
try:
tasks[label] = biz_svc.get_task(db, task_ref)
except HTTPException:
tasks[label] = {"id": task_ref, "error": "task_not_found"}
out["tasks"] = tasks
return out