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 .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
_log = logging.getLogger("netx.config_sync.recovery")
@ -57,7 +64,18 @@ def recover_config_sync_on_startup(db: Session) -> int:
.all()
)
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:
_prune()
return 0
# Single-flight hygiene: keep newest, close older interrupted cycles.
@ -69,6 +87,7 @@ def recover_config_sync_on_startup(db: Session) -> int:
primary.id,
)
_close_cycle(db, stale, reason="superseded_active_cycle")
_prune()
cycle_id = str(primary.id)
orphans = (

View file

@ -20,6 +20,7 @@ class ConfigSyncPolicyOut(BaseModel):
scope_mode: str
selected_targets: list[ConfigSyncTargetRef] = Field(default_factory=list)
history_keep: int
cycle_keep: int = 30
updated_at: datetime | None = None
@ -30,6 +31,7 @@ class ConfigSyncPolicyUpdate(BaseModel):
scope_mode: Literal["all", "selected"] | None = None
selected_targets: list[ConfigSyncTargetRef] | None = None
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):

View file

@ -47,6 +47,9 @@ def _utcnow() -> datetime:
return datetime.utcnow()
DEFAULT_CYCLE_KEEP = 30
def ensure_policy(db: Session) -> ConfigSyncPolicy:
row = db.get(ConfigSyncPolicy, POLICY_ID)
if row is None:
@ -57,6 +60,35 @@ def ensure_policy(db: Session) -> ConfigSyncPolicy:
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]:
items: list[ConfigSyncTargetRef] = []
if not isinstance(raw, list):
@ -80,6 +112,7 @@ def policy_to_out(row: ConfigSyncPolicy) -> ConfigSyncPolicyOut:
scope_mode=str(row.scope_mode or "all"),
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))),
cycle_keep=_cycle_keep_value(row),
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:
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()
db.commit()
db.refresh(row)
prune_config_sync_cycles(db, keep=_cycle_keep_value(row))
return policy_to_out(row)
@ -443,7 +479,12 @@ def stop_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut:
_release_pool(cycle_id)
except Exception:
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:
@ -690,3 +731,7 @@ def finalize_cycle(db: Session, cycle_id: str) -> None:
cycle.error_message = ""
cycle.ended_at = _utcnow()
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:
pass
conn.exec_driver_sql(
"ALTER TABLE config_sync_policy ADD COLUMN IF NOT EXISTS cycle_keep INTEGER DEFAULT 30"
)
except Exception:
_schedule_log.exception("startup: auth/port_traffic/topology schema migration failed")
_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
selected_targets: Mapped[list] = mapped_column(_JsonType, default=list)
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)