python-saga-orchestrator 0.1.1__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {python_saga_orchestrator-0.1.1/python_saga_orchestrator.egg-info → python_saga_orchestrator-0.1.2}/PKG-INFO +13 -1
  2. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/README.md +12 -0
  3. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/pyproject.toml +1 -1
  4. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2/python_saga_orchestrator.egg-info}/PKG-INFO +13 -1
  5. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/python_saga_orchestrator.egg-info/SOURCES.txt +7 -1
  6. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/__init__.py +22 -0
  7. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/core/builder.py +5 -0
  8. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/core/engine.py +239 -7
  9. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/core/orchestrator.py +44 -2
  10. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/models/__init__.py +13 -1
  11. python_saga_orchestrator-0.1.2/saga_orchestrator/domain/models/notify.py +32 -0
  12. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/models/step.py +3 -0
  13. python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/__init__.py +15 -0
  14. python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/dispatcher.py +95 -0
  15. python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/event.py +12 -0
  16. python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/models.py +71 -0
  17. python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/repository.py +73 -0
  18. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/LICENSE +0 -0
  19. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
  20. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/python_saga_orchestrator.egg-info/requires.txt +0 -0
  21. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
  22. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/admin/__init__.py +0 -0
  23. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/admin/api.py +0 -0
  24. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/core/__init__.py +0 -0
  25. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/core/repository.py +0 -0
  26. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/__init__.py +0 -0
  27. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
  28. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/exceptions/saga.py +0 -0
  29. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/mixins/__init__.py +0 -0
  30. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
  31. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/models/builder.py +0 -0
  32. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
  33. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/models/enums/saga_status.py +0 -0
  34. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/models/retry.py +0 -0
  35. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
  36. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-saga-orchestrator
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Lightweight embedded saga orchestrator for asyncio Python services
5
5
  Author-email: Maxim Vasilyev <mayxis@inbox.ru>
6
6
  License-Expression: MIT
@@ -263,6 +263,18 @@ accepted = await orchestrator.notify(
263
263
  )
264
264
  ```
265
265
 
266
+ Configure explicit event expectations through a public API:
267
+
268
+ ```python
269
+ token = await orchestrator.await_event(
270
+ saga_id=saga_id,
271
+ event=AwaitingEvent(
272
+ event_type="model.approved",
273
+ correlation_id="corr-123",
274
+ ),
275
+ )
276
+ ```
277
+
266
278
  The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
267
279
 
268
280
  ## Administrative operations
@@ -229,6 +229,18 @@ accepted = await orchestrator.notify(
229
229
  )
230
230
  ```
231
231
 
232
+ Configure explicit event expectations through a public API:
233
+
234
+ ```python
235
+ token = await orchestrator.await_event(
236
+ saga_id=saga_id,
237
+ event=AwaitingEvent(
238
+ event_type="model.approved",
239
+ correlation_id="corr-123",
240
+ ),
241
+ )
242
+ ```
243
+
232
244
  The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
233
245
 
234
246
  ## Administrative operations
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-saga-orchestrator"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Lightweight embedded saga orchestrator for asyncio Python services"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-saga-orchestrator
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Lightweight embedded saga orchestrator for asyncio Python services
5
5
  Author-email: Maxim Vasilyev <mayxis@inbox.ru>
6
6
  License-Expression: MIT
@@ -263,6 +263,18 @@ accepted = await orchestrator.notify(
263
263
  )
264
264
  ```
265
265
 
266
+ Configure explicit event expectations through a public API:
267
+
268
+ ```python
269
+ token = await orchestrator.await_event(
270
+ saga_id=saga_id,
271
+ event=AwaitingEvent(
272
+ event_type="model.approved",
273
+ correlation_id="corr-123",
274
+ ),
275
+ )
276
+ ```
277
+
266
278
  The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
267
279
 
268
280
  ## Administrative operations
@@ -21,8 +21,14 @@ saga_orchestrator/domain/mixins/__init__.py
21
21
  saga_orchestrator/domain/mixins/saga_state.py
22
22
  saga_orchestrator/domain/models/__init__.py
23
23
  saga_orchestrator/domain/models/builder.py
24
+ saga_orchestrator/domain/models/notify.py
24
25
  saga_orchestrator/domain/models/retry.py
25
26
  saga_orchestrator/domain/models/saga_snapshot.py
26
27
  saga_orchestrator/domain/models/step.py
27
28
  saga_orchestrator/domain/models/enums/__init__.py
28
- saga_orchestrator/domain/models/enums/saga_status.py
29
+ saga_orchestrator/domain/models/enums/saga_status.py
30
+ saga_orchestrator/outbox/__init__.py
31
+ saga_orchestrator/outbox/dispatcher.py
32
+ saga_orchestrator/outbox/event.py
33
+ saga_orchestrator/outbox/models.py
34
+ saga_orchestrator/outbox/repository.py
@@ -11,11 +11,15 @@ from .domain.exceptions import (
11
11
  )
12
12
  from .domain.mixins import SagaStateMixin
13
13
  from .domain.models import (
14
+ AwaitingEvent,
14
15
  BaseStep,
15
16
  ExponentialRetry,
16
17
  FixedRetry,
17
18
  InputContext,
18
19
  NoRetry,
20
+ NotifyEvent,
21
+ NotifyResult,
22
+ OutboxMap,
19
23
  RetryPolicy,
20
24
  SagaAdminSnapshot,
21
25
  SagaDefinition,
@@ -25,14 +29,32 @@ from .domain.models import (
25
29
  StepRef,
26
30
  )
27
31
  from .domain.models.enums import SagaStatus
32
+ from .outbox import (
33
+ OutboxDispatcher,
34
+ OutboxEvent,
35
+ OutboxMessageMixin,
36
+ OutboxPublisher,
37
+ OutboxRepository,
38
+ OutboxStatus,
39
+ )
28
40
 
29
41
  __all__ = [
30
42
  "ActiveSagaAlreadyExistsError",
43
+ "AwaitingEvent",
31
44
  "BaseStep",
32
45
  "ExponentialRetry",
33
46
  "FixedRetry",
34
47
  "InputContext",
48
+ "NotifyEvent",
49
+ "NotifyResult",
35
50
  "NoRetry",
51
+ "OutboxDispatcher",
52
+ "OutboxEvent",
53
+ "OutboxMap",
54
+ "OutboxMessageMixin",
55
+ "OutboxPublisher",
56
+ "OutboxRepository",
57
+ "OutboxStatus",
36
58
  "RetryPolicy",
37
59
  "SagaAdmin",
38
60
  "SagaAdminSnapshot",
@@ -11,6 +11,7 @@ from ..domain.models import (
11
11
  BaseStep,
12
12
  InputContext,
13
13
  NoRetry,
14
+ OutboxMap,
14
15
  RetryPolicy,
15
16
  SagaDefinition,
16
17
  StepDefinition,
@@ -35,11 +36,14 @@ class SagaBuilder:
35
36
  timeout: timedelta | None = None,
36
37
  retry_policy: RetryPolicy | None = None,
37
38
  depends_on: StepRef[Any] | None = None,
39
+ outbox_map: OutboxMap[Any, Any] | None = None,
38
40
  step_id: str | None = None,
39
41
  ) -> StepRef[Any]:
40
42
  """Add one step definition and return a reference to its output."""
41
43
  if not callable(input_map):
42
44
  raise SagaDefinitionError("input_map must be callable")
45
+ if outbox_map is not None and not callable(outbox_map):
46
+ raise SagaDefinitionError("outbox_map must be callable")
43
47
  self.validate_input_map_types(input_map, step.input_model, depends_on)
44
48
 
45
49
  normalized_step_id = step_id or f"step_{len(self._steps)}"
@@ -53,6 +57,7 @@ class SagaBuilder:
53
57
  timeout=timeout,
54
58
  retry_policy=retry_policy or NoRetry(),
55
59
  depends_on=depends_on,
60
+ outbox_map=outbox_map,
56
61
  )
57
62
  self._steps.append(definition)
58
63
  return StepRef(step_id=normalized_step_id, output_model=step.output_model)
@@ -13,13 +13,18 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
13
13
  from ..domain.exceptions import SagaDefinitionError, SagaStateError
14
14
  from ..domain.mixins import SagaStateMixin
15
15
  from ..domain.models import (
16
+ AwaitingEvent,
16
17
  InputContext,
18
+ NotifyEvent,
19
+ NotifyResult,
17
20
  SagaAdminSnapshot,
18
21
  SagaDefinition,
19
22
  SagaSnapshot,
20
23
  StepDefinition,
21
24
  )
22
25
  from ..domain.models.enums import SagaStatus
26
+ from ..outbox.models import OutboxMessageMixin, OutboxStatus
27
+ from ..outbox.repository import OutboxRepository
23
28
  from .repository import SagaRepository
24
29
 
25
30
  ModelT = TypeVar("ModelT", bound=SagaStateMixin)
@@ -33,6 +38,7 @@ class SagaEngine(Generic[ModelT]):
33
38
  *,
34
39
  model_class: type[ModelT],
35
40
  session_maker: async_sessionmaker[AsyncSession],
41
+ outbox_model_class: type[OutboxMessageMixin] | None = None,
36
42
  execution_lease: timedelta = timedelta(minutes=5),
37
43
  ) -> None:
38
44
  """Initialize the engine dependencies and execution lease."""
@@ -40,6 +46,10 @@ class SagaEngine(Generic[ModelT]):
40
46
  self._session_maker = session_maker
41
47
  self._execution_lease = execution_lease
42
48
  self._repository = SagaRepository(model_class)
49
+ self._outbox_model_class = outbox_model_class
50
+ self._outbox_repository: OutboxRepository[OutboxMessageMixin] | None = None
51
+ if outbox_model_class is not None:
52
+ self._outbox_repository = OutboxRepository(outbox_model_class)
43
53
  self._registry: dict[str, SagaDefinition] = {}
44
54
 
45
55
  @property
@@ -47,6 +57,11 @@ class SagaEngine(Generic[ModelT]):
47
57
  """Return the repository used by the engine."""
48
58
  return self._repository
49
59
 
60
+ @property
61
+ def outbox_repository(self) -> OutboxRepository[OutboxMessageMixin] | None:
62
+ """Return the outbox repository used by the engine."""
63
+ return self._outbox_repository
64
+
50
65
  def register(self, name: str, saga_definition: SagaDefinition) -> None:
51
66
  """Register a saga definition under a runtime name."""
52
67
  if name in self._registry:
@@ -102,21 +117,110 @@ class SagaEngine(Generic[ModelT]):
102
117
  return saga_id
103
118
 
104
119
  async def notify(
105
- self, *, saga_id: UUID, token: UUID, event: Any | None = None
120
+ self,
121
+ *,
122
+ saga_id: UUID,
123
+ token: UUID,
124
+ event: NotifyEvent | dict[str, Any] | Any | None = None,
106
125
  ) -> bool:
107
126
  """Resume a suspended saga when the provided execution token matches."""
127
+ result = await self.notify_detailed(
128
+ saga_id=saga_id,
129
+ token=token,
130
+ event=event,
131
+ )
132
+ return result == NotifyResult.ACCEPTED
133
+
134
+ async def notify_detailed(
135
+ self,
136
+ *,
137
+ saga_id: UUID,
138
+ token: UUID,
139
+ event: NotifyEvent | dict[str, Any] | Any | None = None,
140
+ ) -> NotifyResult:
141
+ """Resume a suspended saga and return a detailed notify outcome."""
142
+ normalized_event, idempotency_key = self._normalize_notify_event(event)
143
+
108
144
  async with self._session_maker() as session:
109
145
  async with session.begin():
110
146
  saga = await self._repository.get_for_update(session, saga_id)
111
147
  if saga.status != SagaStatus.SUSPENDED:
112
- return False
148
+ self._append_notify_log(
149
+ saga=saga,
150
+ event=normalized_event,
151
+ result=NotifyResult.NOT_SUSPENDED,
152
+ )
153
+ return NotifyResult.NOT_SUSPENDED
113
154
  if saga.step_execution_token != token:
114
155
  logger.info("Ignoring stale notify for saga_id=%s", saga_id)
115
- return False
116
- if event is not None:
156
+ self._append_notify_log(
157
+ saga=saga,
158
+ event=normalized_event,
159
+ result=NotifyResult.STALE_TOKEN,
160
+ )
161
+ return NotifyResult.STALE_TOKEN
162
+
163
+ processed_ids = saga.context.setdefault("processed_event_ids", [])
164
+ if idempotency_key is not None and idempotency_key in processed_ids:
165
+ self._append_notify_log(
166
+ saga=saga,
167
+ event=normalized_event,
168
+ result=NotifyResult.DUPLICATE,
169
+ )
170
+ return NotifyResult.DUPLICATE
171
+
172
+ expected_type = saga.context.get("awaiting_event_type")
173
+ if (
174
+ expected_type is not None
175
+ and normalized_event is not None
176
+ and normalized_event.event_type != expected_type
177
+ ):
178
+ self._append_notify_log(
179
+ saga=saga,
180
+ event=normalized_event,
181
+ result=NotifyResult.EVENT_TYPE_MISMATCH,
182
+ )
183
+ return NotifyResult.EVENT_TYPE_MISMATCH
184
+
185
+ expected_correlation = saga.context.get("awaiting_correlation_id")
186
+ if (
187
+ expected_correlation is not None
188
+ and normalized_event is not None
189
+ and normalized_event.correlation_id != expected_correlation
190
+ ):
191
+ self._append_notify_log(
192
+ saga=saga,
193
+ event=normalized_event,
194
+ result=NotifyResult.CORRELATION_MISMATCH,
195
+ )
196
+ return NotifyResult.CORRELATION_MISMATCH
197
+
198
+ awaiting_until = self._parse_iso_datetime(
199
+ saga.context.get("awaiting_until")
200
+ )
201
+ if awaiting_until is not None and datetime.now(UTC) > awaiting_until:
202
+ self._append_notify_log(
203
+ saga=saga,
204
+ event=normalized_event,
205
+ result=NotifyResult.EXPIRED,
206
+ )
207
+ return NotifyResult.EXPIRED
208
+
209
+ if normalized_event is not None:
117
210
  events = saga.context.setdefault("events", [])
118
- events.append(self._serialize_value(event))
119
- saga.context["latest_event"] = self._serialize_value(event)
211
+ events.append(self._serialize_value(normalized_event.payload))
212
+ saga.context["latest_event"] = self._serialize_value(
213
+ normalized_event.payload
214
+ )
215
+ saga.context["latest_event_meta"] = self._serialize_value(
216
+ normalized_event.model_dump(mode="json")
217
+ )
218
+ if idempotency_key is not None:
219
+ processed_ids.append(idempotency_key)
220
+
221
+ saga.context.pop("awaiting_event_type", None)
222
+ saga.context.pop("awaiting_correlation_id", None)
223
+ saga.context.pop("awaiting_until", None)
120
224
  saga.status = SagaStatus.RUNNING
121
225
  step_def = self._registry[saga.context["saga_name"]].steps[
122
226
  saga.current_step_index
@@ -126,9 +230,50 @@ class SagaEngine(Generic[ModelT]):
126
230
  now=datetime.now(UTC),
127
231
  )
128
232
  saga.step_execution_token = uuid.uuid4()
233
+ self._append_notify_log(
234
+ saga=saga,
235
+ event=normalized_event,
236
+ result=NotifyResult.ACCEPTED,
237
+ )
129
238
 
130
239
  await self._drive(saga_id)
131
- return True
240
+ return NotifyResult.ACCEPTED
241
+
242
+ async def await_event(
243
+ self,
244
+ *,
245
+ saga_id: UUID,
246
+ event: AwaitingEvent,
247
+ ) -> UUID:
248
+ """Configure a suspended saga to wait for a specific external event."""
249
+ async with self._session_maker() as session:
250
+ async with session.begin():
251
+ saga = await self._repository.get_for_update(session, saga_id)
252
+ if saga.status != SagaStatus.SUSPENDED:
253
+ raise SagaStateError(
254
+ "Cannot configure external wait unless saga is suspended "
255
+ f"(status={saga.status.value})"
256
+ )
257
+
258
+ if event.event_type is None:
259
+ saga.context.pop("awaiting_event_type", None)
260
+ else:
261
+ saga.context["awaiting_event_type"] = event.event_type
262
+
263
+ if event.correlation_id is None:
264
+ saga.context.pop("awaiting_correlation_id", None)
265
+ else:
266
+ saga.context["awaiting_correlation_id"] = event.correlation_id
267
+
268
+ if event.until is None:
269
+ saga.context.pop("awaiting_until", None)
270
+ else:
271
+ saga.context["awaiting_until"] = event.until.isoformat()
272
+
273
+ # External event waits should not be auto-resumed by run_due.
274
+ saga.deadline_at = None
275
+ saga.step_execution_token = uuid.uuid4()
276
+ return saga.step_execution_token
132
277
 
133
278
  async def run_due(self, *, limit: int = 100) -> int:
134
279
  """Resume due running, suspended, and compensating sagas."""
@@ -444,6 +589,39 @@ class SagaEngine(Generic[ModelT]):
444
589
  return False
445
590
 
446
591
  if error is None and step_output is not None:
592
+ if step_def.outbox_map is not None:
593
+ if (
594
+ self._outbox_model_class is None
595
+ or self._outbox_repository is None
596
+ ):
597
+ raise SagaStateError(
598
+ "outbox_map is configured for step "
599
+ f"'{step_def.step_id}', but outbox_model_class is not configured in SagaEngine"
600
+ )
601
+ outbox_events = (
602
+ step_def.outbox_map(step_input, step_output) or []
603
+ )
604
+ now = datetime.now(UTC)
605
+ outbox_messages = [
606
+ self._outbox_model_class(
607
+ saga_id=saga.id,
608
+ aggregation_id=saga.aggregation_id,
609
+ step_id=step_def.step_id,
610
+ trace_id=saga.trace_id,
611
+ topic=event.topic,
612
+ message_key=event.key,
613
+ payload=self._serialize_value(event.payload),
614
+ headers=self._serialize_value(event.headers),
615
+ status=OutboxStatus.PENDING,
616
+ next_attempt_at=now,
617
+ )
618
+ for event in outbox_events
619
+ ]
620
+ if outbox_messages:
621
+ await self._outbox_repository.create_many(
622
+ session,
623
+ outbox_messages,
624
+ )
447
625
  saga.step_history.append(
448
626
  self._history_entry(
449
627
  phase="execute",
@@ -697,6 +875,60 @@ class SagaEngine(Generic[ModelT]):
697
875
  return [self._serialize_value(item) for item in value]
698
876
  return value
699
877
 
878
+ def _normalize_notify_event(
879
+ self,
880
+ event: NotifyEvent | dict[str, Any] | Any | None,
881
+ ) -> tuple[NotifyEvent | None, str | None]:
882
+ if event is None:
883
+ return None, None
884
+ if isinstance(event, NotifyEvent):
885
+ return event, event.event_id
886
+ if isinstance(event, dict):
887
+ envelope_keys = {
888
+ "event_id",
889
+ "event_type",
890
+ "correlation_id",
891
+ "payload",
892
+ "source",
893
+ "occurred_at",
894
+ }
895
+ if any(key in event for key in envelope_keys):
896
+ notify_event = NotifyEvent.model_validate(event)
897
+ return notify_event, notify_event.event_id
898
+ return NotifyEvent(payload=self._serialize_value(event)), None
899
+ return NotifyEvent(payload=self._serialize_value(event)), None
900
+
901
+ @staticmethod
902
+ def _parse_iso_datetime(value: Any) -> datetime | None:
903
+ if not isinstance(value, str):
904
+ return None
905
+ normalized = value.replace("Z", "+00:00")
906
+ try:
907
+ parsed = datetime.fromisoformat(normalized)
908
+ except ValueError:
909
+ return None
910
+ if parsed.tzinfo is None:
911
+ return parsed.replace(tzinfo=UTC)
912
+ return parsed
913
+
914
+ def _append_notify_log(
915
+ self,
916
+ *,
917
+ saga: ModelT,
918
+ event: NotifyEvent | None,
919
+ result: NotifyResult,
920
+ ) -> None:
921
+ inbox = saga.context.setdefault("notify_inbox", [])
922
+ inbox.append(
923
+ {
924
+ "timestamp": datetime.now(UTC).isoformat(),
925
+ "result": result.value,
926
+ "event_id": event.event_id if event is not None else None,
927
+ "event_type": event.event_type if event is not None else None,
928
+ "correlation_id": (event.correlation_id if event is not None else None),
929
+ }
930
+ )
931
+
700
932
  def _history_entry(
701
933
  self,
702
934
  *,
@@ -8,7 +8,15 @@ from pydantic import BaseModel
8
8
  from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
9
9
 
10
10
  from ..domain.mixins import SagaStateMixin
11
- from ..domain.models import SagaDefinition, SagaSnapshot
11
+ from ..domain.models import (
12
+ AwaitingEvent,
13
+ NotifyEvent,
14
+ NotifyResult,
15
+ SagaDefinition,
16
+ SagaSnapshot,
17
+ )
18
+ from ..outbox.models import OutboxMessageMixin
19
+ from ..outbox.repository import OutboxRepository
12
20
  from .engine import SagaEngine
13
21
  from .repository import SagaRepository
14
22
 
@@ -23,12 +31,14 @@ class SagaOrchestrator(Generic[ModelT]):
23
31
  *,
24
32
  model_class: type[ModelT],
25
33
  session_maker: async_sessionmaker[AsyncSession],
34
+ outbox_model_class: type[OutboxMessageMixin] | None = None,
26
35
  execution_lease: timedelta = timedelta(minutes=5),
27
36
  ) -> None:
28
37
  """Initialize the orchestrator facade."""
29
38
  self._engine = SagaEngine(
30
39
  model_class=model_class,
31
40
  session_maker=session_maker,
41
+ outbox_model_class=outbox_model_class,
32
42
  execution_lease=execution_lease,
33
43
  )
34
44
 
@@ -42,6 +52,11 @@ class SagaOrchestrator(Generic[ModelT]):
42
52
  """Return the repository used by the engine."""
43
53
  return self._engine.repository
44
54
 
55
+ @property
56
+ def outbox_repository(self) -> OutboxRepository[OutboxMessageMixin] | None:
57
+ """Return the outbox repository used by the engine."""
58
+ return self._engine.outbox_repository
59
+
45
60
  def register(self, name: str, saga_definition: SagaDefinition) -> None:
46
61
  """Register a saga definition under a runtime name."""
47
62
  self._engine.register(name, saga_definition)
@@ -63,11 +78,38 @@ class SagaOrchestrator(Generic[ModelT]):
63
78
  )
64
79
 
65
80
  async def notify(
66
- self, *, saga_id: UUID, token: UUID, event: Any | None = None
81
+ self,
82
+ *,
83
+ saga_id: UUID,
84
+ token: UUID,
85
+ event: NotifyEvent | dict[str, Any] | Any | None = None,
67
86
  ) -> bool:
68
87
  """Resume a suspended saga when the provided execution token matches."""
69
88
  return await self._engine.notify(saga_id=saga_id, token=token, event=event)
70
89
 
90
+ async def notify_detailed(
91
+ self,
92
+ *,
93
+ saga_id: UUID,
94
+ token: UUID,
95
+ event: NotifyEvent | dict[str, Any] | Any | None = None,
96
+ ) -> NotifyResult:
97
+ """Resume a suspended saga and return a detailed notify outcome."""
98
+ return await self._engine.notify_detailed(
99
+ saga_id=saga_id,
100
+ token=token,
101
+ event=event,
102
+ )
103
+
104
+ async def await_event(
105
+ self,
106
+ *,
107
+ saga_id: UUID,
108
+ event: AwaitingEvent,
109
+ ) -> UUID:
110
+ """Configure a suspended saga to wait for an external event."""
111
+ return await self._engine.await_event(saga_id=saga_id, event=event)
112
+
71
113
  async def run_due(self, *, limit: int = 100) -> int:
72
114
  """Resume due running, suspended, and compensating sagas."""
73
115
  return await self._engine.run_due(limit=limit)
@@ -1,12 +1,23 @@
1
1
  """Domain models module."""
2
2
 
3
3
  from .builder import SagaDefinition
4
+ from .notify import AwaitingEvent, NotifyEvent, NotifyResult
4
5
  from .retry import ExponentialRetry, FixedRetry, NoRetry, RetryPolicy
5
6
  from .saga_snapshot import SagaAdminSnapshot, SagaSnapshot
6
- from .step import BaseStep, InputContext, StepDefinition, StepInputMap, StepRef
7
+ from .step import (
8
+ BaseStep,
9
+ InputContext,
10
+ OutboxMap,
11
+ StepDefinition,
12
+ StepInputMap,
13
+ StepRef,
14
+ )
7
15
 
8
16
  __all__ = [
9
17
  "SagaDefinition",
18
+ "AwaitingEvent",
19
+ "NotifyEvent",
20
+ "NotifyResult",
10
21
  "RetryPolicy",
11
22
  "NoRetry",
12
23
  "FixedRetry",
@@ -15,6 +26,7 @@ __all__ = [
15
26
  "SagaSnapshot",
16
27
  "StepRef",
17
28
  "InputContext",
29
+ "OutboxMap",
18
30
  "StepInputMap",
19
31
  "StepDefinition",
20
32
  "BaseStep",
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from enum import Enum
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class NotifyResult(str, Enum):
11
+ ACCEPTED = "ACCEPTED"
12
+ NOT_SUSPENDED = "NOT_SUSPENDED"
13
+ STALE_TOKEN = "STALE_TOKEN"
14
+ DUPLICATE = "DUPLICATE"
15
+ EVENT_TYPE_MISMATCH = "EVENT_TYPE_MISMATCH"
16
+ CORRELATION_MISMATCH = "CORRELATION_MISMATCH"
17
+ EXPIRED = "EXPIRED"
18
+
19
+
20
+ class NotifyEvent(BaseModel):
21
+ event_id: str | None = None
22
+ event_type: str | None = None
23
+ correlation_id: str | None = None
24
+ payload: Any = None
25
+ source: str | None = None
26
+ occurred_at: datetime | None = None
27
+
28
+
29
+ class AwaitingEvent(BaseModel):
30
+ event_type: str | None = None
31
+ correlation_id: str | None = None
32
+ until: datetime | None = None
@@ -8,6 +8,7 @@ from typing import Any, Generic, TypeAlias, TypeVar, get_type_hints
8
8
 
9
9
  from pydantic import BaseModel
10
10
 
11
+ from ...outbox.event import OutboxEvent
11
12
  from ..exceptions import TypeValidationError
12
13
  from .retry import RetryPolicy
13
14
 
@@ -33,6 +34,7 @@ class InputContext:
33
34
 
34
35
  RootInputMap: TypeAlias = Callable[[InputContext], InputModelT | dict[str, Any]]
35
36
  StepInputMap: TypeAlias = RootInputMap | Callable[[Any], InputModelT | dict[str, Any]]
37
+ OutboxMap: TypeAlias = Callable[[InputModelT, OutputModelT], list[OutboxEvent] | None]
36
38
 
37
39
 
38
40
  @dataclass
@@ -43,6 +45,7 @@ class StepDefinition(Generic[InputModelT, OutputModelT]):
43
45
  timeout: timedelta | None
44
46
  retry_policy: RetryPolicy
45
47
  depends_on: StepRef[Any] | None = None
48
+ outbox_map: OutboxMap[InputModelT, OutputModelT] | None = None
46
49
 
47
50
  @property
48
51
  def input_model(self) -> type[InputModelT]:
@@ -0,0 +1,15 @@
1
+ """Outbox module."""
2
+
3
+ from .dispatcher import OutboxDispatcher, OutboxPublisher
4
+ from .event import OutboxEvent
5
+ from .models import OutboxMessageMixin, OutboxStatus
6
+ from .repository import OutboxRepository
7
+
8
+ __all__ = [
9
+ "OutboxDispatcher",
10
+ "OutboxEvent",
11
+ "OutboxMessageMixin",
12
+ "OutboxPublisher",
13
+ "OutboxRepository",
14
+ "OutboxStatus",
15
+ ]
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime, timedelta
4
+ from typing import Any, Protocol, TypeVar
5
+ from uuid import UUID
6
+
7
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
8
+
9
+ from .models import OutboxMessageMixin, OutboxStatus
10
+ from .repository import OutboxRepository
11
+
12
+ OutboxModelT = TypeVar("OutboxModelT", bound=OutboxMessageMixin)
13
+
14
+
15
+ class OutboxPublisher(Protocol):
16
+ async def publish(
17
+ self,
18
+ *,
19
+ topic: str,
20
+ payload: dict[str, Any],
21
+ key: str | None = None,
22
+ headers: dict[str, Any] | None = None,
23
+ ) -> None: ...
24
+
25
+
26
+ class OutboxDispatcher:
27
+ """Dispatch outbox rows to an external transport."""
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ session_maker: async_sessionmaker[AsyncSession],
33
+ model_class: type[OutboxModelT],
34
+ publisher: OutboxPublisher,
35
+ failure_backoff: timedelta = timedelta(seconds=30),
36
+ ) -> None:
37
+ self._session_maker = session_maker
38
+ self._repository = OutboxRepository(model_class)
39
+ self._publisher = publisher
40
+ self._failure_backoff = failure_backoff
41
+
42
+ async def run_once(self, *, limit: int = 100) -> int:
43
+ """Claim due outbox messages and attempt to publish them once."""
44
+ now = datetime.now(UTC)
45
+ claimed: list[tuple[UUID, str, dict[str, Any], str | None, dict[str, Any]]] = []
46
+
47
+ async with self._session_maker() as session:
48
+ async with session.begin():
49
+ due = await self._repository.due_for_dispatch(
50
+ session,
51
+ now=now,
52
+ limit=limit,
53
+ )
54
+ for message in due:
55
+ message.status = OutboxStatus.DISPATCHING
56
+ claimed.append(
57
+ (
58
+ message.id,
59
+ message.topic,
60
+ message.payload,
61
+ message.message_key,
62
+ message.headers,
63
+ )
64
+ )
65
+
66
+ for message_id, topic, payload, key, headers in claimed:
67
+ try:
68
+ await self._publisher.publish(
69
+ topic=topic,
70
+ payload=payload,
71
+ key=key,
72
+ headers=headers,
73
+ )
74
+ except Exception as exc: # noqa: BLE001
75
+ async with self._session_maker() as session:
76
+ async with session.begin():
77
+ row = await self._repository.get_for_update(session, message_id)
78
+ if row is None or row.status != OutboxStatus.DISPATCHING:
79
+ continue
80
+ row.status = OutboxStatus.FAILED
81
+ row.attempts += 1
82
+ row.last_error = repr(exc)
83
+ row.next_attempt_at = datetime.now(UTC) + self._failure_backoff
84
+ continue
85
+
86
+ async with self._session_maker() as session:
87
+ async with session.begin():
88
+ row = await self._repository.get_for_update(session, message_id)
89
+ if row is None or row.status != OutboxStatus.DISPATCHING:
90
+ continue
91
+ row.status = OutboxStatus.SENT
92
+ row.sent_at = datetime.now(UTC)
93
+ row.last_error = None
94
+
95
+ return len(claimed)
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class OutboxEvent:
9
+ topic: str
10
+ payload: dict[str, Any]
11
+ key: str | None = None
12
+ headers: dict[str, Any] = field(default_factory=dict)
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+ from sqlalchemy import JSON, DateTime
9
+ from sqlalchemy import Enum as SqlEnum
10
+ from sqlalchemy import Integer, String, Text, func
11
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
12
+ from sqlalchemy.ext.mutable import MutableDict
13
+ from sqlalchemy.orm import Mapped, declarative_mixin, mapped_column
14
+
15
+
16
+ def _json_type() -> JSON:
17
+ return JSON().with_variant(JSONB, "postgresql")
18
+
19
+
20
+ class OutboxStatus(str, Enum):
21
+ PENDING = "PENDING"
22
+ DISPATCHING = "DISPATCHING"
23
+ SENT = "SENT"
24
+ FAILED = "FAILED"
25
+
26
+
27
+ @declarative_mixin
28
+ class OutboxMessageMixin:
29
+ id: Mapped[uuid.UUID] = mapped_column(
30
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
31
+ )
32
+ saga_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True)
33
+ aggregation_id: Mapped[str] = mapped_column(String(255), index=True)
34
+ step_id: Mapped[str] = mapped_column(String(255), index=True)
35
+ trace_id: Mapped[str] = mapped_column(String(255), index=True)
36
+ topic: Mapped[str] = mapped_column(String(255), nullable=False)
37
+ message_key: Mapped[str | None] = mapped_column(String(255), nullable=True)
38
+ payload: Mapped[dict[str, Any]] = mapped_column(
39
+ MutableDict.as_mutable(_json_type()),
40
+ default=dict,
41
+ )
42
+ headers: Mapped[dict[str, Any]] = mapped_column(
43
+ MutableDict.as_mutable(_json_type()),
44
+ default=dict,
45
+ )
46
+ status: Mapped[OutboxStatus] = mapped_column(
47
+ SqlEnum(OutboxStatus),
48
+ default=OutboxStatus.PENDING,
49
+ index=True,
50
+ )
51
+ attempts: Mapped[int] = mapped_column(Integer, default=0)
52
+ next_attempt_at: Mapped[datetime] = mapped_column(
53
+ DateTime(timezone=True),
54
+ nullable=False,
55
+ server_default=func.now(),
56
+ index=True,
57
+ )
58
+ last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
59
+ sent_at: Mapped[datetime | None] = mapped_column(
60
+ DateTime(timezone=True),
61
+ nullable=True,
62
+ )
63
+ created_at: Mapped[datetime] = mapped_column(
64
+ DateTime(timezone=True), nullable=False, server_default=func.now()
65
+ )
66
+ updated_at: Mapped[datetime] = mapped_column(
67
+ DateTime(timezone=True),
68
+ nullable=False,
69
+ server_default=func.now(),
70
+ onupdate=func.now(),
71
+ )
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Generic, TypeVar
5
+ from uuid import UUID
6
+
7
+ from sqlalchemy import Select, select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from .models import OutboxMessageMixin, OutboxStatus
11
+
12
+ OutboxModelT = TypeVar("OutboxModelT", bound=OutboxMessageMixin)
13
+
14
+
15
+ class OutboxRepository(Generic[OutboxModelT]):
16
+ """Provide persistence operations for outbox rows."""
17
+
18
+ def __init__(self, model_class: type[OutboxModelT]) -> None:
19
+ self.model_class = model_class
20
+
21
+ async def create_many(
22
+ self,
23
+ session: AsyncSession,
24
+ messages: list[OutboxModelT],
25
+ ) -> None:
26
+ session.add_all(messages)
27
+ await session.flush()
28
+
29
+ async def due_for_dispatch(
30
+ self,
31
+ session: AsyncSession,
32
+ *,
33
+ now: datetime,
34
+ limit: int,
35
+ ) -> list[OutboxModelT]:
36
+ stmt: Select[tuple[OutboxModelT]] = (
37
+ select(self.model_class)
38
+ .where(
39
+ self.model_class.status.in_(
40
+ (OutboxStatus.PENDING, OutboxStatus.FAILED),
41
+ ),
42
+ self.model_class.next_attempt_at <= now,
43
+ )
44
+ .order_by(
45
+ self.model_class.next_attempt_at.asc(),
46
+ self.model_class.created_at.asc(),
47
+ )
48
+ .limit(limit)
49
+ )
50
+ if self._supports_skip_locked(session):
51
+ stmt = stmt.with_for_update(skip_locked=True)
52
+ else:
53
+ stmt = stmt.with_for_update(nowait=False)
54
+ result = await session.execute(stmt)
55
+ return list(result.scalars().all())
56
+
57
+ async def get_for_update(
58
+ self,
59
+ session: AsyncSession,
60
+ message_id: UUID,
61
+ ) -> OutboxModelT | None:
62
+ stmt: Select[tuple[OutboxModelT]] = (
63
+ select(self.model_class)
64
+ .where(self.model_class.id == message_id)
65
+ .with_for_update(nowait=False)
66
+ )
67
+ result = await session.execute(stmt)
68
+ return result.scalar_one_or_none()
69
+
70
+ @staticmethod
71
+ def _supports_skip_locked(session: AsyncSession) -> bool:
72
+ bind = session.get_bind()
73
+ return bind is not None and bind.dialect.name == "postgresql"