"""Revision retention. Dedup already keeps growth low - a revision appears only when a setting really changed - so retention exists for the pathological case, not the normal one: a value that flaps would otherwise append a revision every collection cycle forever. Two independent limits, both off by default at 0: retentioncount keep at most N revisions per chain retentiondays additionally drop anything older than N days A CHAIN is one asset, one kind, one source hostname. Several PCs can share a machine number, and each keeps its own revision history against that machine; pruning per asset would let a busy marker's revisions push a quiet marker's only backup out of the table. The NEWEST and the OLDEST revision of each chain are never pruned. The newest is the one a tech restores from; the oldest is the earliest known-good baseline, which is usually the most valuable row in the table and the one a naive "keep last N" would delete first. """ from datetime import datetime, timedelta from shopdb.api import db from ..models import BackupRevision def samesource(sourcehostname): """Filter matching one source's chain, treating NULL as its own source. `sourcehostname == None` never matches in SQL, so a NULL-sourced row - every row written before the column was populated, and any hand-loaded one - needs IS NULL. Without this those rows form no chain at all and re-post forever. """ if sourcehostname is None or sourcehostname == '': return BackupRevision.sourcehostname.is_(None) return BackupRevision.sourcehostname == sourcehostname def sourcesfor(assetid, backupkind): """Every distinct source hostname with a revision for this asset+kind.""" rows = (db.session.query(BackupRevision.sourcehostname) .filter(BackupRevision.assetid == assetid, BackupRevision.backupkind == backupkind) .distinct() .all()) return [row[0] for row in rows] def prune(assetid, backupkind, retentioncount=0, retentiondays=0): """Delete surplus revisions for one asset+kind. Returns the number removed. Prunes each source's chain independently. Caller commits. Returns 0 when both limits are disabled. """ if int(retentioncount or 0) <= 0 and int(retentiondays or 0) <= 0: return 0 return sum(prunechain(assetid, backupkind, source, retentioncount=retentioncount, retentiondays=retentiondays) for source in sourcesfor(assetid, backupkind)) def prunechain(assetid, backupkind, sourcehostname, retentioncount=0, retentiondays=0): """Delete surplus revisions for one asset+kind+source chain.""" retentioncount = int(retentioncount or 0) retentiondays = int(retentiondays or 0) if retentioncount <= 0 and retentiondays <= 0: return 0 revisions = (db.session.query(BackupRevision) .filter(BackupRevision.assetid == assetid, BackupRevision.backupkind == backupkind, samesource(sourcehostname)) .order_by(BackupRevision.backuprevisionid.desc()) .all()) if len(revisions) <= 2: return 0 # newest + oldest are both protected newest = revisions[0] oldest = revisions[-1] protected = {newest.backuprevisionid, oldest.backuprevisionid} doomed = [] if retentioncount > 0 and len(revisions) > retentioncount: # Walk from the oldest END of the middle, so the surplus dropped is # always the least recent, never the newest. for revision in revisions[retentioncount:]: if revision.backuprevisionid not in protected: doomed.append(revision) if retentiondays > 0: cutoff = datetime.utcnow() - timedelta(days=retentiondays) for revision in revisions: if revision.backuprevisionid in protected: continue stamp = revision.collectedat or revision.createdat if stamp and stamp < cutoff and revision not in doomed: doomed.append(revision) for revision in doomed: db.session.delete(revision) return len(doomed)