fix(retention): add cleanup sweeps + CalDAV orphan reconciliation
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 1m29s

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:
2026-06-02 18:55:23 -04:00
parent 5fe0fd126d
commit c363a5a6df
4 changed files with 116 additions and 7 deletions
+43
View File
@@ -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())