Add config-sync cycle retention alongside config history keep.

Mirror LLDP job retention so finished sync cycles are pruned by policy while keeping per-NE config version history separate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
hansjone 2026-08-02 03:44:35 +00:00
parent 53ddb3b520
commit a6c7267b47
10 changed files with 162 additions and 7 deletions

View file

@ -8,7 +8,14 @@ from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .config_sync_runner import dispatch_cycle from .config_sync_runner import dispatch_cycle
from .config_sync_service import finalize_cycle, sync_cycle_progress from .config_sync_service import (
DEFAULT_CYCLE_KEEP,
_cycle_keep_value,
ensure_policy,
finalize_cycle,
prune_config_sync_cycles,
sync_cycle_progress,
)
from .models import ConfigSyncCycle, ConfigSyncTask from .models import ConfigSyncCycle, ConfigSyncTask
_log = logging.getLogger("netx.config_sync.recovery") _log = logging.getLogger("netx.config_sync.recovery")
@ -57,7 +64,18 @@ def recover_config_sync_on_startup(db: Session) -> int:
.all() .all()
) )
cycles = sorted(cycles, key=lambda c: c.created_at or datetime.min) cycles = sorted(cycles, key=lambda c: c.created_at or datetime.min)
def _prune() -> None:
try:
keep = _cycle_keep_value(ensure_policy(db))
except Exception:
keep = DEFAULT_CYCLE_KEEP
try:
prune_config_sync_cycles(db, keep=keep)
except Exception:
_log.exception("config_sync recovery prune failed")
if not cycles: if not cycles:
_prune()
return 0 return 0
# Single-flight hygiene: keep newest, close older interrupted cycles. # Single-flight hygiene: keep newest, close older interrupted cycles.
@ -69,6 +87,7 @@ def recover_config_sync_on_startup(db: Session) -> int:
primary.id, primary.id,
) )
_close_cycle(db, stale, reason="superseded_active_cycle") _close_cycle(db, stale, reason="superseded_active_cycle")
_prune()
cycle_id = str(primary.id) cycle_id = str(primary.id)
orphans = ( orphans = (

View file

@ -20,6 +20,7 @@ class ConfigSyncPolicyOut(BaseModel):
scope_mode: str scope_mode: str
selected_targets: list[ConfigSyncTargetRef] = Field(default_factory=list) selected_targets: list[ConfigSyncTargetRef] = Field(default_factory=list)
history_keep: int history_keep: int
cycle_keep: int = 30
updated_at: datetime | None = None updated_at: datetime | None = None
@ -30,6 +31,7 @@ class ConfigSyncPolicyUpdate(BaseModel):
scope_mode: Literal["all", "selected"] | None = None scope_mode: Literal["all", "selected"] | None = None
selected_targets: list[ConfigSyncTargetRef] | None = None selected_targets: list[ConfigSyncTargetRef] | None = None
history_keep: int | None = Field(default=None, ge=0, le=30) history_keep: int | None = Field(default=None, ge=0, le=30)
cycle_keep: int | None = Field(default=None, ge=0, le=200)
class ConfigSyncCycleCreate(BaseModel): class ConfigSyncCycleCreate(BaseModel):

View file

@ -47,6 +47,9 @@ def _utcnow() -> datetime:
return datetime.utcnow() return datetime.utcnow()
DEFAULT_CYCLE_KEEP = 30
def ensure_policy(db: Session) -> ConfigSyncPolicy: def ensure_policy(db: Session) -> ConfigSyncPolicy:
row = db.get(ConfigSyncPolicy, POLICY_ID) row = db.get(ConfigSyncPolicy, POLICY_ID)
if row is None: if row is None:
@ -57,6 +60,35 @@ def ensure_policy(db: Session) -> ConfigSyncPolicy:
return row return row
def prune_config_sync_cycles(db: Session, *, keep: int = DEFAULT_CYCLE_KEEP) -> int:
"""Delete finished cycles beyond ``keep`` (newest kept). Active cycles always retained."""
keep = max(0, min(200, int(keep)))
finished = (
db.query(ConfigSyncCycle)
.filter(ConfigSyncCycle.status.in_(("success", "fail", "cancelled")))
.order_by(ConfigSyncCycle.created_at.desc())
.all()
)
to_drop = finished if keep == 0 else finished[keep:]
if not to_drop:
return 0
dropped = 0
for cycle in to_drop:
cid = str(cycle.id)
db.query(ConfigSyncTask).filter(ConfigSyncTask.cycle_id == cid).delete(
synchronize_session=False
)
db.delete(cycle)
dropped += 1
if dropped:
db.commit()
return dropped
def _cycle_keep_value(row: ConfigSyncPolicy) -> int:
return max(0, min(200, int(getattr(row, "cycle_keep", None) or DEFAULT_CYCLE_KEEP)))
def _targets_from_json(raw: Any) -> list[ConfigSyncTargetRef]: def _targets_from_json(raw: Any) -> list[ConfigSyncTargetRef]:
items: list[ConfigSyncTargetRef] = [] items: list[ConfigSyncTargetRef] = []
if not isinstance(raw, list): if not isinstance(raw, list):
@ -80,6 +112,7 @@ def policy_to_out(row: ConfigSyncPolicy) -> ConfigSyncPolicyOut:
scope_mode=str(row.scope_mode or "all"), scope_mode=str(row.scope_mode or "all"),
selected_targets=_targets_from_json(row.selected_targets), selected_targets=_targets_from_json(row.selected_targets),
history_keep=max(0, min(30, int(row.history_keep if row.history_keep is not None else 3))), history_keep=max(0, min(30, int(row.history_keep if row.history_keep is not None else 3))),
cycle_keep=_cycle_keep_value(row),
updated_at=row.updated_at, updated_at=row.updated_at,
) )
@ -107,9 +140,12 @@ def update_policy(db: Session, body: ConfigSyncPolicyUpdate) -> ConfigSyncPolicy
] ]
if "history_keep" in data and data["history_keep"] is not None: if "history_keep" in data and data["history_keep"] is not None:
row.history_keep = max(0, min(30, int(data["history_keep"]))) row.history_keep = max(0, min(30, int(data["history_keep"])))
if "cycle_keep" in data and data["cycle_keep"] is not None:
row.cycle_keep = max(0, min(200, int(data["cycle_keep"])))
row.updated_at = _utcnow() row.updated_at = _utcnow()
db.commit() db.commit()
db.refresh(row) db.refresh(row)
prune_config_sync_cycles(db, keep=_cycle_keep_value(row))
return policy_to_out(row) return policy_to_out(row)
@ -443,7 +479,12 @@ def stop_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut:
_release_pool(cycle_id) _release_pool(cycle_id)
except Exception: except Exception:
pass pass
return cycle_to_out(row) out = cycle_to_out(row)
try:
prune_config_sync_cycles(db, keep=_cycle_keep_value(ensure_policy(db)))
except Exception:
_log.exception("prune_config_sync_cycles after stop failed")
return out
def dashboard(db: Session) -> ConfigSyncDashboardOut: def dashboard(db: Session) -> ConfigSyncDashboardOut:
@ -690,3 +731,7 @@ def finalize_cycle(db: Session, cycle_id: str) -> None:
cycle.error_message = "" cycle.error_message = ""
cycle.ended_at = _utcnow() cycle.ended_at = _utcnow()
db.commit() db.commit()
try:
prune_config_sync_cycles(db, keep=_cycle_keep_value(ensure_policy(db)))
except Exception:
_log.exception("prune_config_sync_cycles after finish failed")

View file

@ -829,6 +829,9 @@ def on_startup() -> None:
) )
except Exception: except Exception:
pass pass
conn.exec_driver_sql(
"ALTER TABLE config_sync_policy ADD COLUMN IF NOT EXISTS cycle_keep INTEGER DEFAULT 30"
)
except Exception: except Exception:
_schedule_log.exception("startup: auth/port_traffic/topology schema migration failed") _schedule_log.exception("startup: auth/port_traffic/topology schema migration failed")
_reset_runtime_pause_flags() _reset_runtime_pause_flags()

View file

@ -693,6 +693,8 @@ class ConfigSyncPolicy(Base):
scope_mode: Mapped[str] = mapped_column(String(32), default="all") # all | selected scope_mode: Mapped[str] = mapped_column(String(32), default="all") # all | selected
selected_targets: Mapped[list] = mapped_column(_JsonType, default=list) selected_targets: Mapped[list] = mapped_column(_JsonType, default=list)
history_keep: Mapped[int] = mapped_column(Integer, default=3) history_keep: Mapped[int] = mapped_column(Integer, default=3)
# Finished sync cycles to retain (newest kept); active cycles always kept.
cycle_keep: Mapped[int] = mapped_column(Integer, default=30)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View file

@ -3,13 +3,17 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from uuid import uuid4
from netx_api.config_sync_codec import compress_text, decompress_text from netx_api.config_sync_codec import compress_text, decompress_text
from netx_api.config_sync_commands import command_list, commands_for_vendor, normalize_vendor_key from netx_api.config_sync_commands import command_list, commands_for_vendor, normalize_vendor_key
from netx_api.config_sync_recovery import recover_config_sync_on_startup from netx_api.config_sync_recovery import recover_config_sync_on_startup
from netx_api.config_sync_runner import _claim_task, _save_success_snapshot from netx_api.config_sync_runner import _claim_task, _save_success_snapshot
from netx_api.config_sync_service import prune_config_sync_cycles
from netx_api.db import Base, SessionLocal, engine
from netx_api.models import ConfigSyncCycle, ConfigSyncTask
class ConfigSyncCommandsTests(unittest.TestCase): class ConfigSyncCommandsTests(unittest.TestCase):
@ -208,10 +212,11 @@ class ConfigSyncClaimTests(unittest.TestCase):
class ConfigSyncRecoveryTests(unittest.TestCase): class ConfigSyncRecoveryTests(unittest.TestCase):
@patch("netx_api.config_sync_recovery.prune_config_sync_cycles")
@patch("netx_api.config_sync_recovery.dispatch_cycle", return_value=2) @patch("netx_api.config_sync_recovery.dispatch_cycle", return_value=2)
@patch("netx_api.config_sync_recovery.finalize_cycle") @patch("netx_api.config_sync_recovery.finalize_cycle")
@patch("netx_api.config_sync_recovery.sync_cycle_progress") @patch("netx_api.config_sync_recovery.sync_cycle_progress")
def test_requeues_orphans_and_resumes(self, _sync, _fin, dispatch): def test_requeues_orphans_and_resumes(self, _sync, _fin, dispatch, _prune):
cycle = MagicMock() cycle = MagicMock()
cycle.id = "c1" cycle.id = "c1"
cycle.status = "running" cycle.status = "running"
@ -250,10 +255,11 @@ class ConfigSyncRecoveryTests(unittest.TestCase):
self.assertEqual(resumed, 2) self.assertEqual(resumed, 2)
dispatch.assert_called_once_with("c1") dispatch.assert_called_once_with("c1")
@patch("netx_api.config_sync_recovery.prune_config_sync_cycles")
@patch("netx_api.config_sync_recovery.dispatch_cycle") @patch("netx_api.config_sync_recovery.dispatch_cycle")
@patch("netx_api.config_sync_recovery.finalize_cycle") @patch("netx_api.config_sync_recovery.finalize_cycle")
@patch("netx_api.config_sync_recovery.sync_cycle_progress") @patch("netx_api.config_sync_recovery.sync_cycle_progress")
def test_closes_older_active_keeps_newest(self, _sync, _fin, dispatch): def test_closes_older_active_keeps_newest(self, _sync, _fin, dispatch, _prune):
old = MagicMock() old = MagicMock()
old.id = "old" old.id = "old"
old.status = "running" old.status = "running"
@ -287,5 +293,66 @@ class ConfigSyncRecoveryTests(unittest.TestCase):
dispatch.assert_not_called() dispatch.assert_not_called()
class ConfigSyncCyclePruneTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
try:
conn.exec_driver_sql(
"ALTER TABLE config_sync_policy ADD COLUMN IF NOT EXISTS cycle_keep INTEGER DEFAULT 30"
)
except Exception:
pass
def setUp(self) -> None:
self.db = SessionLocal()
self.db.query(ConfigSyncTask).delete()
self.db.query(ConfigSyncCycle).delete()
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def test_prune_cycles_keeps_newest_and_active(self) -> None:
now = datetime.utcnow()
for i in range(5):
cid = uuid4().hex
self.db.add(
ConfigSyncCycle(
id=cid,
trigger_mode="manual",
status="success",
created_at=now - timedelta(minutes=5 - i),
ended_at=now - timedelta(minutes=5 - i),
)
)
self.db.add(
ConfigSyncTask(
id=uuid4().hex,
cycle_id=cid,
source="managed",
target_id=f"ne-{i}",
status="success",
)
)
open_id = uuid4().hex
self.db.add(
ConfigSyncCycle(
id=open_id,
trigger_mode="manual",
status="running",
created_at=now,
)
)
self.db.commit()
dropped = prune_config_sync_cycles(self.db, keep=2)
self.assertEqual(dropped, 3)
left = {r.id for r in self.db.query(ConfigSyncCycle).all()}
self.assertIn(open_id, left)
self.assertEqual(len(left), 3)
self.assertEqual(self.db.query(ConfigSyncTask).count(), 2)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -304,7 +304,8 @@ const en = {
enabled: "Enable scheduled sync", enabled: "Enable scheduled sync",
intervalDays: "Interval (days)", intervalDays: "Interval (days)",
concurrency: "Concurrency", concurrency: "Concurrency",
historyKeep: "History keep", historyKeep: "Config versions to keep",
cycleKeep: "Jobs to keep",
scope: "Scope", scope: "Scope",
scopeAll: "All NEs", scopeAll: "All NEs",
scopeSelected: "Selected NEs", scopeSelected: "Selected NEs",

View file

@ -304,7 +304,8 @@ const zh = {
enabled: "启用周期调度", enabled: "启用周期调度",
intervalDays: "周期(天)", intervalDays: "周期(天)",
concurrency: "并发", concurrency: "并发",
historyKeep: "历史保留", historyKeep: "配置历史保留",
cycleKeep: "任务保留数",
scope: "范围", scope: "范围",
scopeAll: "全部网元", scopeAll: "全部网元",
scopeSelected: "指定网元", scopeSelected: "指定网元",

View file

@ -37,6 +37,7 @@ export function ConfigSyncPage() {
const [concurrency, setConcurrency] = useState(5); const [concurrency, setConcurrency] = useState(5);
const [scopeMode, setScopeMode] = useState<"all" | "selected">("all"); const [scopeMode, setScopeMode] = useState<"all" | "selected">("all");
const [historyKeep, setHistoryKeep] = useState(3); const [historyKeep, setHistoryKeep] = useState(3);
const [cycleKeep, setCycleKeep] = useState(30);
const [selectedMap, setSelectedMap] = useState<Record<string, ConfigSyncTargetRef>>({}); const [selectedMap, setSelectedMap] = useState<Record<string, ConfigSyncTargetRef>>({});
const [policyHydrated, setPolicyHydrated] = useState(false); const [policyHydrated, setPolicyHydrated] = useState(false);
@ -63,6 +64,7 @@ export function ConfigSyncPage() {
setConcurrency(Number(p.concurrency || 5)); setConcurrency(Number(p.concurrency || 5));
setScopeMode(p.scope_mode === "selected" ? "selected" : "all"); setScopeMode(p.scope_mode === "selected" ? "selected" : "all");
setHistoryKeep(Number(p.history_keep ?? 3)); setHistoryKeep(Number(p.history_keep ?? 3));
setCycleKeep(Math.max(0, Math.min(200, Number(p.cycle_keep ?? 30))));
const map: Record<string, ConfigSyncTargetRef> = {}; const map: Record<string, ConfigSyncTargetRef> = {};
for (const ref of p.selected_targets || []) { for (const ref of p.selected_targets || []) {
map[`${ref.source}:${ref.id}`] = { source: ref.source, id: ref.id }; map[`${ref.source}:${ref.id}`] = { source: ref.source, id: ref.id };
@ -117,6 +119,7 @@ export function ConfigSyncPage() {
concurrency, concurrency,
scope_mode: scopeMode, scope_mode: scopeMode,
history_keep: historyKeep, history_keep: historyKeep,
cycle_keep: cycleKeep,
selected_targets: Object.values(selectedMap), selected_targets: Object.values(selectedMap),
}), }),
onSuccess: async (saved) => { onSuccess: async (saved) => {
@ -127,6 +130,7 @@ export function ConfigSyncPage() {
setConcurrency(Number(saved.concurrency || 5)); setConcurrency(Number(saved.concurrency || 5));
setScopeMode(saved.scope_mode === "selected" ? "selected" : "all"); setScopeMode(saved.scope_mode === "selected" ? "selected" : "all");
setHistoryKeep(Number(saved.history_keep ?? 3)); setHistoryKeep(Number(saved.history_keep ?? 3));
setCycleKeep(Math.max(0, Math.min(200, Number(saved.cycle_keep ?? 30))));
const map: Record<string, ConfigSyncTargetRef> = {}; const map: Record<string, ConfigSyncTargetRef> = {};
for (const ref of saved.selected_targets || []) { for (const ref of saved.selected_targets || []) {
map[`${ref.source}:${ref.id}`] = { source: ref.source, id: ref.id }; map[`${ref.source}:${ref.id}`] = { source: ref.source, id: ref.id };
@ -313,6 +317,16 @@ export function ConfigSyncPage() {
onChange={(e) => setHistoryKeep(Math.max(0, Math.min(30, Number(e.target.value) || 0)))} onChange={(e) => setHistoryKeep(Math.max(0, Math.min(30, Number(e.target.value) || 0)))}
/> />
</label> </label>
<label className="config-sync-policy-field">
<span>{t("configSync.cycleKeep")}</span>
<input
type="number"
min={0}
max={200}
value={cycleKeep}
onChange={(e) => setCycleKeep(Math.max(0, Math.min(200, Number(e.target.value) || 0)))}
/>
</label>
<label className="config-sync-policy-field"> <label className="config-sync-policy-field">
<span>{t("configSync.scope")}</span> <span>{t("configSync.scope")}</span>
<select <select

View file

@ -670,6 +670,7 @@ export type ConfigSyncPolicy = {
scope_mode: string; scope_mode: string;
selected_targets: ConfigSyncTargetRef[]; selected_targets: ConfigSyncTargetRef[];
history_keep: number; history_keep: number;
cycle_keep: number;
updated_at?: string | null; updated_at?: string | null;
}; };