Coverage for object_streams/outbox.py: 88%
123 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"""Helpers for writing stream events to the replayable outbox."""
3from __future__ import annotations
5from django.apps import apps
6from django.contrib.contenttypes.models import ContentType
7from django.db import models
8from django.db import transaction
10from object_streams.events import ObjectRef
11from object_streams.events import SourceRef
12from object_streams.events import StreamEvent
13from object_streams.models import ObjectStreamEvent
14from object_streams.models import ObjectStreamOutboxState
15from object_streams.postgres import notify_outbox_event
18__all__ = (
19 "assign_outbox_cursors",
20 "broadcasted_through_cursor",
21 "create_outbox_event",
22 "enqueue_outbox_event",
23 "latest_outbox_cursor",
24 "outbox_events_after",
25 "outbox_events_pending_broadcast",
26 "pruned_through_cursor",
27 "record_broadcasted_through",
28 "record_pruned_through",
29 "replay_is_complete",
30)
33def _model_label(model: type[models.Model] | str) -> str:
34 if isinstance(model, str): 34 ↛ 35line 34 didn't jump to line 35 because the condition on line 34 was never true
35 return model
36 return model._meta.label
39def _content_type_for_model_label(model_label: str, *, using: str | None = None) -> ContentType:
40 app_label, model_name = model_label.split(".", 1)
41 model_class = apps.get_model(app_label, model_name)
42 if model_class is None: 42 ↛ 43line 42 didn't jump to line 43 because the condition on line 42 was never true
43 msg = f"No installed model matches {model_label!r}."
44 raise LookupError(msg)
45 manager = ContentType.objects
46 if using is not None: 46 ↛ 47line 46 didn't jump to line 47 because the condition on line 46 was never true
47 manager = manager.db_manager(using)
48 return manager.get_for_model(model_class)
51def _source_content_type(source: ObjectRef | SourceRef | None, *, using: str | None = None) -> ContentType | None:
52 if source is None:
53 return None
54 if isinstance(source, ObjectRef): 54 ↛ 55line 54 didn't jump to line 55 because the condition on line 54 was never true
55 return _content_type_for_model_label(source.model, using=using)
56 if source.model: 56 ↛ 58line 56 didn't jump to line 58 because the condition on line 56 was always true
57 return _content_type_for_model_label(source.model, using=using)
58 return None
61def _source_object_id(source: ObjectRef | SourceRef | None) -> str:
62 if source is None:
63 return ""
64 return source.pk or ""
67def create_outbox_event(event: StreamEvent, *, notify: bool = True, using: str | None = None) -> ObjectStreamEvent:
68 """Persist a stream event immediately and return the created outbox row."""
70 source_history_content_type = None
71 source_history_id = ""
72 if isinstance(event.source, SourceRef) and event.source.history_model:
73 source_history_content_type = _content_type_for_model_label(event.source.history_model, using=using)
74 source_history_id = event.source.history_id or ""
76 manager = ObjectStreamEvent.objects
77 if using is not None: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true
78 manager = manager.db_manager(using)
80 row = manager.create(
81 subject_content_type=_content_type_for_model_label(event.subject.model, using=using),
82 subject_object_id=event.subject.pk,
83 source_content_type=_source_content_type(event.source, using=using),
84 source_object_id=_source_object_id(event.source),
85 source_history_content_type=source_history_content_type,
86 source_history_id=source_history_id,
87 facet=event.facet,
88 op=event.op,
89 changed_fields=list(event.changed_fields),
90 before=event.before,
91 after=event.after,
92 metadata=dict(event.metadata),
93 )
94 connection = transaction.get_connection(using=using)
95 if not connection.in_atomic_block:
96 assign_outbox_cursors(using=using)
97 row.refresh_from_db(fields=["cursor"], using=using)
98 if notify:
99 notify_outbox_event(row.pk, using=using)
100 return row
103def enqueue_outbox_event(event: StreamEvent, *, using: str | None = None, notify: bool = True) -> None:
104 """Write an event after the current database transaction commits."""
106 transaction.on_commit(lambda: create_outbox_event(event, notify=notify, using=using), using=using)
109def _state_manager(using: str | None = None):
110 manager = ObjectStreamOutboxState.objects
111 if using is not None:
112 manager = manager.db_manager(using)
113 return manager
116def assign_outbox_cursors(*, using: str | None = None, limit: int | None = None) -> tuple[ObjectStreamEvent, ...]:
117 """Assign commit-visible delivery cursors to captured outbox rows."""
119 manager = ObjectStreamEvent.objects
120 if using is not None:
121 manager = manager.db_manager(using)
123 with transaction.atomic(using=using):
124 state, _ = _state_manager(using).select_for_update().get_or_create(pk=1)
125 queryset = manager.select_for_update().filter(cursor__isnull=True).order_by("id")
126 if limit is not None:
127 queryset = queryset[:limit]
128 rows = list(queryset)
129 if not rows:
130 return ()
132 next_cursor = state.next_cursor
133 for row in rows:
134 row.cursor = next_cursor
135 next_cursor += 1
136 manager.bulk_update(rows, ["cursor"])
137 state.next_cursor = next_cursor
138 state.save(update_fields=["next_cursor"])
139 return tuple(rows)
142def latest_outbox_cursor(*, using: str | None = None) -> int:
143 """Return the latest global outbox cursor, or 0 when the outbox is empty."""
145 manager = ObjectStreamEvent.objects
146 if using is not None: 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true
147 manager = manager.db_manager(using)
148 return manager.filter(cursor__isnull=False).order_by("-cursor").values_list("cursor", flat=True).first() or 0
151def pruned_through_cursor(*, using: str | None = None) -> int:
152 """Return the highest outbox cursor removed by retention pruning."""
154 return _state_manager(using).values_list("pruned_through", flat=True).first() or 0
157def broadcasted_through_cursor(*, using: str | None = None) -> int:
158 """Return the highest delivery cursor successfully fanned out by the listener."""
160 return _state_manager(using).values_list("broadcasted_through", flat=True).first() or 0
163def record_pruned_through(cursor: int, *, using: str | None = None) -> None:
164 """Raise the pruning watermark to a cursor that has been deleted."""
166 state, created = _state_manager(using).get_or_create(pk=1, defaults={"pruned_through": cursor})
167 if not created and cursor > state.pruned_through: 167 ↛ exitline 167 didn't return from function 'record_pruned_through' because the condition on line 167 was always true
168 state.pruned_through = cursor
169 state.save(update_fields=["pruned_through"])
172def record_broadcasted_through(cursor: int, *, using: str | None = None) -> None:
173 """Advance the durable listener watermark after successful fanout."""
175 with transaction.atomic(using=using):
176 state, _ = _state_manager(using).select_for_update().get_or_create(pk=1)
177 if cursor > state.broadcasted_through: 177 ↛ exitline 177 didn't jump to the function exit
178 state.broadcasted_through = cursor
179 state.save(update_fields=["broadcasted_through"])
182def replay_is_complete(cursor: int) -> bool:
183 """Return whether every outbox row after a cursor is still retained."""
185 return cursor >= pruned_through_cursor()
188def outbox_events_after(
189 cursor: int,
190 *,
191 model: type[models.Model] | str | None = None,
192 subject: ObjectRef | None = None,
193 through_cursor: int | None = None,
194 limit: int | None = None,
195) -> models.QuerySet:
196 """Return outbox rows after a cursor, optionally scoped to a model or subject."""
198 queryset = ObjectStreamEvent.objects.filter(cursor__gt=cursor).order_by("cursor")
199 if through_cursor is not None:
200 queryset = queryset.filter(cursor__lte=through_cursor)
201 if subject is not None:
202 queryset = queryset.filter(
203 subject_content_type=_content_type_for_model_label(subject.model),
204 subject_object_id=subject.pk,
205 )
206 elif model is not None:
207 queryset = queryset.filter(subject_content_type=_content_type_for_model_label(_model_label(model)))
208 if limit is not None:
209 return queryset[:limit]
210 return queryset
213def outbox_events_pending_broadcast(
214 *,
215 using: str | None = None,
216 limit: int | None = None,
217) -> models.QuerySet:
218 """Return cursor-assigned rows after the listener's durable watermark."""
220 manager = ObjectStreamEvent.objects
221 if using is not None: 221 ↛ 223line 221 didn't jump to line 223 because the condition on line 221 was always true
222 manager = manager.db_manager(using)
223 queryset = (
224 manager.select_related(
225 "subject_content_type",
226 "source_content_type",
227 "source_history_content_type",
228 )
229 .filter(cursor__gt=broadcasted_through_cursor(using=using))
230 .order_by("cursor")
231 )
232 if limit is not None: 232 ↛ 234line 232 didn't jump to line 234 because the condition on line 232 was always true
233 return queryset[:limit]
234 return queryset