Make biz compare async and cutover-friendly for large tables.

Run compares in the background with progress, persist fails first, and store success as sampled key+row_id hydrated from source so million-row sheets no longer block the API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-08 10:47:39 +08:00
parent 3e2cb4165c
commit 227959b9a2
10 changed files with 1035 additions and 168 deletions

View file

@ -105,6 +105,9 @@ def compare_rows(
field_rules: Sequence[Mapping[str, Any]] | None = None,
iface_normalize_rules: Sequence[Mapping[str, str]] | None = None,
ignore_port_changes: bool | None = None,
include_unchanged: bool = False,
unchanged_limit: int | None = None,
compact_unchanged: bool = False,
) -> dict[str, Any]:
"""Return summary + diffs list.
@ -112,6 +115,14 @@ def compare_rows(
Pipeline: iface normalize (both sides) → port map (before) → match.
``include_unchanged``: when False, matching rows still increment
``summary.unchanged`` but are omitted from ``diffs``.
``unchanged_limit``: max unchanged diffs to emit (None = no cap). Use with
large sheets so the UI can browse a sample without writing millions of rows.
``compact_unchanged``: emit key only (empty before/after) to cut storage.
``ignore_port_changes``:
- ``None`` (default): auto — drop iface from match key only when remaining
keys stay unique on both sides (legacy LLDP-style heuristic).
@ -184,10 +195,57 @@ def compare_rows(
before_dup_keys: list[tuple[str, ...]] = []
diffs: list[dict[str, Any]] = []
added = removed = changed = unchanged = duplicate = 0
unchanged_listed = 0
limit_n = None if unchanged_limit is None else max(0, int(unchanged_limit))
def _key_obj(row: dict[str, Any]) -> dict[str, Any]:
return {f: row.get(f, "") for f in key_fields}
def _row_id(row: dict[str, Any] | None) -> str:
if not isinstance(row, dict):
return ""
netx = row.get("_netx")
if isinstance(netx, dict):
return str(netx.get("row_id") or "")
return ""
def _emit_unchanged(orig: dict[str, Any], mapped: dict[str, Any], after_row: dict[str, Any]) -> None:
nonlocal unchanged_listed
if not include_unchanged:
return
if limit_n is not None and unchanged_listed >= limit_n:
return
unchanged_listed += 1
before_rid = _row_id(orig)
after_rid = _row_id(after_row)
if compact_unchanged:
diffs.append(
{
"kind": "unchanged",
"key": _key_obj(mapped),
"before": {},
"after": {},
"mapped_before": {},
"changes": {},
"compact": True,
"before_row_id": before_rid,
"after_row_id": after_rid,
}
)
else:
diffs.append(
{
"kind": "unchanged",
"key": _key_obj(mapped),
"before": orig,
"after": after_row,
"mapped_before": mapped,
"changes": {},
"before_row_id": before_rid,
"after_row_id": after_rid,
}
)
for orig, mapped in zip(before_rows, before_mapped):
k = row_key(mapped, match_keys)
if k in before_keys:
@ -248,16 +306,7 @@ def compare_rows(
)
else:
unchanged += 1
diffs.append(
{
"kind": "unchanged",
"key": _key_obj(mapped),
"before": orig,
"after": after,
"mapped_before": mapped,
"changes": {},
}
)
_emit_unchanged(orig, mapped, after)
for k, after in after_index.items():
if k in before_keys:
@ -319,6 +368,11 @@ def compare_rows(
"duplicate_keys_before": before_dup,
"duplicate_keys_after": after_dup,
"duplicate_key_list": _fmt_keys(before_dup_keys + after_dup_keys),
"unchanged_listed": unchanged_listed,
"unchanged_truncated": bool(
include_unchanged and limit_n is not None and unchanged > unchanged_listed
),
"unchanged_compact": bool(compact_unchanged and unchanged_listed > 0),
},
"diffs": diffs,
"mapping_stats": stats,

View file

@ -6,6 +6,7 @@ import io
import json
import logging
import threading
import time
import zipfile
from datetime import datetime
from typing import Any
@ -181,7 +182,61 @@ def _compare_side(
}
_DIFF_CHUNK = 2000
_LOAD_YIELD_PER = 5000
_SEARCH_TEXT_MAX = 4000
# Success-row persist policy (see resolve_unchanged_policy)
_STORE_UNCHANGED_MODES = frozenset({"auto", "always", "never", "sample", "keys"})
_UNCHANGED_FULL_MAX = 20_000
_UNCHANGED_SAMPLE_MAX = 5_000
def normalize_store_unchanged(raw: Any) -> str:
m = str(raw or "auto").strip().lower()
return m if m in _STORE_UNCHANGED_MODES else "auto"
def resolve_unchanged_policy(
mode: str, *, before_n: int, after_n: int
) -> dict[str, Any]:
"""Decide whether / how many success rows to emit for one sheet.
- always: full before/after for every match (slow on million-row sheets)
- never: count only
- sample: key + row_id sample (cap) — cutover spot-check default for large sheets
- keys: all success as key + row_id (full browse; write still heavy)
- auto: full when sheet is small; otherwise sample compact
"""
m = normalize_store_unchanged(mode)
n = max(int(before_n or 0), int(after_n or 0))
if m == "never":
return {"mode": m, "include": False, "limit": None, "compact": False}
if m == "always":
return {"mode": m, "include": True, "limit": None, "compact": False}
if m == "keys":
return {"mode": m, "include": True, "limit": None, "compact": True}
if m == "sample":
return {
"mode": m,
"include": True,
"limit": _UNCHANGED_SAMPLE_MAX,
"compact": True,
}
# auto — cutover-oriented: large sheets sample, not full key dump
if n <= _UNCHANGED_FULL_MAX:
return {"mode": m, "include": True, "limit": None, "compact": False}
return {
"mode": m,
"include": True,
"limit": _UNCHANGED_SAMPLE_MAX,
"compact": True,
}
def _strip_netx(row: Any) -> dict[str, Any]:
"""Drop collector provenance before persisting compare payloads."""
if not isinstance(row, dict):
return {}
return {k: v for k, v in row.items() if k != "_netx"}
def _diff_search_text(d: dict[str, Any]) -> str:
@ -215,30 +270,114 @@ def _persist_sheet_diffs(
run_id: str,
metric_id: str,
diffs: list[dict[str, Any]],
) -> None:
"""Bulk-insert diff rows; avoids embedding million-row arrays in summary_json."""
seq_start: int = 0,
) -> int:
"""Bulk-insert diff rows already selected by the engine policy.
Compact success rows carry key + before/after_row_id; JSON sides stay empty
and are hydrated from metric tables on read.
"""
buf: list[dict[str, Any]] = []
for i, d in enumerate(diffs):
seq = int(seq_start or 0)
written = 0
for d in diffs:
kind = str(d.get("kind") or "")
before = _strip_netx(d.get("before"))
after = _strip_netx(d.get("after"))
mapped = _strip_netx(d.get("mapped_before"))
payload = {
"kind": kind,
"key": dict(d.get("key") or {}),
"before": before,
"after": after,
"mapped_before": mapped,
"changes": dict(d.get("changes") or {}),
}
# Compact success: search_text = kind + key only (no fat sides)
search_src = (
{"kind": kind, "key": payload["key"]}
if kind == "unchanged" and bool(d.get("compact"))
else payload
)
buf.append(
{
"id": uuid4().hex,
"run_id": run_id,
"metric_id": metric_id,
"seq": i,
"kind": str(d.get("kind") or ""),
"key_json": dict(d.get("key") or {}),
"before_json": dict(d.get("before") or {}),
"after_json": dict(d.get("after") or {}),
"mapped_before_json": dict(d.get("mapped_before") or {}),
"changes_json": dict(d.get("changes") or {}),
"search_text": _diff_search_text(d),
"seq": seq,
"kind": kind,
"key_json": payload["key"],
"before_json": before,
"after_json": after,
"mapped_before_json": mapped,
"changes_json": payload["changes"],
"before_row_id": str(d.get("before_row_id") or "")[:64],
"after_row_id": str(d.get("after_row_id") or "")[:64],
"search_text": _diff_search_text(search_src),
}
)
seq += 1
written += 1
if len(buf) >= _DIFF_CHUNK:
db.bulk_insert_mappings(BizCompareDiff, buf)
buf.clear()
if buf:
db.bulk_insert_mappings(BizCompareDiff, buf)
return written
def _metric_rows_by_ids(db: Session, ids: list[str]) -> dict[str, dict[str, Any]]:
"""Load BizStateMetricRow / LLDP rows by primary key for hydrate."""
clean = [str(i).strip() for i in ids if str(i or "").strip()]
if not clean:
return {}
from ..models import BizStateMetricRow
out: dict[str, dict[str, Any]] = {}
# Chunk IN lists for large pages
for i in range(0, len(clean), 500):
chunk = clean[i : i + 500]
for r in db.query(BizStateMetricRow).filter(BizStateMetricRow.id.in_(chunk)).all():
out[str(r.id)] = dict(r.data_json or {})
missing = [x for x in chunk if x not in out]
if missing:
for n in (
db.query(BizStateLldpNeighbor)
.filter(BizStateLldpNeighbor.id.in_(missing))
.all()
):
out[str(n.id)] = {
"local_if": n.local_if,
"remote_sys": n.remote_sys,
"remote_if": n.remote_if,
"remote_ip": n.remote_ip,
"protocol": n.protocol,
}
return out
def _hydrate_diff_rows(db: Session, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Fill empty before/after from metric tables when row_ids are present."""
need: list[str] = []
for it in items:
if not it.get("before") and it.get("before_row_id"):
need.append(str(it["before_row_id"]))
if not it.get("after") and it.get("after_row_id"):
need.append(str(it["after_row_id"]))
if not need:
return items
by_id = _metric_rows_by_ids(db, need)
for it in items:
brid = str(it.get("before_row_id") or "")
arid = str(it.get("after_row_id") or "")
if not it.get("before") and brid and brid in by_id:
it["before"] = by_id[brid]
if not it.get("after") and arid and arid in by_id:
it["after"] = by_id[arid]
# Success compact: no mapped_before stored — UI falls back to before
if not it.get("mapped_before") and it.get("before"):
it["mapped_before"] = dict(it["before"])
return items
def _diff_row_out(r: BizCompareDiff) -> dict[str, Any]:
@ -249,6 +388,8 @@ def _diff_row_out(r: BizCompareDiff) -> dict[str, Any]:
"after": r.after_json or {},
"mapped_before": r.mapped_before_json or {},
"changes": r.changes_json or {},
"before_row_id": getattr(r, "before_row_id", "") or "",
"after_row_id": getattr(r, "after_row_id", "") or "",
}
@ -1207,47 +1348,54 @@ def _port_map_dict(db: Session, mapping_id: str) -> dict[str, str]:
def _load_metric_rows(db: Session, *, batch_id: str, metric_id: str) -> list[dict[str, Any]]:
"""Load metric rows with streaming fetch to avoid holding the full ORM set."""
if metric_id == "lldp_neighbor":
rows = (
q = (
db.query(BizStateLldpNeighbor)
.filter(BizStateLldpNeighbor.batch_id == batch_id)
.all()
.execution_options(stream_results=True, yield_per=_LOAD_YIELD_PER)
)
return [
{
"local_if": n.local_if,
"remote_sys": n.remote_sys,
"remote_if": n.remote_if,
"remote_ip": n.remote_ip,
"protocol": n.protocol,
"_netx": {
"batch_id": batch_id,
"batch_command_id": n.batch_command_id or "",
"task_id": n.task_id or "",
"ne_id": n.ne_id or "",
"collected_at": n.collected_at.isoformat() + "Z" if n.collected_at else None,
"row_id": n.id,
},
}
for n in rows
]
out: list[dict[str, Any]] = []
for n in q:
out.append(
{
"local_if": n.local_if,
"remote_sys": n.remote_sys,
"remote_if": n.remote_if,
"remote_ip": n.remote_ip,
"protocol": n.protocol,
"_netx": {
"batch_id": batch_id,
"batch_command_id": n.batch_command_id or "",
"task_id": n.task_id or "",
"ne_id": n.ne_id or "",
"collected_at": n.collected_at.isoformat() + "Z"
if n.collected_at
else None,
"row_id": n.id,
},
}
)
db.expunge(n)
return out
# Generic tabular metrics (ISIS / interface / ARP / ND6 / BGP …)
from ..models import BizStateMetricRow
rows = (
q = (
db.query(BizStateMetricRow)
.filter(
BizStateMetricRow.batch_id == batch_id,
BizStateMetricRow.metric_id == metric_id,
)
.order_by(BizStateMetricRow.seq.asc(), BizStateMetricRow.id.asc())
.all()
.execution_options(stream_results=True, yield_per=_LOAD_YIELD_PER)
)
if rows:
out = []
for r in q:
# Raw rows only — filtering belongs to the compare sheet template
# (``row_filters``), not metric-specific branches here.
# ``_netx`` is collector provenance (stripped before field compare).
return [
out.append(
{
**dict(r.data_json or {}),
"_netx": {
@ -1255,12 +1403,16 @@ def _load_metric_rows(db: Session, *, batch_id: str, metric_id: str) -> list[dic
"batch_command_id": r.batch_command_id or "",
"task_id": r.task_id or "",
"ne_id": r.ne_id or "",
"collected_at": r.collected_at.isoformat() + "Z" if r.collected_at else None,
"collected_at": r.collected_at.isoformat() + "Z"
if r.collected_at
else None,
"row_id": r.id,
},
}
for r in rows
]
)
db.expunge(r)
if out:
return out
# Known metric with zero rows is OK; unknown metric still errors
if metric_id in metric_field_map():
return []
@ -1318,6 +1470,7 @@ def _job_out(j: BizCompareJob) -> dict[str, Any]:
"mode": j.mode,
"status": j.status,
"enabled_sheet_ids": _str_list(getattr(j, "enabled_sheet_ids", None)),
"store_unchanged": normalize_store_unchanged(getattr(j, "store_unchanged", None)),
"note": j.note,
"updated_at": j.updated_at.isoformat() + "Z" if j.updated_at else None,
}
@ -1360,6 +1513,7 @@ def create_job(db: Session, body: dict[str, Any]) -> dict[str, Any]:
mode=str(body.get("mode") or "manual")[:16],
status="ready",
enabled_sheet_ids=enabled,
store_unchanged=normalize_store_unchanged(body.get("store_unchanged")),
note=str(body.get("note") or "")[:512],
created_at=_utcnow(),
updated_at=_utcnow(),
@ -1391,6 +1545,8 @@ def update_job(db: Session, job_id: str, body: dict[str, Any]) -> dict[str, Any]
setattr(j, key, str(body.get(key) or ""))
if "enabled_sheet_ids" in body:
j.enabled_sheet_ids = _str_list(body.get("enabled_sheet_ids"))
if "store_unchanged" in body:
j.store_unchanged = normalize_store_unchanged(body.get("store_unchanged"))
j.updated_at = _utcnow()
db.commit()
return _job_out(j)
@ -1452,6 +1608,7 @@ def _run_sheet(
after_batch_id: str,
port_map: dict[str, str],
iface_normalize_rules: list[dict[str, str]] | None = None,
store_unchanged: str = "auto",
) -> dict[str, Any]:
key_fields = list(sheet.get("key_fields") or [])
iface_fields = list(sheet.get("iface_fields") or [])
@ -1476,6 +1633,9 @@ def _run_sheet(
after_raw = _load_metric_rows(db, batch_id=after_batch_id, metric_id=sheet["metric_id"])
before_rows = apply_row_filters(before_raw, row_filters)
after_rows = apply_row_filters(after_raw, row_filters)
policy = resolve_unchanged_policy(
store_unchanged, before_n=len(before_rows), after_n=len(after_rows)
)
result = compare_rows(
before_rows=before_rows,
after_rows=after_rows,
@ -1486,11 +1646,15 @@ def _run_sheet(
field_rules=field_rules,
iface_normalize_rules=iface_normalize_rules,
ignore_port_changes=ignore_ports,
include_unchanged=bool(policy["include"]),
unchanged_limit=policy.get("limit"),
compact_unchanged=bool(policy.get("compact")),
)
summary = dict(result["summary"])
summary["before_raw_count"] = len(before_raw)
summary["after_raw_count"] = len(after_raw)
summary["row_filters"] = len(row_filters)
summary["unchanged_policy"] = policy
return {
"sheet_id": sheet_key(sheet),
"title": sheet_title(sheet),
@ -1509,19 +1673,10 @@ def _run_sheet(
}
def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> dict[str, Any]:
lock = _job_compare_lock(job_id)
if not lock.acquire(blocking=False):
raise HTTPException(status_code=409, detail="compare_already_running")
try:
return _run_compare_unlocked(db, job_id, force_after_batch_id=force_after_batch_id)
finally:
lock.release()
def _run_compare_unlocked(
def _validate_compare_job(
db: Session, job_id: str, *, force_after_batch_id: str = ""
) -> dict[str, Any]:
"""Resolve job/template/batches/sheets; raises HTTPException on bad input."""
j = db.get(BizCompareJob, job_id)
if not j:
raise HTTPException(status_code=404, detail="job_not_found")
@ -1541,10 +1696,136 @@ def _run_compare_unlocked(
sheets_cfg = _filter_enabled_sheets(sheets_cfg, getattr(j, "enabled_sheet_ids", None))
if not sheets_cfg:
raise HTTPException(status_code=400, detail="no_enabled_sheets")
return {
"job": j,
"template": tpl,
"before_batch_id": before_batch_id,
"after_batch_id": after_batch_id,
"sheets_cfg": sheets_cfg,
}
pmap = _port_map_dict(db, j.mapping_id)
def _create_running_run(
db: Session,
*,
job: BizCompareJob,
tpl: BizCompareTemplate,
before_batch_id: str,
after_batch_id: str,
sheets_cfg: list[dict[str, Any]],
) -> BizCompareRun:
first_metric = str(sheets_cfg[0].get("metric_id") or "")
run = BizCompareRun(
id=uuid4().hex,
job_id=job.id,
template_id=tpl.id,
mapping_id=job.mapping_id,
before_batch_id=before_batch_id,
after_batch_id=after_batch_id,
metric_id=first_metric,
status="running",
summary_json={
"progress": {
"phase": "queued",
"sheet_index": 0,
"sheet_total": len(sheets_cfg),
"sheet_id": "",
"sheet_title": "",
"elapsed_ms": 0,
},
"sheet_count": len(sheets_cfg),
"added": 0,
"removed": 0,
"changed": 0,
"unchanged": 0,
"duplicate": 0,
"before_count": 0,
"after_count": 0,
"sheets": [],
},
diffs_json=[],
mapping_stats_json={},
message="queued",
created_at=_utcnow(),
)
db.add(run)
db.commit()
db.refresh(run)
return run
def _set_run_progress(
db: Session,
run: BizCompareRun,
*,
phase: str,
sheet_index: int,
sheet_total: int,
sheet: dict[str, Any] | None,
started_mono: float,
extra: dict[str, Any] | None = None,
) -> None:
elapsed_ms = int((time.monotonic() - started_mono) * 1000)
prev = dict(run.summary_json or {})
progress = {
"phase": phase,
"sheet_index": sheet_index,
"sheet_total": sheet_total,
"sheet_id": sheet_key(sheet) if sheet else "",
"sheet_title": sheet_title(sheet) if sheet else "",
"elapsed_ms": elapsed_ms,
}
if extra:
progress.update(extra)
prev["progress"] = progress
run.summary_json = prev
run.status = "running"
title = progress["sheet_title"] or progress["sheet_id"] or ""
run.message = (
f"{phase} {sheet_index}/{sheet_total}"
+ (f" · {title}" if title else "")
+ f" · {elapsed_ms // 1000}s"
)[:1024]
db.commit()
def _execute_compare_into_run(db: Session, run_id: str) -> dict[str, Any]:
"""Run compare into an existing ``running`` BizCompareRun; persist sheet-by-sheet."""
run = db.get(BizCompareRun, run_id)
if not run:
raise HTTPException(status_code=404, detail="run_not_found")
j = db.get(BizCompareJob, run.job_id)
if not j:
run.status = "failed"
run.message = "job_not_found"
db.commit()
raise HTTPException(status_code=404, detail="job_not_found")
tpl = db.get(BizCompareTemplate, run.template_id)
if not tpl:
run.status = "failed"
run.message = "template_not_found"
db.commit()
raise HTTPException(status_code=404, detail="template_not_found")
before_batch_id = str(run.before_batch_id or "")
after_batch_id = str(run.after_batch_id or "")
sheets_cfg = _filter_enabled_sheets(
template_metrics(tpl), getattr(j, "enabled_sheet_ids", None)
)
if not sheets_cfg:
run.status = "failed"
run.message = "no_enabled_sheets"
db.commit()
raise HTTPException(status_code=400, detail="no_enabled_sheets")
started_mono = time.monotonic()
store_mode = normalize_store_unchanged(getattr(j, "store_unchanged", None))
pmap = _port_map_dict(db, run.mapping_id)
norm_rules = template_iface_normalize(tpl)
sheet_results: list[dict[str, Any]] = []
sheet_metas: list[dict[str, Any]] = []
unchanged_listed_total = 0
unchanged_truncated_any = False
unchanged_compact_any = False
agg = {
"before_count": 0,
"after_count": 0,
@ -1555,87 +1836,250 @@ def _run_compare_unlocked(
"duplicate": 0,
}
mapping_by_metric: dict[str, Any] = {}
for sheet in sheets_cfg:
one = _run_sheet(
db,
sheet=sheet,
before_batch_id=before_batch_id,
after_batch_id=after_batch_id,
port_map=pmap,
iface_normalize_rules=norm_rules,
)
sheet_results.append(one)
s = one["summary"]
for k in agg:
agg[k] += int(s.get(k) or 0)
mapping_by_metric[sheet_key(one)] = one["mapping_stats"]
first = sheet_results[0]
field_counts: dict[str, int] = {}
for s in sheet_results:
for d in list(s.get("diffs") or []):
if str(d.get("kind") or "") != "changed":
continue
for fname in d.get("changes") or {}:
field_counts[str(fname)] = field_counts.get(str(fname), 0) + 1
top_fields = sorted(
[{"field": k, "count": v} for k, v in field_counts.items()],
key=lambda x: (-int(x["count"]), str(x["field"])),
)[:8]
total = len(sheets_cfg)
run_id = uuid4().hex
# Persist counts/meta only — diffs go to biz_compare_diff rows
summary_payload = {
**agg,
"sheet_count": len(sheet_results),
"top_changed_fields": top_fields,
"sheets": [
{
"sheet_id": s.get("sheet_id") or s["metric_id"],
"title": s.get("title") or s.get("sheet_id") or s["metric_id"],
"metric_id": s["metric_id"],
"key_fields": s["key_fields"],
"iface_fields": s["iface_fields"],
"compare_fields": s["compare_fields"],
"display_fields": s.get("display_fields") or [],
"field_rules": s.get("field_rules") or [],
"ignore_port_changes": s.get("ignore_port_changes"),
"mode": s["mode"],
"summary": s["summary"],
}
for s in sheet_results
],
}
try:
for idx, sheet in enumerate(sheets_cfg, start=1):
_set_run_progress(
db,
run,
phase="loading",
sheet_index=idx,
sheet_total=total,
sheet=sheet,
started_mono=started_mono,
)
one = _run_sheet(
db,
sheet=sheet,
before_batch_id=before_batch_id,
after_batch_id=after_batch_id,
port_map=pmap,
iface_normalize_rules=norm_rules,
store_unchanged=store_mode,
)
s = one["summary"]
listed = int(s.get("unchanged_listed") or 0)
unchanged_listed_total += listed
if s.get("unchanged_truncated"):
unchanged_truncated_any = True
if s.get("unchanged_compact"):
unchanged_compact_any = True
_set_run_progress(
db,
run,
phase="persisting",
sheet_index=idx,
sheet_total=total,
sheet=sheet,
started_mono=started_mono,
extra={
"before_count": int(s.get("before_count") or 0),
"after_count": int(s.get("after_count") or 0),
"diff_rows": int(s.get("added") or 0)
+ int(s.get("removed") or 0)
+ int(s.get("changed") or 0)
+ int(s.get("duplicate") or 0)
+ listed,
},
)
diffs = list(one.get("diffs") or [])
for d in diffs:
if str(d.get("kind") or "") != "changed":
continue
for fname in d.get("changes") or {}:
field_counts[str(fname)] = field_counts.get(str(fname), 0) + 1
# Cutover-first: persist fails so the UI can open the fail tab ASAP,
# then write success slim keys (sample / keys mode).
fail_diffs = [d for d in diffs if str(d.get("kind") or "") != "unchanged"]
ok_diffs = [d for d in diffs if str(d.get("kind") or "") == "unchanged"]
mid = sheet_key(one)
_set_run_progress(
db,
run,
phase="persisting_fail",
sheet_index=idx,
sheet_total=total,
sheet=sheet,
started_mono=started_mono,
extra={"fail_rows": len(fail_diffs)},
)
n_fail = _persist_sheet_diffs(
db, run_id=run.id, metric_id=mid, diffs=fail_diffs, seq_start=0
)
db.commit()
if ok_diffs:
_set_run_progress(
db,
run,
phase="persisting_ok",
sheet_index=idx,
sheet_total=total,
sheet=sheet,
started_mono=started_mono,
extra={"ok_rows": len(ok_diffs)},
)
_persist_sheet_diffs(
db,
run_id=run.id,
metric_id=mid,
diffs=ok_diffs,
seq_start=n_fail,
)
for k in agg:
agg[k] += int(s.get(k) or 0)
mapping_by_metric[mid] = one["mapping_stats"]
sheet_metas.append(
{
"sheet_id": one.get("sheet_id") or one["metric_id"],
"title": one.get("title") or one.get("sheet_id") or one["metric_id"],
"metric_id": one["metric_id"],
"key_fields": one["key_fields"],
"iface_fields": one["iface_fields"],
"compare_fields": one["compare_fields"],
"display_fields": one.get("display_fields") or [],
"field_rules": one.get("field_rules") or [],
"ignore_port_changes": one.get("ignore_port_changes"),
"mode": one["mode"],
"summary": one["summary"],
}
)
# Drop heavy diffs before next sheet
one.clear()
diffs.clear()
fail_diffs.clear()
ok_diffs.clear()
db.commit()
run = BizCompareRun(
id=run_id,
job_id=j.id,
template_id=tpl.id,
mapping_id=j.mapping_id,
before_batch_id=before_batch_id,
after_batch_id=after_batch_id,
metric_id=first["metric_id"],
status="success",
summary_json=summary_payload,
diffs_json=[],
mapping_stats_json=mapping_by_metric,
message="",
created_at=_utcnow(),
)
db.add(run)
db.flush()
for s in sheet_results:
_persist_sheet_diffs(
duration_ms = int((time.monotonic() - started_mono) * 1000)
top_fields = sorted(
[{"field": k, "count": v} for k, v in field_counts.items()],
key=lambda x: (-int(x["count"]), str(x["field"])),
)[:8]
summary_payload = {
**agg,
"sheet_count": len(sheet_metas),
"top_changed_fields": top_fields,
"duration_ms": duration_ms,
"store_unchanged": store_mode,
"unchanged_stored": unchanged_listed_total > 0,
"unchanged_listed": unchanged_listed_total,
"unchanged_truncated": unchanged_truncated_any,
"unchanged_compact": unchanged_compact_any,
"progress": {
"phase": "done",
"sheet_index": total,
"sheet_total": total,
"sheet_id": "",
"sheet_title": "",
"elapsed_ms": duration_ms,
},
"sheets": sheet_metas,
}
run.status = "success"
run.summary_json = summary_payload
run.mapping_stats_json = mapping_by_metric
run.metric_id = str(sheet_metas[0]["metric_id"]) if sheet_metas else run.metric_id
run.message = f"done · {duration_ms // 1000}s"
run.diffs_json = []
j.updated_at = _utcnow()
if j.mode == "manual":
j.after_batch_id = after_batch_id
db.commit()
return get_run(db, run.id)
except HTTPException as exc:
run.status = "failed"
run.message = str(getattr(exc, "detail", "") or exc)[:1024]
prev = dict(run.summary_json or {})
prog = dict(prev.get("progress") or {})
prog["phase"] = "failed"
prog["elapsed_ms"] = int((time.monotonic() - started_mono) * 1000)
prev["progress"] = prog
prev["duration_ms"] = prog["elapsed_ms"]
run.summary_json = prev
db.commit()
raise
except Exception as exc:
_log.exception("compare run failed run=%s job=%s", run_id, j.id)
run.status = "failed"
run.message = str(exc)[:1024]
prev = dict(run.summary_json or {})
prog = dict(prev.get("progress") or {})
prog["phase"] = "failed"
prog["elapsed_ms"] = int((time.monotonic() - started_mono) * 1000)
prev["progress"] = prog
prev["duration_ms"] = prog["elapsed_ms"]
run.summary_json = prev
db.commit()
raise
def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> dict[str, Any]:
"""Synchronous compare (auto-compare / tests). Blocks the caller until done."""
lock = _job_compare_lock(job_id)
if not lock.acquire(blocking=False):
raise HTTPException(status_code=409, detail="compare_already_running")
try:
ctx = _validate_compare_job(db, job_id, force_after_batch_id=force_after_batch_id)
run = _create_running_run(
db,
run_id=run_id,
metric_id=sheet_key(s),
diffs=list(s.get("diffs") or []),
job=ctx["job"],
tpl=ctx["template"],
before_batch_id=ctx["before_batch_id"],
after_batch_id=ctx["after_batch_id"],
sheets_cfg=ctx["sheets_cfg"],
)
j.updated_at = _utcnow()
if j.mode == "manual":
j.after_batch_id = after_batch_id
db.commit()
return get_run(db, run.id)
return _execute_compare_into_run(db, run.id)
finally:
lock.release()
def enqueue_compare(
db: Session, job_id: str, *, force_after_batch_id: str = ""
) -> dict[str, Any]:
"""Create a ``running`` run and execute compare on a daemon thread.
Returns immediately so the HTTP worker / UI stay responsive. Poll
``GET /compare/runs/{id}`` for progress (``summary.progress``).
"""
lock = _job_compare_lock(job_id)
if not lock.acquire(blocking=False):
raise HTTPException(status_code=409, detail="compare_already_running")
run_id = ""
try:
ctx = _validate_compare_job(db, job_id, force_after_batch_id=force_after_batch_id)
run = _create_running_run(
db,
job=ctx["job"],
tpl=ctx["template"],
before_batch_id=ctx["before_batch_id"],
after_batch_id=ctx["after_batch_id"],
sheets_cfg=ctx["sheets_cfg"],
)
run_id = run.id
except Exception:
lock.release()
raise
def _bg() -> None:
from ..db import SessionLocal
s = SessionLocal()
try:
_execute_compare_into_run(s, run_id)
except Exception:
_log.exception("bg compare failed job=%s run=%s", job_id, run_id)
finally:
s.close()
lock.release()
threading.Thread(
target=_bg,
name=f"biz-cmp-{run_id[:8]}",
daemon=True,
).start()
return get_run(db, run_id)
def _csv_cell(v: Any) -> str:
@ -1775,6 +2219,13 @@ def _enrich_summary(summary: dict[str, Any], sheets: list[dict[str, Any]]) -> di
"ok": fail_count == 0,
"sheet_cards": sheet_cards,
"top_changed_fields": top_fields,
"duration_ms": int(summary.get("duration_ms") or 0),
"store_unchanged": str(summary.get("store_unchanged") or ""),
"unchanged_stored": bool(summary.get("unchanged_stored", True)),
"unchanged_listed": int(summary.get("unchanged_listed") or 0),
"unchanged_truncated": bool(summary.get("unchanged_truncated")),
"unchanged_compact": bool(summary.get("unchanged_compact")),
"progress": dict(summary.get("progress") or {}),
}
@ -1893,12 +2344,13 @@ def list_run_diffs(
.limit(size_n)
.all()
)
items = _hydrate_diff_rows(db, [_diff_row_out(x) for x in rows])
return {
"total": total,
"page": page_n,
"page_size": size_n,
"metric_id": mid,
"items": [_diff_row_out(x) for x in rows],
"items": items,
}
# Legacy: diffs embedded in summary_json / diffs_json
@ -1922,7 +2374,10 @@ def list_run_diffs(
def _iter_sheet_diffs(db: Session, run_id: str, metric_id: str) -> list[dict[str, Any]]:
"""Load all diffs for one sheet (export). Prefer row table; fall back to inline."""
"""Load all diffs for one sheet (export). Prefer row table; fall back to inline.
Compact success rows are hydrated in chunks from metric tables.
"""
if _run_has_diff_rows(db, run_id):
out: list[dict[str, Any]] = []
offset = 0
@ -1937,7 +2392,8 @@ def _iter_sheet_diffs(db: Session, run_id: str, metric_id: str) -> list[dict[str
)
if not rows:
break
out.extend(_diff_row_out(x) for x in rows)
chunk = _hydrate_diff_rows(db, [_diff_row_out(x) for x in rows])
out.extend(chunk)
offset += len(rows)
if len(rows) < _DIFF_CHUNK:
break
@ -2025,22 +2481,34 @@ def list_runs(db: Session, job_id: str, *, limit: int = 20) -> list[dict[str, An
job = db.get(BizCompareJob, job_id)
before_tid = str(job.before_task_id or "") if job else ""
after_tid = str(job.after_task_id or "") if job else ""
return [
{
"id": r.id,
"before_batch_id": r.before_batch_id,
"after_batch_id": r.after_batch_id,
"before": _compare_side(db, r.before_batch_id, fallback_task_id=before_tid),
"after": _compare_side(db, r.after_batch_id, fallback_task_id=after_tid),
"status": r.status,
"summary": {
k: (r.summary_json or {}).get(k, 0)
for k in ("added", "removed", "changed", "unchanged", "sheet_count")
},
"created_at": r.created_at.isoformat() + "Z" if r.created_at else None,
}
for r in rows
]
out: list[dict[str, Any]] = []
for r in rows:
summary = dict(r.summary_json or {})
out.append(
{
"id": r.id,
"before_batch_id": r.before_batch_id,
"after_batch_id": r.after_batch_id,
"before": _compare_side(db, r.before_batch_id, fallback_task_id=before_tid),
"after": _compare_side(db, r.after_batch_id, fallback_task_id=after_tid),
"status": r.status,
"message": r.message or "",
"summary": {
k: summary.get(k, 0)
for k in (
"added",
"removed",
"changed",
"unchanged",
"sheet_count",
"duration_ms",
)
},
"progress": dict(summary.get("progress") or {}),
"created_at": r.created_at.isoformat() + "Z" if r.created_at else None,
}
)
return out
def try_auto_compare_for_task(db: Session, task_id: str, batch_id: str) -> int:

View file

@ -23,6 +23,7 @@ def apply_biz_state_schema(conn: Connection) -> None:
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_command_batch_id ON biz_state_batch_command (batch_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_state_lldp_neighbor_batch_id ON biz_state_lldp_neighbor (batch_id)",
"ALTER TABLE biz_compare_job ADD COLUMN IF NOT EXISTS enabled_sheet_ids JSON DEFAULT '[]'",
"ALTER TABLE biz_compare_job ADD COLUMN IF NOT EXISTS store_unchanged VARCHAR(16) DEFAULT 'auto'",
"CREATE INDEX IF NOT EXISTS ix_biz_compare_job_status ON biz_compare_job (status)",
"CREATE INDEX IF NOT EXISTS ix_biz_compare_run_job_id ON biz_compare_run (job_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_state_vrf_route_batch_id ON biz_state_vrf_route_summary (batch_id)",
@ -31,6 +32,8 @@ def apply_biz_state_schema(conn: Connection) -> None:
"CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_id ON biz_compare_diff (run_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_metric_kind ON biz_compare_diff (run_id, metric_id, kind)",
"CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_metric_seq ON biz_compare_diff (run_id, metric_id, seq)",
"ALTER TABLE biz_compare_diff ADD COLUMN IF NOT EXISTS before_row_id VARCHAR(64) DEFAULT ''",
"ALTER TABLE biz_compare_diff ADD COLUMN IF NOT EXISTS after_row_id VARCHAR(64) DEFAULT ''",
"CREATE INDEX IF NOT EXISTS ix_biz_migration_project_status ON biz_migration_project (status)",
"CREATE INDEX IF NOT EXISTS ix_biz_migration_batch_project_id ON biz_migration_batch (project_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_migration_run_batch_id ON biz_migration_run (batch_id)",

View file

@ -633,6 +633,8 @@ class CompareJobIn(BaseModel):
mode: str = "manual"
# Empty = all template sheets; non-empty = only these sheet_id values
enabled_sheet_ids: list[str] = Field(default_factory=list)
# auto|always|never|sample|keys — how to persist matching (success) rows
store_unchanged: str = "auto"
note: str = ""
@ -723,8 +725,15 @@ def api_delete_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]
@router.post("/compare/jobs/{job_id}/run")
def api_run_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.run_compare(db, job_id)
def api_run_job(
job_id: str,
sync: bool = Query(False, description="If true, block until compare finishes (tests/debug)"),
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""Start a compare run. Default is async (returns status=running immediately)."""
if sync:
return cmp_svc.run_compare(db, job_id)
return cmp_svc.enqueue_compare(db, job_id)
@router.get("/compare/jobs/{job_id}/runs")

View file

@ -303,6 +303,8 @@ class BizCompareJob(Base):
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|ready|auto
# Empty = all template sheets; non-empty = only these sheet_id values
enabled_sheet_ids: Mapped[list] = mapped_column(_JsonType, default=list)
# auto|always|never|sample — how to persist matching (success) rows
store_unchanged: Mapped[str] = mapped_column(String(16), default="auto")
note: Mapped[str] = mapped_column(String(512), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
@ -347,4 +349,7 @@ class BizCompareDiff(Base):
after_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
mapped_before_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
changes_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
# Pointers into BizStateMetricRow / LLDP tables for compact success hydrate
before_row_id: Mapped[str] = mapped_column(String(64), default="")
after_row_id: Mapped[str] = mapped_column(String(64), default="")
search_text: Mapped[str] = mapped_column(Text, default="")