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>
This commit is contained in:
oliver 2026-10-12 11:51:55 +08:00
parent fff7d63b9b
commit 315539e0fd
59 changed files with 6483 additions and 487 deletions

View file

@ -6,13 +6,17 @@ from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from ..models import (
BizCompareDiff,
BizCompareJob,
BizCompareRun,
BizMigrationBatch,
BizMigrationDiff,
BizMigrationProject,
BizMigrationRedTicket,
BizMigrationRun,
BizStateBatch,
BizStateBatchCommand,
@ -76,9 +80,103 @@ def protected_batch_map(
add(before_id, before_reason)
add(after_id, after_reason)
# Every batch a *kept* acceptance/manual run (or the newest auto run of a wave)
# compared, not only the first binding. Older auto runs are bounded by
# ``purge_auto_runs`` and keep protecting just their primary pair above.
for run_batches in _kept_run_metric_batches(db):
for bid in run_batches:
add(bid, "migration_run_metric")
return dict(out)
def _kept_run_metric_batches(db: Session, *, limit: int = 500) -> list[set[str]]:
newest_auto = (
db.query(func.max(BizMigrationRun.created_at).label("at"), BizMigrationRun.batch_id.label("bid"))
.filter(BizMigrationRun.purpose == "auto").group_by(BizMigrationRun.batch_id).subquery()
)
latest_auto_ids = [
r[0] for r in db.query(BizMigrationRun.id).join(
newest_auto,
(BizMigrationRun.batch_id == newest_auto.c.bid) & (BizMigrationRun.created_at == newest_auto.c.at),
).limit(limit).all()
]
kept_ids = [
r[0] for r in db.query(BizMigrationRun.id).filter(BizMigrationRun.purpose != "auto")
.order_by(BizMigrationRun.created_at.desc()).limit(limit).all()
] + latest_auto_ids
out: list[set[str]] = []
for start in range(0, len(kept_ids), 100):
for (summary,) in db.query(BizMigrationRun.summary_json).filter(
BizMigrationRun.id.in_(kept_ids[start:start + 100])
):
batches: set[str] = set()
for item in ((summary or {}).get("metric_batches") or {}).values():
if isinstance(item, dict):
batches.update(str(item.get(k) or "") for k in ("old_batch_id", "new_batch_id"))
batches.discard("")
if batches:
out.append(batches)
return out
def purge_auto_runs(db: Session, *, migration_keep: int = 300, compare_keep: int = 50) -> dict[str, int]:
"""Bound auto-generated run history.
Cutover monitoring writes a run (+ one diff row per object) every minute and
auto-compare writes one per new sample. Acceptance / manual runs and runs a
red ticket points at are kept forever; only the newest ``keep`` auto runs of
each wave / job survive.
"""
dropped = {"migration_runs": 0, "compare_runs": 0}
ticket_runs = {r[0] for r in db.query(BizMigrationRedTicket.run_id).filter(
BizMigrationRedTicket.run_id != "").distinct()}
accepted = {r[0] for r in db.query(BizMigrationBatch.accept_run_id).filter(
BizMigrationBatch.accept_run_id != "")}
keep_forever = ticket_runs | accepted
def surplus(model, group_col, extra_filter, keep: int) -> list[str]:
over = [g for g, n in db.query(group_col, func.count(model.id)).filter(*extra_filter)
.group_by(group_col).all() if n > keep]
ids: list[str] = []
for g in over:
rows = db.query(model.id).filter(group_col == g, *extra_filter).order_by(
model.created_at.desc(), model.id.desc()).offset(keep).all()
ids.extend(r[0] for r in rows if r[0] not in keep_forever)
return ids
mig_ids = surplus(BizMigrationRun, BizMigrationRun.batch_id, [BizMigrationRun.purpose == "auto"],
max(1, int(migration_keep)))
for start in range(0, len(mig_ids), 500):
chunk = mig_ids[start:start + 500]
db.query(BizMigrationDiff).filter(BizMigrationDiff.run_id.in_(chunk)).delete(synchronize_session=False)
db.query(BizMigrationRun).filter(BizMigrationRun.id.in_(chunk)).delete(synchronize_session=False)
dropped["migration_runs"] = len(mig_ids)
# Auto-mode jobs only. A run's origin lives in summary_json["purpose"]; unmarked runs
# are manual and stay. Only the surplus beyond ``compare_keep`` is inspected.
cmp_ids: list[str] = []
keep_n = max(1, int(compare_keep))
for (job_id,) in db.query(BizCompareJob.id).filter(BizCompareJob.mode == "auto").all():
surplus_ids = [r[0] for r in db.query(BizCompareRun.id).filter(
BizCompareRun.job_id == job_id, BizCompareRun.status.notin_(("queued", "running")),
).order_by(BizCompareRun.created_at.desc(), BizCompareRun.id.desc()).offset(keep_n).all()]
for start in range(0, len(surplus_ids), 200):
for rid, summary in db.query(BizCompareRun.id, BizCompareRun.summary_json).filter(
BizCompareRun.id.in_(surplus_ids[start:start + 200])
):
if (summary or {}).get("purpose") == "auto":
cmp_ids.append(rid)
for start in range(0, len(cmp_ids), 500):
chunk = cmp_ids[start:start + 500]
db.query(BizCompareDiff).filter(BizCompareDiff.run_id.in_(chunk)).delete(synchronize_session=False)
db.query(BizCompareRun).filter(BizCompareRun.id.in_(chunk)).delete(synchronize_session=False)
dropped["compare_runs"] = len(cmp_ids)
if mig_ids or cmp_ids:
db.commit()
return dropped
def protected_batch_ids(db: Session, *, task_id: str = "") -> set[str]:
return set(protected_batch_map(db, task_id=task_id).keys())