fix(retention): add cleanup sweeps + CalDAV orphan reconciliation
Drift-audit Group 4 (retention / unbounded growth): - CalDAV pull now reconciles deletions: a previously-synced event whose caldav_uid no longer appears remotely within the synced window is soft-deleted (one batch_id per run, restorable), so a remote delete propagates locally instead of orphaning forever. Guarded on a non-empty fetch so a spurious empty result can't wipe every local copy. Also wrap the blocking fetch in a 120s wait_for and log run duration. - Notifications: hourly loop now purges read notifications older than 30d (unread kept). Table no longer grows without bound. - Auth tokens: new daily sweep deletes password-reset / invitation tokens whose validity window ended >7d ago; wired via start_auth_token_retention_loop in app startup. Both tables previously only flipped used=True, never pruned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -356,3 +356,46 @@ async def revoke_invitation(invitation_id: int) -> bool:
|
||||
await session.commit()
|
||||
logger.info("Invitation %d revoked", invitation_id)
|
||||
return True
|
||||
|
||||
|
||||
# ── Token retention ─────────────────────────────────────────────────────
|
||||
# Password-reset and invitation tokens are only ever flipped used=True and are
|
||||
# never pruned, so on a long-lived instance both tables grow without bound.
|
||||
# A daily sweep deletes any token whose validity window ended over grace_days
|
||||
# ago (covers both used and naturally-expired rows once they're cold).
|
||||
|
||||
_auth_retention_task = None
|
||||
|
||||
|
||||
async def purge_expired_auth_tokens(grace_days: int = 7) -> int:
|
||||
"""Delete password-reset / invitation tokens that expired > grace_days ago."""
|
||||
from sqlalchemy import delete
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=grace_days)
|
||||
removed = 0
|
||||
async with async_session() as session:
|
||||
for model in (PasswordResetToken, InvitationToken):
|
||||
result = await session.execute(
|
||||
delete(model).where(model.expires_at < cutoff)
|
||||
)
|
||||
removed += result.rowcount or 0
|
||||
await session.commit()
|
||||
return removed
|
||||
|
||||
|
||||
async def _auth_token_retention_loop() -> None:
|
||||
import asyncio
|
||||
while True:
|
||||
await asyncio.sleep(86400) # daily
|
||||
try:
|
||||
removed = await purge_expired_auth_tokens()
|
||||
if removed:
|
||||
logger.info("Auth token retention: deleted %d expired token(s)", removed)
|
||||
except Exception:
|
||||
logger.exception("Error in auth token retention cleanup")
|
||||
|
||||
|
||||
def start_auth_token_retention_loop() -> None:
|
||||
global _auth_retention_task
|
||||
import asyncio
|
||||
if _auth_retention_task is None or _auth_retention_task.done():
|
||||
_auth_retention_task = asyncio.create_task(_auth_token_retention_loop())
|
||||
|
||||
Reference in New Issue
Block a user