Automating Retention Schedule Enforcement with Celery Beat
This page answers one narrow operational question: why does patron circulation data keep outliving its lawful retention window even though you have a purge script, and how do you make the purge run on a schedule that is idempotent, overlap-safe, and provable at audit time? It sits under Data Retention Policies for Public Libraries, which defines the retention windows themselves, and within the broader Patron Validation & Privacy Data Routing architecture. If your retention purge is a script someone remembers to run each quarter, this is the failure you will eventually discover during an audit — and by then the data has already overstayed.
Problem Framing
The symptom is silence. Nobody reports an error, no alert fires, and the retention purge “works” — until an audit asks for proof that it ran. When you pull the purge history, the gaps are unmistakable: a run every quarter until the person who owned the script changed teams, then months of nothing, then a single catch-up run far too large to be routine.
$ psql -c "SELECT date_trunc('month', run_at) AS month, count(*) AS runs, \
sum(rows_purged) AS purged FROM retention_purge_audit \
GROUP BY 1 ORDER BY 1"
month | runs | purged
------------+------+--------
2025-10-01 | 1 | 4213
2026-01-01 | 1 | 3987
2026-04-01 | 0 | 0 <-- no purge ran; data retained past its window
2026-05-01 | 0 | 0 <-- still nothing
2026-06-01 | 0 | 0 <-- still nothing
2026-07-01 | 1 | 38120 <-- catch-up run; a quarter of overdue data at once
Every zero-row month is a compliance exposure: circulation records whose retention window closed but that were never purged, sitting in the database, discoverable, and technically unlawful to retain. The catch-up spike is worse than the gap — it proves the data survived months past its window before anything touched it. A purge that depends on a human remembering is a purge that will, eventually, be forgotten.
Root Cause
No scheduler owns the recurring purge. The work exists as a management command or a standalone script that a person runs by hand, or at best a bare cron one-liner on a single box. Each of those arrangements is missing the same four properties, and the absence of any one of them is enough to produce the gap above.
- No durable schedule. A hand-run script runs when someone remembers; a
cronentry lives on one host and vanishes when that host is rebuilt, drained, or replaced during a deploy. Nothing declares the cadence as part of the application, so nothing guarantees it recurs. - No idempotency. If a purge is interrupted midway — the box is rebooted, the connection drops — re-running it must be safe. A naive
DELETEthat also writes a summary row can double-count, or worse, a partial run can leave the audit trail claiming success while rows remain. - No overlap protection. If a run is slow and the next fires before it finishes, two purges race over the same rows. Without a lock they contend, deadlock, or duplicate audit entries.
- No observability. A
cronone-liner emails stderr to a mailbox nobody reads. When it fails — a migration renamed a column, the disk filled, a credential rotated — it fails silently. The first sign is the audit gap, months later.
The retention windows are already defined correctly in Data Retention Policies for Public Libraries. What is missing is a component whose job is to run the enforcer on time, exactly once per interval, and to leave a provable record whether it succeeds or fails.
Solution
Move the purge into a Celery Beat periodic task. Beat is the scheduler; a worker executes the task; a distributed lock guarantees a single run at a time; the delete is idempotent per record; and every outcome — ran, skipped, failed — is written to the append-only audit table. This reuses the same broker and worker topology described in Using Celery for Distributed Catalog Ingestion; you are adding a scheduled task to an existing Celery deployment, not standing up new infrastructure.
First, declare the schedule so the cadence lives in the application, not in one host’s crontab:
from __future__ import annotations
from celery import Celery
from celery.schedules import crontab
app = Celery("librarycatalog")
app.conf.beat_schedule = {
"enforce-retention-daily": {
"task": "retention.tasks.enforce_retention",
# Run every night at 02:00; small windows purged daily never
# accumulate into a quarter-sized catch-up batch.
"schedule": crontab(hour=2, minute=0),
"options": {"expires": 3600}, # drop if a worker cannot start it within the hour
},
}
Then the task itself. The lock is acquired first and released in a finally; the purge deletes only records whose window has expired; and each outcome is logged with structured extra={...} and recorded in the audit table.
from __future__ import annotations
import logging
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from typing import Iterator
from celery import shared_task
from django.db import transaction
from redis import Redis
from redis.exceptions import RedisError
logger = logging.getLogger("retention.enforce")
_redis = Redis.from_url("redis://localhost:6379/1")
_LOCK_KEY = "lock:retention:enforce"
_LOCK_TTL = 1800 # seconds; longer than a healthy run, short enough to self-heal
class LockNotAcquired(Exception):
"""A prior run still holds the retention lock."""
class RetentionPurgeError(Exception):
"""The purge could not complete and must alert on-call."""
@contextmanager
def distributed_lock(key: str, ttl: int) -> Iterator[None]:
"""Redis single-holder lock; auto-expires so a crashed run cannot wedge it."""
acquired = _redis.set(key, "1", nx=True, ex=ttl)
if not acquired:
raise LockNotAcquired(key)
try:
yield
finally:
_redis.delete(key)
@shared_task(
bind=True,
name="retention.tasks.enforce_retention",
autoretry_for=(RedisError,),
retry_backoff=True,
max_retries=3,
)
def enforce_retention(self, *, retention_days: int = 730) -> dict[str, int | str]:
"""Purge circulation records past their retention window, exactly once per run."""
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
run_id = self.request.id
try:
with distributed_lock(_LOCK_KEY, _LOCK_TTL):
purged = _purge_expired(cutoff=cutoff, run_id=run_id)
except LockNotAcquired:
logger.info(
"retention_skip_overlap",
extra={"run_id": run_id, "reason": "prior_run_active"},
)
return {"status": "skipped", "run_id": run_id}
except Exception as exc: # noqa: BLE001 — audit + alert on any failure
logger.exception(
"retention_run_failed",
extra={"run_id": run_id, "cutoff": cutoff.isoformat()},
)
_record_audit(run_id=run_id, rows_purged=0, status="failed", detail=str(exc))
raise RetentionPurgeError(run_id) from exc
logger.info(
"retention_run_complete",
extra={"run_id": run_id, "rows_purged": purged, "cutoff": cutoff.isoformat()},
)
_record_audit(run_id=run_id, rows_purged=purged, status="ok", detail="")
return {"status": "ok", "run_id": run_id, "rows_purged": purged}
def _purge_expired(*, cutoff: datetime, run_id: str) -> int:
"""Idempotent per-record delete: only rows strictly older than the cutoff."""
from circulation.models import CirculationEvent
with transaction.atomic():
# Re-selecting < cutoff each run makes the operation naturally idempotent:
# a re-run over already-purged rows simply matches nothing.
qs = CirculationEvent.objects.filter(occurred_at__lt=cutoff)
deleted, _ = qs.delete()
logger.debug(
"retention_batch_deleted",
extra={"run_id": run_id, "deleted": deleted},
)
return deleted
The behavioural change is that the schedule now owns the work. Beat fires enforce_retention every night whether or not anyone is watching. The lock makes a second concurrent run a no-op instead of a race. The delete is idempotent because it selects occurred_at < cutoff on every run — a re-run after an interruption matches only rows that still qualify, so nothing is double-counted and nothing is missed. And because both success and failure write to retention_purge_audit, the gap that started this investigation becomes structurally impossible: a night with no ok row is itself a visible, alertable anomaly.
Compliance or Privacy Impact
Automating enforcement changes the retention posture in three concrete ways, each with a downstream effect worth tracking.
- Retention becomes continuous, not quarterly. Purging nightly means the maximum time a record can overstay its window shrinks from a quarter to a day. That is the difference between “we retain circulation data for the lawful period” being a policy statement and being a demonstrable fact. The window itself still comes from Data Retention Policies for Public Libraries; this task only enforces it on time.
- The audit trail is now complete and adversarial-review-ready. Every run writes an outcome, so an auditor’s “prove the purge ran in April” is answered with a query, not an assurance. Feed the
retention_purge_audittable into the same immutable reporting surface described in Compliance Reporting for Circulation Data, where a missing nightlyokrow is treated as a reportable exception rather than an absence of news. - Purging is not the same as masking, and both are needed. Deleting an expired circulation event removes it, but records still inside the retention window remain live and must not leak through exports. The redaction contract in PII Masking in Patron Data Exports governs that surface; this task governs the far end of the lifecycle, where the record’s lawful basis for retention has expired and it must leave the system entirely.
The task narrows the retention surface rather than widening it, but it shifts risk onto the correctness of retention_days and the cutoff query. A window set too long silently retains data past its lawful period with the scheduler’s full authority behind it, so changes to that value deserve the same code review and the verification below.
Verification
Confirm three properties: that only expired rows are deleted, that a concurrent run is skipped rather than racing, and that a failure is recorded and re-raised so it alerts.
import pytest
from datetime import datetime, timedelta, timezone
from redis import Redis
from retention.tasks import _purge_expired, distributed_lock, LockNotAcquired
def test_only_expired_rows_are_purged(db) -> None:
from circulation.models import CirculationEvent
now = datetime.now(timezone.utc)
cutoff = now - timedelta(days=730)
old = CirculationEvent.objects.create(occurred_at=cutoff - timedelta(days=1))
fresh = CirculationEvent.objects.create(occurred_at=now - timedelta(days=10))
deleted = _purge_expired(cutoff=cutoff, run_id="test")
assert deleted == 1
assert not CirculationEvent.objects.filter(pk=old.pk).exists() # expired, gone
assert CirculationEvent.objects.filter(pk=fresh.pk).exists() # in window, kept
def test_purge_is_idempotent(db) -> None:
from circulation.models import CirculationEvent
cutoff = datetime.now(timezone.utc) - timedelta(days=730)
CirculationEvent.objects.create(occurred_at=cutoff - timedelta(days=5))
first = _purge_expired(cutoff=cutoff, run_id="run-1")
second = _purge_expired(cutoff=cutoff, run_id="run-2") # re-run over same window
assert first == 1
assert second == 0 # nothing left to purge; no double-count
def test_overlapping_run_is_skipped() -> None:
with distributed_lock("lock:test:overlap", ttl=30):
with pytest.raises(LockNotAcquired):
with distributed_lock("lock:test:overlap", ttl=30):
pytest.fail("second holder must not acquire the lock")
For a running deployment, add a continuous invariant on the audit table itself: a monitor that asserts a status='ok' row exists for every scheduled interval, and pages if a night is missing. That is the same detection that would have caught the original gap on the first missed night instead of at audit time a quarter later. Track the rows_purged distribution too — a sudden spike is the signature of a schedule that stopped and restarted, exactly the catch-up batch this task exists to prevent.
Related
- Data Retention Policies for Public Libraries — the parent guide defining the retention windows this task enforces on a schedule.
- Patron Validation & Privacy Data Routing — the architecture governing how patron data is validated, masked, and retired.
- Enforcing FERPA Retention Schedules in Patron Records — applying scheduled retention to FERPA-governed student records specifically.
- Compliance Reporting for Circulation Data — turning the purge audit trail into a provable compliance report.