mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
Compare same-key multipath by order; heartbeat during persist.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3d92a74fac
commit
dd4195cdbc
7 changed files with 300 additions and 170 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
from .compare_rules import field_rule_map, values_equal, explain_diff
|
from .compare_rules import field_rule_map, values_equal, explain_diff
|
||||||
|
|
@ -111,9 +112,14 @@ def compare_rows(
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return summary + diffs list.
|
"""Return summary + diffs list.
|
||||||
|
|
||||||
Diff kinds: added | removed | changed | unchanged | duplicate
|
Diff kinds: added | removed | changed | unchanged
|
||||||
|
|
||||||
Pipeline: iface normalize (both sides) → port map (before) → match.
|
Pipeline: iface normalize (both sides) → port map (before) → ordered
|
||||||
|
same-key pairing (load / list order within each match key).
|
||||||
|
|
||||||
|
Same match key with N before and M after rows: zip by index
|
||||||
|
``0..min(N,M)-1`` for field compare; extras become ``removed`` (before)
|
||||||
|
or ``added`` (after). Example: 5 vs 2 → 2 compared + 3 removed.
|
||||||
|
|
||||||
``include_unchanged``: when False, matching rows still increment
|
``include_unchanged``: when False, matching rows still increment
|
||||||
``summary.unchanged`` but are omitted from ``diffs``.
|
``summary.unchanged`` but are omitted from ``diffs``.
|
||||||
|
|
@ -129,8 +135,8 @@ def compare_rows(
|
||||||
- ``True``: force drop iface from match key when a non-empty candidate exists.
|
- ``True``: force drop iface from match key when a non-empty candidate exists.
|
||||||
- ``False``: never drop iface from match key.
|
- ``False``: never drop iface from match key.
|
||||||
|
|
||||||
Duplicate match keys are not silently discarded: extras become ``duplicate``
|
``summary.duplicate`` is always 0. ``duplicate_keys_*`` count match keys
|
||||||
diffs and ``summary.duplicate_key_list`` lists the colliding keys.
|
that appear more than once on a side (diagnostic only).
|
||||||
|
|
||||||
``field_rules`` drives normalize / numeric tolerance / per-field compare mode
|
``field_rules`` drives normalize / numeric tolerance / per-field compare mode
|
||||||
(template-driven; no metric-specific branches here).
|
(template-driven; no metric-specific branches here).
|
||||||
|
|
@ -177,26 +183,21 @@ def compare_rows(
|
||||||
apply_port_map(r, iface_fields=iface_list, port_map=pmap) for r in before_norm
|
apply_port_map(r, iface_fields=iface_list, port_map=pmap) for r in before_norm
|
||||||
]
|
]
|
||||||
|
|
||||||
after_index: dict[tuple[str, ...], dict[str, Any]] = {}
|
before_groups: dict[tuple[str, ...], list[tuple[dict[str, Any], dict[str, Any]]]] = (
|
||||||
after_dup = 0
|
defaultdict(list)
|
||||||
after_dup_keys: list[tuple[str, ...]] = []
|
)
|
||||||
after_dup_rows: list[tuple[tuple[str, ...], dict[str, Any]]] = []
|
after_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
||||||
|
for orig, mapped in zip(before_rows, before_mapped):
|
||||||
|
before_groups[row_key(mapped, match_keys)].append((orig, mapped))
|
||||||
for r in after_norm:
|
for r in after_norm:
|
||||||
k = row_key(r, match_keys)
|
after_groups[row_key(r, match_keys)].append(r)
|
||||||
if k in after_index:
|
|
||||||
after_dup += 1
|
|
||||||
after_dup_keys.append(k)
|
|
||||||
after_dup_rows.append((k, r))
|
|
||||||
continue # first wins — do not overwrite
|
|
||||||
after_index[k] = r
|
|
||||||
|
|
||||||
before_keys: set[tuple[str, ...]] = set()
|
|
||||||
before_dup = 0
|
|
||||||
before_dup_keys: list[tuple[str, ...]] = []
|
|
||||||
diffs: list[dict[str, Any]] = []
|
diffs: list[dict[str, Any]] = []
|
||||||
added = removed = changed = unchanged = duplicate = 0
|
added = removed = changed = unchanged = 0
|
||||||
unchanged_listed = 0
|
unchanged_listed = 0
|
||||||
limit_n = None if unchanged_limit is None else max(0, int(unchanged_limit))
|
limit_n = None if unchanged_limit is None else max(0, int(unchanged_limit))
|
||||||
|
multi_before_keys: list[tuple[str, ...]] = []
|
||||||
|
multi_after_keys: list[tuple[str, ...]] = []
|
||||||
|
|
||||||
def _key_obj(row: dict[str, Any]) -> dict[str, Any]:
|
def _key_obj(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
return {f: row.get(f, "") for f in key_fields}
|
return {f: row.get(f, "") for f in key_fields}
|
||||||
|
|
@ -246,96 +247,90 @@ def compare_rows(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Stable key order: before encounter order, then after-only keys
|
||||||
|
seen_keys: set[tuple[str, ...]] = set()
|
||||||
|
ordered_keys: list[tuple[str, ...]] = []
|
||||||
for orig, mapped in zip(before_rows, before_mapped):
|
for orig, mapped in zip(before_rows, before_mapped):
|
||||||
k = row_key(mapped, match_keys)
|
k = row_key(mapped, match_keys)
|
||||||
if k in before_keys:
|
if k not in seen_keys:
|
||||||
before_dup += 1
|
seen_keys.add(k)
|
||||||
before_dup_keys.append(k)
|
ordered_keys.append(k)
|
||||||
duplicate += 1
|
for r in after_norm:
|
||||||
diffs.append(
|
k = row_key(r, match_keys)
|
||||||
{
|
if k not in seen_keys:
|
||||||
"kind": "duplicate",
|
seen_keys.add(k)
|
||||||
"side": "before",
|
ordered_keys.append(k)
|
||||||
"key": _key_obj(mapped),
|
for k in ordered_keys:
|
||||||
"before": orig,
|
b_list = before_groups.get(k) or []
|
||||||
"after": after_index.get(k),
|
a_list = after_groups.get(k) or []
|
||||||
"mapped_before": mapped,
|
if len(b_list) > 1:
|
||||||
"changes": {},
|
multi_before_keys.append(k)
|
||||||
}
|
if len(a_list) > 1:
|
||||||
)
|
multi_after_keys.append(k)
|
||||||
continue # only first before row participates in match
|
n = max(len(b_list), len(a_list))
|
||||||
before_keys.add(k)
|
for i in range(n):
|
||||||
after = after_index.get(k)
|
if i >= len(b_list):
|
||||||
if after is None:
|
after = a_list[i]
|
||||||
removed += 1
|
added += 1
|
||||||
diffs.append(
|
diffs.append(
|
||||||
{
|
{
|
||||||
"kind": "removed",
|
"kind": "added",
|
||||||
"key": _key_obj(mapped),
|
"key": _key_obj(after),
|
||||||
"before": orig,
|
"before": None,
|
||||||
"after": None,
|
"after": after,
|
||||||
"mapped_before": mapped,
|
"mapped_before": None,
|
||||||
"changes": {},
|
"changes": {},
|
||||||
}
|
"before_row_id": "",
|
||||||
)
|
"after_row_id": _row_id(after),
|
||||||
continue
|
}
|
||||||
# Empty compare_fields = presence-only: keyed rows that exist on both
|
)
|
||||||
# sides are unchanged (no value checks).
|
continue
|
||||||
field_changes: dict[str, dict[str, Any]] = {}
|
if i >= len(a_list):
|
||||||
for f in compare_fields:
|
orig, mapped = b_list[i]
|
||||||
bv = mapped.get(f, "")
|
removed += 1
|
||||||
av = after.get(f, "")
|
diffs.append(
|
||||||
rule = rules.get(f)
|
{
|
||||||
if not values_equal(bv, av, rule=rule):
|
"kind": "removed",
|
||||||
entry: dict[str, Any] = {"before": bv, "after": av}
|
"key": _key_obj(mapped),
|
||||||
reason = explain_diff(bv, av, rule=rule)
|
"before": orig,
|
||||||
if reason:
|
"after": None,
|
||||||
entry["reason"] = reason
|
"mapped_before": mapped,
|
||||||
field_changes[f] = entry
|
"changes": {},
|
||||||
if field_changes:
|
"before_row_id": _row_id(orig),
|
||||||
changed += 1
|
"after_row_id": "",
|
||||||
diffs.append(
|
}
|
||||||
{
|
)
|
||||||
"kind": "changed",
|
continue
|
||||||
"key": _key_obj(mapped),
|
orig, mapped = b_list[i]
|
||||||
"before": orig,
|
after = a_list[i]
|
||||||
"after": after,
|
field_changes: dict[str, dict[str, Any]] = {}
|
||||||
"mapped_before": mapped,
|
for f in compare_fields:
|
||||||
"changes": field_changes,
|
bv = mapped.get(f, "")
|
||||||
}
|
av = after.get(f, "")
|
||||||
)
|
rule = rules.get(f)
|
||||||
else:
|
if not values_equal(bv, av, rule=rule):
|
||||||
unchanged += 1
|
entry: dict[str, Any] = {"before": bv, "after": av}
|
||||||
_emit_unchanged(orig, mapped, after)
|
reason = explain_diff(bv, av, rule=rule)
|
||||||
|
if reason:
|
||||||
for k, after in after_index.items():
|
entry["reason"] = reason
|
||||||
if k in before_keys:
|
field_changes[f] = entry
|
||||||
continue
|
if field_changes:
|
||||||
added += 1
|
changed += 1
|
||||||
diffs.append(
|
diffs.append(
|
||||||
{
|
{
|
||||||
"kind": "added",
|
"kind": "changed",
|
||||||
"key": _key_obj(after),
|
"key": _key_obj(mapped),
|
||||||
"before": None,
|
"before": orig,
|
||||||
"after": after,
|
"after": after,
|
||||||
"mapped_before": None,
|
"mapped_before": mapped,
|
||||||
"changes": {},
|
"changes": field_changes,
|
||||||
}
|
"before_row_id": _row_id(orig),
|
||||||
)
|
"after_row_id": _row_id(after),
|
||||||
|
}
|
||||||
for k, after in after_dup_rows:
|
)
|
||||||
duplicate += 1
|
else:
|
||||||
diffs.append(
|
unchanged += 1
|
||||||
{
|
_emit_unchanged(orig, mapped, after)
|
||||||
"kind": "duplicate",
|
|
||||||
"side": "after",
|
|
||||||
"key": _key_obj(after),
|
|
||||||
"before": None,
|
|
||||||
"after": after,
|
|
||||||
"mapped_before": None,
|
|
||||||
"changes": {},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def _fmt_keys(keys: list[tuple[str, ...]]) -> list[str]:
|
def _fmt_keys(keys: list[tuple[str, ...]]) -> list[str]:
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
|
|
@ -345,7 +340,7 @@ def compare_rows(
|
||||||
if s not in seen:
|
if s not in seen:
|
||||||
seen.add(s)
|
seen.add(s)
|
||||||
out.append(s)
|
out.append(s)
|
||||||
return out
|
return out[:64]
|
||||||
|
|
||||||
stats = mapping_stats(
|
stats = mapping_stats(
|
||||||
before_rows=before_norm,
|
before_rows=before_norm,
|
||||||
|
|
@ -363,11 +358,11 @@ def compare_rows(
|
||||||
"removed": removed,
|
"removed": removed,
|
||||||
"changed": changed,
|
"changed": changed,
|
||||||
"unchanged": unchanged,
|
"unchanged": unchanged,
|
||||||
"duplicate": duplicate,
|
"duplicate": 0,
|
||||||
"match_key_fields": match_keys,
|
"match_key_fields": match_keys,
|
||||||
"duplicate_keys_before": before_dup,
|
"duplicate_keys_before": len(multi_before_keys),
|
||||||
"duplicate_keys_after": after_dup,
|
"duplicate_keys_after": len(multi_after_keys),
|
||||||
"duplicate_key_list": _fmt_keys(before_dup_keys + after_dup_keys),
|
"duplicate_key_list": _fmt_keys(multi_before_keys + multi_after_keys),
|
||||||
"unchanged_listed": unchanged_listed,
|
"unchanged_listed": unchanged_listed,
|
||||||
"unchanged_truncated": bool(
|
"unchanged_truncated": bool(
|
||||||
include_unchanged and limit_n is not None and unchanged > unchanged_listed
|
include_unchanged and limit_n is not None and unchanged > unchanged_listed
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,10 @@ def _compare_side(
|
||||||
_DIFF_CHUNK = 2000
|
_DIFF_CHUNK = 2000
|
||||||
_LOAD_YIELD_PER = 5000
|
_LOAD_YIELD_PER = 5000
|
||||||
_SEARCH_TEXT_MAX = 4000
|
_SEARCH_TEXT_MAX = 4000
|
||||||
|
# Heartbeat while bulk-inserting large fail/ok diff sets (vpnv4-scale).
|
||||||
|
_PERSIST_PROGRESS_EVERY = 10_000
|
||||||
|
# Above this, store fail diffs as key + row_id + changes (hydrate sides on read).
|
||||||
|
_FAIL_COMPACT_MIN = 50_000
|
||||||
# Success-row persist policy (see resolve_unchanged_policy)
|
# Success-row persist policy (see resolve_unchanged_policy)
|
||||||
_STORE_UNCHANGED_MODES = frozenset({"auto", "always", "never", "sample", "keys"})
|
_STORE_UNCHANGED_MODES = frozenset({"auto", "always", "never", "sample", "keys"})
|
||||||
_UNCHANGED_FULL_MAX = 20_000
|
_UNCHANGED_FULL_MAX = 20_000
|
||||||
|
|
@ -273,15 +277,41 @@ def _persist_sheet_diffs(
|
||||||
metric_id: str,
|
metric_id: str,
|
||||||
diffs: list[dict[str, Any]],
|
diffs: list[dict[str, Any]],
|
||||||
seq_start: int = 0,
|
seq_start: int = 0,
|
||||||
|
on_progress: Callable[[int, int], None] | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Bulk-insert diff rows already selected by the engine policy.
|
"""Bulk-insert diff rows already selected by the engine policy.
|
||||||
|
|
||||||
Compact success rows carry key + before/after_row_id; JSON sides stay empty
|
Compact success rows carry key + before/after_row_id; JSON sides stay empty
|
||||||
and are hydrated from metric tables on read.
|
and are hydrated from metric tables on read.
|
||||||
|
|
||||||
|
``on_progress(written, total)`` fires periodically so UI elapsed time moves
|
||||||
|
during multi-minute inserts (e.g. large vpnv4 fail sets).
|
||||||
"""
|
"""
|
||||||
buf: list[dict[str, Any]] = []
|
buf: list[dict[str, Any]] = []
|
||||||
seq = int(seq_start or 0)
|
seq = int(seq_start or 0)
|
||||||
written = 0
|
written = 0
|
||||||
|
total = len(diffs)
|
||||||
|
last_prog = 0
|
||||||
|
last_prog_t = time.monotonic()
|
||||||
|
|
||||||
|
def _maybe_prog(force: bool = False) -> None:
|
||||||
|
nonlocal last_prog, last_prog_t
|
||||||
|
if not on_progress:
|
||||||
|
return
|
||||||
|
now = time.monotonic()
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and written - last_prog < _PERSIST_PROGRESS_EVERY
|
||||||
|
and now - last_prog_t < 2.0
|
||||||
|
):
|
||||||
|
return
|
||||||
|
last_prog = written
|
||||||
|
last_prog_t = now
|
||||||
|
try:
|
||||||
|
on_progress(written, total)
|
||||||
|
except Exception:
|
||||||
|
_log.exception("persist progress callback failed run=%s metric=%s", run_id, metric_id)
|
||||||
|
|
||||||
for d in diffs:
|
for d in diffs:
|
||||||
kind = str(d.get("kind") or "")
|
kind = str(d.get("kind") or "")
|
||||||
before = _strip_netx(d.get("before"))
|
before = _strip_netx(d.get("before"))
|
||||||
|
|
@ -295,10 +325,14 @@ def _persist_sheet_diffs(
|
||||||
"mapped_before": mapped,
|
"mapped_before": mapped,
|
||||||
"changes": dict(d.get("changes") or {}),
|
"changes": dict(d.get("changes") or {}),
|
||||||
}
|
}
|
||||||
# Compact success: search_text = kind + key only (no fat sides)
|
# Compact rows: search_text = kind + key (+ changes) only — no fat sides
|
||||||
search_src = (
|
search_src = (
|
||||||
{"kind": kind, "key": payload["key"]}
|
{
|
||||||
if kind == "unchanged" and bool(d.get("compact"))
|
"kind": kind,
|
||||||
|
"key": payload["key"],
|
||||||
|
**({"changes": payload["changes"]} if payload["changes"] else {}),
|
||||||
|
}
|
||||||
|
if bool(d.get("compact"))
|
||||||
else payload
|
else payload
|
||||||
)
|
)
|
||||||
buf.append(
|
buf.append(
|
||||||
|
|
@ -323,8 +357,14 @@ def _persist_sheet_diffs(
|
||||||
if len(buf) >= _DIFF_CHUNK:
|
if len(buf) >= _DIFF_CHUNK:
|
||||||
db.bulk_insert_mappings(BizCompareDiff, buf)
|
db.bulk_insert_mappings(BizCompareDiff, buf)
|
||||||
buf.clear()
|
buf.clear()
|
||||||
|
# Commit chunks so progress/UI can see mid-write fail rows and
|
||||||
|
# elapsed_ms advances (otherwise persisting_* looks frozen).
|
||||||
|
db.commit()
|
||||||
|
_maybe_prog()
|
||||||
if buf:
|
if buf:
|
||||||
db.bulk_insert_mappings(BizCompareDiff, buf)
|
db.bulk_insert_mappings(BizCompareDiff, buf)
|
||||||
|
db.commit()
|
||||||
|
_maybe_prog(force=True)
|
||||||
return written
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2423,6 +2463,39 @@ def _execute_compare_into_run(db: Session, run_id: str) -> dict[str, Any]:
|
||||||
fail_diffs = [d for d in diffs if str(d.get("kind") or "") != "unchanged"]
|
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"]
|
ok_diffs = [d for d in diffs if str(d.get("kind") or "") == "unchanged"]
|
||||||
mid = sheet_key(one)
|
mid = sheet_key(one)
|
||||||
|
# Million-row vpnv4 with many diffs: keep key/row_id/changes only.
|
||||||
|
if len(fail_diffs) >= _FAIL_COMPACT_MIN:
|
||||||
|
for d in fail_diffs:
|
||||||
|
d["before"] = {}
|
||||||
|
d["after"] = {}
|
||||||
|
d["mapped_before"] = {}
|
||||||
|
d["compact"] = True
|
||||||
|
|
||||||
|
def _on_persist(
|
||||||
|
written: int,
|
||||||
|
total_n: int,
|
||||||
|
*,
|
||||||
|
phase: str,
|
||||||
|
kind_key: str,
|
||||||
|
) -> None:
|
||||||
|
_set_run_progress(
|
||||||
|
db,
|
||||||
|
run,
|
||||||
|
phase=phase,
|
||||||
|
sheet_index=idx,
|
||||||
|
sheet_total=total,
|
||||||
|
sheet=sheet,
|
||||||
|
started_mono=started_mono,
|
||||||
|
extra={
|
||||||
|
kind_key: total_n,
|
||||||
|
"persisted": written,
|
||||||
|
"persist_total": total_n,
|
||||||
|
},
|
||||||
|
# Separate session so chunk commits in persist do not race
|
||||||
|
# with progress JSON writes on the worker session.
|
||||||
|
detach=True,
|
||||||
|
)
|
||||||
|
|
||||||
_set_run_progress(
|
_set_run_progress(
|
||||||
db,
|
db,
|
||||||
run,
|
run,
|
||||||
|
|
@ -2431,12 +2504,22 @@ def _execute_compare_into_run(db: Session, run_id: str) -> dict[str, Any]:
|
||||||
sheet_total=total,
|
sheet_total=total,
|
||||||
sheet=sheet,
|
sheet=sheet,
|
||||||
started_mono=started_mono,
|
started_mono=started_mono,
|
||||||
extra={"fail_rows": len(fail_diffs)},
|
extra={
|
||||||
|
"fail_rows": len(fail_diffs),
|
||||||
|
"persisted": 0,
|
||||||
|
"persist_total": len(fail_diffs),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
n_fail = _persist_sheet_diffs(
|
n_fail = _persist_sheet_diffs(
|
||||||
db, run_id=run.id, metric_id=mid, diffs=fail_diffs, seq_start=0
|
db,
|
||||||
|
run_id=run.id,
|
||||||
|
metric_id=mid,
|
||||||
|
diffs=fail_diffs,
|
||||||
|
seq_start=0,
|
||||||
|
on_progress=lambda w, n: _on_persist(
|
||||||
|
w, n, phase="persisting_fail", kind_key="fail_rows"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
db.commit()
|
|
||||||
if ok_diffs:
|
if ok_diffs:
|
||||||
_set_run_progress(
|
_set_run_progress(
|
||||||
db,
|
db,
|
||||||
|
|
@ -2446,7 +2529,11 @@ def _execute_compare_into_run(db: Session, run_id: str) -> dict[str, Any]:
|
||||||
sheet_total=total,
|
sheet_total=total,
|
||||||
sheet=sheet,
|
sheet=sheet,
|
||||||
started_mono=started_mono,
|
started_mono=started_mono,
|
||||||
extra={"ok_rows": len(ok_diffs)},
|
extra={
|
||||||
|
"ok_rows": len(ok_diffs),
|
||||||
|
"persisted": 0,
|
||||||
|
"persist_total": len(ok_diffs),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
_persist_sheet_diffs(
|
_persist_sheet_diffs(
|
||||||
db,
|
db,
|
||||||
|
|
@ -2454,6 +2541,9 @@ def _execute_compare_into_run(db: Session, run_id: str) -> dict[str, Any]:
|
||||||
metric_id=mid,
|
metric_id=mid,
|
||||||
diffs=ok_diffs,
|
diffs=ok_diffs,
|
||||||
seq_start=n_fail,
|
seq_start=n_fail,
|
||||||
|
on_progress=lambda w, n: _on_persist(
|
||||||
|
w, n, phase="persisting_ok", kind_key="ok_rows"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for k in agg:
|
for k in agg:
|
||||||
agg[k] += int(s.get(k) or 0)
|
agg[k] += int(s.get(k) or 0)
|
||||||
|
|
|
||||||
|
|
@ -406,6 +406,9 @@ def run_sql_sheet_compare(
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Compare one sheet via PostgreSQL TEMP tables + FULL OUTER JOIN.
|
"""Compare one sheet via PostgreSQL TEMP tables + FULL OUTER JOIN.
|
||||||
|
|
||||||
|
Same-key multipath: rows get ``dup_rn`` by ``seq,id`` and join on
|
||||||
|
``(rk, dup_rn)`` (ordered zip). Extras are added/removed, not duplicate.
|
||||||
|
|
||||||
Returns the same ``{summary, diffs, mapping_stats}`` shape as ``compare_rows``.
|
Returns the same ``{summary, diffs, mapping_stats}`` shape as ``compare_rows``.
|
||||||
``on_progress(side, n, *, engine=\"sql\", note=..., phase=...)``.
|
``on_progress(side, n, *, engine=\"sql\", note=..., phase=...)``.
|
||||||
"""
|
"""
|
||||||
|
|
@ -503,9 +506,7 @@ def run_sql_sheet_compare(
|
||||||
),
|
),
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
db.execute(
|
db.execute(text(f"CREATE INDEX IF NOT EXISTS {tname}_rk ON {tname} (rk, dup_rn)"))
|
||||||
text(f"CREATE INDEX IF NOT EXISTS {tname}_rk ON {tname} (rk) WHERE dup_rn = 1")
|
|
||||||
)
|
|
||||||
|
|
||||||
before_n = int(
|
before_n = int(
|
||||||
db.execute(text(f"SELECT count(*) FROM {tb}")).scalar() or 0
|
db.execute(text(f"SELECT count(*) FROM {tb}")).scalar() or 0
|
||||||
|
|
@ -525,14 +526,14 @@ def run_sql_sheet_compare(
|
||||||
END
|
END
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Aggregate primary match kinds (first-wins keys only)
|
# Ordered same-key pairing: join on (rk, dup_rn) so 5 vs 2 → 2 compared + 3 removed
|
||||||
agg_rows = db.execute(
|
agg_rows = db.execute(
|
||||||
text(
|
text(
|
||||||
f"""
|
f"""
|
||||||
SELECT {kind_expr} AS kind, count(*)::bigint AS n
|
SELECT {kind_expr} AS kind, count(*)::bigint AS n
|
||||||
FROM (SELECT * FROM {tb} WHERE dup_rn = 1) b
|
FROM {tb} b
|
||||||
FULL OUTER JOIN (SELECT * FROM {ta} WHERE dup_rn = 1) a
|
FULL OUTER JOIN {ta} a
|
||||||
ON b.rk = a.rk
|
ON b.rk = a.rk AND b.dup_rn = a.dup_rn
|
||||||
GROUP BY 1
|
GROUP BY 1
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
@ -543,13 +544,19 @@ def run_sql_sheet_compare(
|
||||||
changed = counts.get("changed", 0)
|
changed = counts.get("changed", 0)
|
||||||
unchanged = counts.get("unchanged", 0)
|
unchanged = counts.get("unchanged", 0)
|
||||||
|
|
||||||
dup_b = int(
|
# Diagnostic: count of match keys with >1 row (not fail rows)
|
||||||
db.execute(text(f"SELECT count(*) FROM {tb} WHERE dup_rn > 1")).scalar() or 0
|
multi_b = int(
|
||||||
|
db.execute(
|
||||||
|
text(f"SELECT count(*) FROM (SELECT rk FROM {tb} GROUP BY rk HAVING count(*) > 1) t")
|
||||||
|
).scalar()
|
||||||
|
or 0
|
||||||
)
|
)
|
||||||
dup_a = int(
|
multi_a = int(
|
||||||
db.execute(text(f"SELECT count(*) FROM {ta} WHERE dup_rn > 1")).scalar() or 0
|
db.execute(
|
||||||
|
text(f"SELECT count(*) FROM (SELECT rk FROM {ta} GROUP BY rk HAVING count(*) > 1) t")
|
||||||
|
).scalar()
|
||||||
|
or 0
|
||||||
)
|
)
|
||||||
duplicate = dup_b + dup_a
|
|
||||||
|
|
||||||
# Lazy import — avoid circular import with compare_service
|
# Lazy import — avoid circular import with compare_service
|
||||||
from .compare_service import resolve_unchanged_policy
|
from .compare_service import resolve_unchanged_policy
|
||||||
|
|
@ -559,7 +566,7 @@ def run_sql_sheet_compare(
|
||||||
)
|
)
|
||||||
diffs: list[dict[str, Any]] = []
|
diffs: list[dict[str, Any]] = []
|
||||||
|
|
||||||
# Fail + duplicate rows (stream into Python — should be << million)
|
# Fail rows (stream into Python — should be << million)
|
||||||
fail_sql = text(
|
fail_sql = text(
|
||||||
f"""
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -569,12 +576,14 @@ def run_sql_sheet_compare(
|
||||||
b.data AS before_data,
|
b.data AS before_data,
|
||||||
a.data AS after_data,
|
a.data AS after_data,
|
||||||
COALESCE(b.rk, a.rk) AS rk
|
COALESCE(b.rk, a.rk) AS rk
|
||||||
FROM (SELECT * FROM {tb} WHERE dup_rn = 1) b
|
FROM {tb} b
|
||||||
FULL OUTER JOIN (SELECT * FROM {ta} WHERE dup_rn = 1) a
|
FULL OUTER JOIN {ta} a
|
||||||
ON b.rk = a.rk
|
ON b.rk = a.rk AND b.dup_rn = a.dup_rn
|
||||||
WHERE {kind_expr} IN ('added', 'removed', 'changed')
|
WHERE {kind_expr} IN ('added', 'removed', 'changed')
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
fail_n = 0
|
||||||
|
_prog("after", after_n, phase="sql_fail_fetch", note="fetch_fails")
|
||||||
for row in db.execute(fail_sql).mappings():
|
for row in db.execute(fail_sql).mappings():
|
||||||
kind = str(row["kind"] or "")
|
kind = str(row["kind"] or "")
|
||||||
before = dict(row["before_data"] or {}) if row["before_data"] is not None else None
|
before = dict(row["before_data"] or {}) if row["before_data"] is not None else None
|
||||||
|
|
@ -593,29 +602,11 @@ def run_sql_sheet_compare(
|
||||||
if kind == "changed":
|
if kind == "changed":
|
||||||
item["changes"] = _changes_from_rows(before, after, compare_fields, rules)
|
item["changes"] = _changes_from_rows(before, after, compare_fields, rules)
|
||||||
diffs.append(item)
|
diffs.append(item)
|
||||||
|
fail_n += 1
|
||||||
# Duplicate extras
|
if fail_n == 1 or fail_n % 25_000 == 0:
|
||||||
for side, tname in (("before", tb), ("after", ta)):
|
_prog("fail", fail_n, phase="sql_fail_fetch", note="fetch_fails")
|
||||||
q = text(
|
if fail_n:
|
||||||
f"""
|
_prog("fail", fail_n, phase="sql_fail_fetch", note="fetch_fails")
|
||||||
SELECT id, data, rk FROM {tname} WHERE dup_rn > 1
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
for row in db.execute(q).mappings():
|
|
||||||
data = dict(row["data"] or {})
|
|
||||||
diffs.append(
|
|
||||||
{
|
|
||||||
"kind": "duplicate",
|
|
||||||
"side": side,
|
|
||||||
"key": _key_obj_from_data(data, key_fields),
|
|
||||||
"before": data if side == "before" else None,
|
|
||||||
"after": data if side == "after" else None,
|
|
||||||
"mapped_before": data if side == "before" else None,
|
|
||||||
"changes": {},
|
|
||||||
"before_row_id": str(row["id"] or "") if side == "before" else "",
|
|
||||||
"after_row_id": str(row["id"] or "") if side == "after" else "",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
unchanged_listed = 0
|
unchanged_listed = 0
|
||||||
include_u = bool(policy.get("include"))
|
include_u = bool(policy.get("include"))
|
||||||
|
|
@ -634,9 +625,9 @@ def run_sql_sheet_compare(
|
||||||
a.id AS after_row_id,
|
a.id AS after_row_id,
|
||||||
b.data AS before_data,
|
b.data AS before_data,
|
||||||
a.data AS after_data
|
a.data AS after_data
|
||||||
FROM (SELECT * FROM {tb} WHERE dup_rn = 1) b
|
FROM {tb} b
|
||||||
INNER JOIN (SELECT * FROM {ta} WHERE dup_rn = 1) a
|
INNER JOIN {ta} a
|
||||||
ON b.rk = a.rk
|
ON b.rk = a.rk AND b.dup_rn = a.dup_rn
|
||||||
WHERE NOT ({changed_pred})
|
WHERE NOT ({changed_pred})
|
||||||
{lim_sql}
|
{lim_sql}
|
||||||
"""
|
"""
|
||||||
|
|
@ -677,7 +668,6 @@ def run_sql_sheet_compare(
|
||||||
db.execute(text(f"DROP TABLE IF EXISTS {tb}"))
|
db.execute(text(f"DROP TABLE IF EXISTS {tb}"))
|
||||||
db.execute(text(f"DROP TABLE IF EXISTS {ta}"))
|
db.execute(text(f"DROP TABLE IF EXISTS {ta}"))
|
||||||
|
|
||||||
dup_key_list: list[str] = []
|
|
||||||
summary = {
|
summary = {
|
||||||
"before_count": before_n,
|
"before_count": before_n,
|
||||||
"after_count": after_n,
|
"after_count": after_n,
|
||||||
|
|
@ -685,11 +675,11 @@ def run_sql_sheet_compare(
|
||||||
"removed": removed,
|
"removed": removed,
|
||||||
"changed": changed,
|
"changed": changed,
|
||||||
"unchanged": unchanged,
|
"unchanged": unchanged,
|
||||||
"duplicate": duplicate,
|
"duplicate": 0,
|
||||||
"match_key_fields": list(key_fields),
|
"match_key_fields": list(key_fields),
|
||||||
"duplicate_keys_before": dup_b,
|
"duplicate_keys_before": multi_b,
|
||||||
"duplicate_keys_after": dup_a,
|
"duplicate_keys_after": multi_a,
|
||||||
"duplicate_key_list": dup_key_list,
|
"duplicate_key_list": [],
|
||||||
"unchanged_listed": unchanged_listed,
|
"unchanged_listed": unchanged_listed,
|
||||||
"unchanged_truncated": bool(
|
"unchanged_truncated": bool(
|
||||||
include_u and limit_n is not None and unchanged > unchanged_listed
|
include_u and limit_n is not None and unchanged > unchanged_listed
|
||||||
|
|
|
||||||
|
|
@ -475,6 +475,13 @@ class CompareSheetDefaultsTests(unittest.TestCase):
|
||||||
for r in (route4.get("field_rules") or [])
|
for r in (route4.get("field_rules") or [])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
route_vpn = next(s for s in sheets if sheet_key(s) == "bgp_route.vpnv4")
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
f.get("field") == "afi" and f.get("value") == "vpnv4"
|
||||||
|
for f in (route_vpn.get("row_filters") or [])
|
||||||
|
)
|
||||||
|
)
|
||||||
vpnv4 = next(s for s in sheets if sheet_key(s) == "bgp_peer.vpnv4")
|
vpnv4 = next(s for s in sheets if sheet_key(s) == "bgp_peer.vpnv4")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
vpnv4["row_filters"],
|
vpnv4["row_filters"],
|
||||||
|
|
@ -483,7 +490,8 @@ class CompareSheetDefaultsTests(unittest.TestCase):
|
||||||
isis4 = next(s for s in sheets if sheet_key(s) == "isis_adjacency.ipv4")
|
isis4 = next(s for s in sheets if sheet_key(s) == "isis_adjacency.ipv4")
|
||||||
self.assertEqual(isis4["row_filters"][0]["op"], "contains")
|
self.assertEqual(isis4["row_filters"][0]["op"], "contains")
|
||||||
|
|
||||||
def test_duplicate_match_keys_are_reported(self) -> None:
|
def test_ordered_same_key_pairing_2v2(self) -> None:
|
||||||
|
"""Same key, two rows each: zip by order (not first-wins + duplicate)."""
|
||||||
before = [
|
before = [
|
||||||
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"},
|
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"},
|
||||||
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "9"},
|
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "9"},
|
||||||
|
|
@ -500,15 +508,47 @@ class CompareSheetDefaultsTests(unittest.TestCase):
|
||||||
compare_fields=["remote_ip"],
|
compare_fields=["remote_ip"],
|
||||||
port_map={"a": "a"},
|
port_map={"a": "a"},
|
||||||
)
|
)
|
||||||
|
self.assertEqual(out["summary"]["before_count"], 2)
|
||||||
|
self.assertEqual(out["summary"]["after_count"], 2)
|
||||||
|
self.assertEqual(out["summary"]["duplicate"], 0)
|
||||||
self.assertEqual(out["summary"]["duplicate_keys_before"], 1)
|
self.assertEqual(out["summary"]["duplicate_keys_before"], 1)
|
||||||
self.assertEqual(out["summary"]["duplicate_keys_after"], 1)
|
self.assertEqual(out["summary"]["duplicate_keys_after"], 1)
|
||||||
self.assertEqual(out["summary"]["duplicate"], 2)
|
|
||||||
self.assertIn("a|X|1", out["summary"]["duplicate_key_list"])
|
self.assertIn("a|X|1", out["summary"]["duplicate_key_list"])
|
||||||
kinds = [d["kind"] for d in out["diffs"]]
|
kinds = [d["kind"] for d in out["diffs"]]
|
||||||
self.assertEqual(kinds.count("duplicate"), 2)
|
self.assertNotIn("duplicate", kinds)
|
||||||
# First before wins → matches first after → unchanged (same remote_ip)
|
# Pair 0: 1==1 unchanged; pair 1: 9!=2 changed
|
||||||
self.assertEqual(out["summary"]["unchanged"], 1)
|
self.assertEqual(out["summary"]["unchanged"], 1)
|
||||||
|
self.assertEqual(out["summary"]["changed"], 1)
|
||||||
|
self.assertEqual(out["summary"]["added"], 0)
|
||||||
|
self.assertEqual(out["summary"]["removed"], 0)
|
||||||
|
|
||||||
|
def test_ordered_multipath_5_vs_2(self) -> None:
|
||||||
|
"""5 before / 2 after same key → 2 compared + 3 removed failures."""
|
||||||
|
key = {"local_if": "a", "remote_sys": "X", "remote_if": "1"}
|
||||||
|
before = [{**key, "remote_ip": str(i)} for i in range(5)]
|
||||||
|
after = [{**key, "remote_ip": "0"}, {**key, "remote_ip": "1"}]
|
||||||
|
out = compare_rows(
|
||||||
|
before_rows=before,
|
||||||
|
after_rows=after,
|
||||||
|
key_fields=["local_if", "remote_sys", "remote_if"],
|
||||||
|
iface_fields=["local_if"],
|
||||||
|
compare_fields=["remote_ip"],
|
||||||
|
port_map={"a": "a"},
|
||||||
|
)
|
||||||
|
self.assertEqual(out["summary"]["before_count"], 5)
|
||||||
|
self.assertEqual(out["summary"]["after_count"], 2)
|
||||||
|
self.assertEqual(out["summary"]["removed"], 3)
|
||||||
|
self.assertEqual(out["summary"]["added"], 0)
|
||||||
|
self.assertEqual(
|
||||||
|
out["summary"]["unchanged"] + out["summary"]["changed"],
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
self.assertEqual(out["summary"]["unchanged"], 2)
|
||||||
self.assertEqual(out["summary"]["changed"], 0)
|
self.assertEqual(out["summary"]["changed"], 0)
|
||||||
|
self.assertEqual(out["summary"]["duplicate"], 0)
|
||||||
|
kinds = [d["kind"] for d in out["diffs"]]
|
||||||
|
self.assertEqual(kinds.count("removed"), 3)
|
||||||
|
self.assertNotIn("duplicate", kinds)
|
||||||
|
|
||||||
def test_ignore_port_changes_false_keeps_iface(self) -> None:
|
def test_ignore_port_changes_false_keeps_iface(self) -> None:
|
||||||
before = [
|
before = [
|
||||||
|
|
|
||||||
|
|
@ -502,6 +502,7 @@ const en = {
|
||||||
runProgress: "{{phase}} · sheet {{i}}/{{n}} · {{sheet}} · {{s}}s elapsed",
|
runProgress: "{{phase}} · sheet {{i}}/{{n}} · {{sheet}} · {{s}}s elapsed",
|
||||||
runRowsLoaded: "Loaded {{side}} {{n}} rows",
|
runRowsLoaded: "Loaded {{side}} {{n}} rows",
|
||||||
runRowsSqlCount: "DB count {{side}} {{n}} rows",
|
runRowsSqlCount: "DB count {{side}} {{n}} rows",
|
||||||
|
runPersisting: "Wrote {{done}}/{{total}} rows",
|
||||||
runEngineSql: "engine SQL",
|
runEngineSql: "engine SQL",
|
||||||
runEnginePython: "engine Python",
|
runEnginePython: "engine Python",
|
||||||
runEngineNote: "reason {{note}}",
|
runEngineNote: "reason {{note}}",
|
||||||
|
|
|
||||||
|
|
@ -501,6 +501,7 @@ const zh = {
|
||||||
runProgress: "{{phase}} · 表 {{i}}/{{n}} · {{sheet}} · 已用 {{s}}s",
|
runProgress: "{{phase}} · 表 {{i}}/{{n}} · {{sheet}} · 已用 {{s}}s",
|
||||||
runRowsLoaded: "已加载 {{side}} {{n}} 行",
|
runRowsLoaded: "已加载 {{side}} {{n}} 行",
|
||||||
runRowsSqlCount: "库内统计 {{side}} {{n}} 行",
|
runRowsSqlCount: "库内统计 {{side}} {{n}} 行",
|
||||||
|
runPersisting: "已写入 {{done}}/{{total}} 行",
|
||||||
runEngineSql: "引擎 SQL",
|
runEngineSql: "引擎 SQL",
|
||||||
runEnginePython: "引擎 Python",
|
runEnginePython: "引擎 Python",
|
||||||
runEngineNote: "原因 {{note}}",
|
runEngineNote: "原因 {{note}}",
|
||||||
|
|
|
||||||
|
|
@ -1895,6 +1895,10 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
rows_loaded?: number;
|
rows_loaded?: number;
|
||||||
engine?: string;
|
engine?: string;
|
||||||
engine_note?: string;
|
engine_note?: string;
|
||||||
|
persisted?: number;
|
||||||
|
persist_total?: number;
|
||||||
|
fail_rows?: number;
|
||||||
|
ok_rows?: number;
|
||||||
};
|
};
|
||||||
const runEngine = String(runProgress.engine || "").toLowerCase();
|
const runEngine = String(runProgress.engine || "").toLowerCase();
|
||||||
|
|
||||||
|
|
@ -3292,6 +3296,15 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{String(runProgress.phase || "").startsWith("persisting") &&
|
||||||
|
Number(runProgress.persist_total || 0) > 0 ? (
|
||||||
|
<span className="muted">
|
||||||
|
{t("bizCompare.runPersisting", {
|
||||||
|
done: String(runProgress.persisted || 0),
|
||||||
|
total: String(runProgress.persist_total || 0),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{runDetail.message ? (
|
{runDetail.message ? (
|
||||||
<span className="muted bs-cmp-progress__msg">{String(runDetail.message)}</span>
|
<span className="muted bs-cmp-progress__msg">{String(runDetail.message)}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue