Coverage for object_streams/retention.py: 94%
72 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-02 17:07 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-02 17:07 +0000
1"""Retention settings and pruning for the replayable outbox."""
3from __future__ import annotations
5from datetime import datetime
6from datetime import timedelta
8from django.conf import settings
9from django.db import transaction
10from django.utils import timezone
12from object_streams.models import ObjectStreamEvent
13from object_streams.outbox import broadcasted_through_cursor
14from object_streams.outbox import record_pruned_through
17__all__ = (
18 "get_retention_days",
19 "get_retention_max_rows",
20 "prune_outbox",
21 "retention_cutoff",
22)
25def _positive_int_setting(name: str) -> int | None:
26 value = getattr(settings, name, None)
27 if value is None:
28 return None
29 value = int(value)
30 if value < 1:
31 msg = f"{name} must be a positive integer or None."
32 raise ValueError(msg)
33 return value
36def get_retention_days() -> int | None:
37 """Return the configured outbox age limit in days, or None to keep every row."""
39 return _positive_int_setting("OBJECT_STREAMS_RETENTION_DAYS")
42def get_retention_max_rows() -> int | None:
43 """Return the configured outbox row limit, or None to keep every row."""
45 return _positive_int_setting("OBJECT_STREAMS_RETENTION_MAX_ROWS")
48def retention_cutoff(days: int) -> datetime:
49 """Return the timestamp before which rows are older than the age limit."""
51 if days < 1:
52 msg = "Retention days must be a positive integer."
53 raise ValueError(msg)
54 return timezone.now() - timedelta(days=days)
57def _manager(using: str | None):
58 manager = ObjectStreamEvent.objects
59 if using is not None:
60 manager = manager.db_manager(using)
61 return manager
64def _age_prune_through(manager, before: datetime) -> int | None:
65 return manager.filter(created_at__lt=before).order_by("-cursor").values_list("cursor", flat=True).first()
68def _row_limit_prune_through(manager, max_rows: int) -> int | None:
69 if max_rows < 1: 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true
70 msg = "Retention row limits must be a positive integer."
71 raise ValueError(msg)
72 oldest_kept = list(manager.order_by("-cursor").values_list("cursor", flat=True)[max_rows - 1 : max_rows])
73 if not oldest_kept:
74 return None
75 return oldest_kept[0] - 1
78def prune_outbox(
79 *,
80 before: datetime | None = None,
81 max_rows: int | None = None,
82 using: str | None = None,
83 dry_run: bool = False,
84) -> int:
85 """Delete outbox rows past the retention limits and return how many were removed.
87 Pruning never deletes the newest retained row, so the global cursor never
88 moves backwards. Deleted ranges are recorded as a watermark so replay can
89 answer with a resync instead of an empty catch-up.
90 """
92 if before is None and max_rows is None:
93 return 0
94 if max_rows is not None and max_rows < 1:
95 msg = "Retention row limits must be a positive integer."
96 raise ValueError(msg)
98 manager = _manager(using).filter(cursor__isnull=False)
99 newest = manager.order_by("-cursor").values_list("cursor", flat=True).first()
100 if newest is None:
101 return 0
103 candidates = []
104 if before is not None:
105 candidates.append(_age_prune_through(manager, before))
106 if max_rows is not None:
107 candidates.append(_row_limit_prune_through(manager, max_rows))
109 retained = [candidate for candidate in candidates if candidate is not None]
110 if not retained:
111 return 0
113 prune_through = min(max(retained), newest - 1, broadcasted_through_cursor(using=using))
114 if prune_through < 1: 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true
115 return 0
117 queryset = manager.filter(cursor__lte=prune_through)
118 if dry_run:
119 return queryset.count()
121 with transaction.atomic(using=using):
122 deleted, _ = queryset.delete()
123 if deleted: 123 ↛ 125line 123 didn't jump to line 125
124 record_pruned_through(prune_through, using=using)
125 return deleted