python-saga-orchestrator 0.1.1__tar.gz → 0.1.3__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 (40) hide show
  1. {python_saga_orchestrator-0.1.1/python_saga_orchestrator.egg-info → python_saga_orchestrator-0.1.3}/PKG-INFO +13 -1
  2. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/README.md +12 -0
  3. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/pyproject.toml +1 -1
  4. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3/python_saga_orchestrator.egg-info}/PKG-INFO +13 -1
  5. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/SOURCES.txt +11 -1
  6. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/__init__.py +40 -0
  7. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/builder.py +5 -0
  8. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/engine.py +252 -7
  9. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/orchestrator.py +58 -2
  10. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/__init__.py +13 -1
  11. python_saga_orchestrator-0.1.3/saga_orchestrator/domain/models/notify.py +32 -0
  12. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/step.py +3 -0
  13. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/__init__.py +33 -0
  14. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/contracts.py +87 -0
  15. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/dispatcher.py +84 -0
  16. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/event.py +12 -0
  17. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/factory.py +62 -0
  18. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/models.py +71 -0
  19. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/repository.py +154 -0
  20. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/retry.py +20 -0
  21. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/serialization.py +47 -0
  22. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/LICENSE +0 -0
  23. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
  24. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/requires.txt +0 -0
  25. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
  26. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/admin/__init__.py +0 -0
  27. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/admin/api.py +0 -0
  28. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/__init__.py +0 -0
  29. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/repository.py +0 -0
  30. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/__init__.py +0 -0
  31. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
  32. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/exceptions/saga.py +0 -0
  33. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/mixins/__init__.py +0 -0
  34. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
  35. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/builder.py +0 -0
  36. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
  37. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/enums/saga_status.py +0 -0
  38. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/retry.py +0 -0
  39. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
  40. {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/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.3
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.3"
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.3
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,18 @@ 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/contracts.py
32
+ saga_orchestrator/outbox/dispatcher.py
33
+ saga_orchestrator/outbox/event.py
34
+ saga_orchestrator/outbox/factory.py
35
+ saga_orchestrator/outbox/models.py
36
+ saga_orchestrator/outbox/repository.py
37
+ saga_orchestrator/outbox/retry.py
38
+ saga_orchestrator/outbox/serialization.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,50 @@ from .domain.models import (
25
29
  StepRef,
26
30
  )
27
31
  from .domain.models.enums import SagaStatus
32
+ from .outbox import (
33
+ ClaimedOutboxMessage,
34
+ DefaultOutboxMessageFactory,
35
+ FixedOutboxDispatchRetry,
36
+ JsonOutboxSerializer,
37
+ OutboxDispatcher,
38
+ OutboxDispatchRetryPolicy,
39
+ OutboxEvent,
40
+ OutboxMessageFactory,
41
+ OutboxMessageMixin,
42
+ OutboxPublisher,
43
+ OutboxRepository,
44
+ OutboxSerializer,
45
+ OutboxStatus,
46
+ OutboxWriteMessage,
47
+ OutboxWriter,
48
+ )
28
49
 
29
50
  __all__ = [
30
51
  "ActiveSagaAlreadyExistsError",
52
+ "AwaitingEvent",
31
53
  "BaseStep",
54
+ "ClaimedOutboxMessage",
55
+ "DefaultOutboxMessageFactory",
32
56
  "ExponentialRetry",
57
+ "FixedOutboxDispatchRetry",
33
58
  "FixedRetry",
34
59
  "InputContext",
60
+ "JsonOutboxSerializer",
61
+ "NotifyEvent",
62
+ "NotifyResult",
35
63
  "NoRetry",
64
+ "OutboxDispatcher",
65
+ "OutboxDispatchRetryPolicy",
66
+ "OutboxEvent",
67
+ "OutboxMessageFactory",
68
+ "OutboxMap",
69
+ "OutboxMessageMixin",
70
+ "OutboxPublisher",
71
+ "OutboxRepository",
72
+ "OutboxSerializer",
73
+ "OutboxStatus",
74
+ "OutboxWriteMessage",
75
+ "OutboxWriter",
36
76
  "RetryPolicy",
37
77
  "SagaAdmin",
38
78
  "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,21 @@ 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.contracts import OutboxWriter
27
+ from ..outbox.factory import DefaultOutboxMessageFactory, OutboxMessageFactory
28
+ from ..outbox.models import OutboxMessageMixin
29
+ from ..outbox.repository import OutboxRepository
30
+ from ..outbox.serialization import JsonOutboxSerializer, OutboxSerializer
23
31
  from .repository import SagaRepository
24
32
 
25
33
  ModelT = TypeVar("ModelT", bound=SagaStateMixin)
@@ -33,6 +41,10 @@ class SagaEngine(Generic[ModelT]):
33
41
  *,
34
42
  model_class: type[ModelT],
35
43
  session_maker: async_sessionmaker[AsyncSession],
44
+ outbox_model_class: type[OutboxMessageMixin] | None = None,
45
+ outbox_writer: OutboxWriter | None = None,
46
+ outbox_serializer: OutboxSerializer | None = None,
47
+ outbox_message_factory: OutboxMessageFactory | None = None,
36
48
  execution_lease: timedelta = timedelta(minutes=5),
37
49
  ) -> None:
38
50
  """Initialize the engine dependencies and execution lease."""
@@ -40,6 +52,20 @@ class SagaEngine(Generic[ModelT]):
40
52
  self._session_maker = session_maker
41
53
  self._execution_lease = execution_lease
42
54
  self._repository = SagaRepository(model_class)
55
+ self._outbox_repository: OutboxRepository[OutboxMessageMixin] | None = None
56
+ if outbox_writer is not None:
57
+ self._outbox_writer: OutboxWriter | None = outbox_writer
58
+ elif outbox_model_class is not None:
59
+ self._outbox_repository = OutboxRepository(outbox_model_class)
60
+ self._outbox_writer = self._outbox_repository
61
+ else:
62
+ self._outbox_writer = None
63
+ self._outbox_serializer = outbox_serializer or JsonOutboxSerializer(
64
+ normalize=self._serialize_value
65
+ )
66
+ self._outbox_message_factory = (
67
+ outbox_message_factory or DefaultOutboxMessageFactory()
68
+ )
43
69
  self._registry: dict[str, SagaDefinition] = {}
44
70
 
45
71
  @property
@@ -47,6 +73,16 @@ class SagaEngine(Generic[ModelT]):
47
73
  """Return the repository used by the engine."""
48
74
  return self._repository
49
75
 
76
+ @property
77
+ def outbox_repository(self) -> OutboxRepository[OutboxMessageMixin] | None:
78
+ """Return the outbox repository used by the engine."""
79
+ return self._outbox_repository
80
+
81
+ @property
82
+ def outbox_writer(self) -> OutboxWriter | None:
83
+ """Return the outbox writer used by the engine."""
84
+ return self._outbox_writer
85
+
50
86
  def register(self, name: str, saga_definition: SagaDefinition) -> None:
51
87
  """Register a saga definition under a runtime name."""
52
88
  if name in self._registry:
@@ -102,21 +138,110 @@ class SagaEngine(Generic[ModelT]):
102
138
  return saga_id
103
139
 
104
140
  async def notify(
105
- self, *, saga_id: UUID, token: UUID, event: Any | None = None
141
+ self,
142
+ *,
143
+ saga_id: UUID,
144
+ token: UUID,
145
+ event: NotifyEvent | dict[str, Any] | Any | None = None,
106
146
  ) -> bool:
107
147
  """Resume a suspended saga when the provided execution token matches."""
148
+ result = await self.notify_detailed(
149
+ saga_id=saga_id,
150
+ token=token,
151
+ event=event,
152
+ )
153
+ return result == NotifyResult.ACCEPTED
154
+
155
+ async def notify_detailed(
156
+ self,
157
+ *,
158
+ saga_id: UUID,
159
+ token: UUID,
160
+ event: NotifyEvent | dict[str, Any] | Any | None = None,
161
+ ) -> NotifyResult:
162
+ """Resume a suspended saga and return a detailed notify outcome."""
163
+ normalized_event, idempotency_key = self._normalize_notify_event(event)
164
+
108
165
  async with self._session_maker() as session:
109
166
  async with session.begin():
110
167
  saga = await self._repository.get_for_update(session, saga_id)
111
168
  if saga.status != SagaStatus.SUSPENDED:
112
- return False
169
+ self._append_notify_log(
170
+ saga=saga,
171
+ event=normalized_event,
172
+ result=NotifyResult.NOT_SUSPENDED,
173
+ )
174
+ return NotifyResult.NOT_SUSPENDED
113
175
  if saga.step_execution_token != token:
114
176
  logger.info("Ignoring stale notify for saga_id=%s", saga_id)
115
- return False
116
- if event is not None:
177
+ self._append_notify_log(
178
+ saga=saga,
179
+ event=normalized_event,
180
+ result=NotifyResult.STALE_TOKEN,
181
+ )
182
+ return NotifyResult.STALE_TOKEN
183
+
184
+ processed_ids = saga.context.setdefault("processed_event_ids", [])
185
+ if idempotency_key is not None and idempotency_key in processed_ids:
186
+ self._append_notify_log(
187
+ saga=saga,
188
+ event=normalized_event,
189
+ result=NotifyResult.DUPLICATE,
190
+ )
191
+ return NotifyResult.DUPLICATE
192
+
193
+ expected_type = saga.context.get("awaiting_event_type")
194
+ if (
195
+ expected_type is not None
196
+ and normalized_event is not None
197
+ and normalized_event.event_type != expected_type
198
+ ):
199
+ self._append_notify_log(
200
+ saga=saga,
201
+ event=normalized_event,
202
+ result=NotifyResult.EVENT_TYPE_MISMATCH,
203
+ )
204
+ return NotifyResult.EVENT_TYPE_MISMATCH
205
+
206
+ expected_correlation = saga.context.get("awaiting_correlation_id")
207
+ if (
208
+ expected_correlation is not None
209
+ and normalized_event is not None
210
+ and normalized_event.correlation_id != expected_correlation
211
+ ):
212
+ self._append_notify_log(
213
+ saga=saga,
214
+ event=normalized_event,
215
+ result=NotifyResult.CORRELATION_MISMATCH,
216
+ )
217
+ return NotifyResult.CORRELATION_MISMATCH
218
+
219
+ awaiting_until = self._parse_iso_datetime(
220
+ saga.context.get("awaiting_until")
221
+ )
222
+ if awaiting_until is not None and datetime.now(UTC) > awaiting_until:
223
+ self._append_notify_log(
224
+ saga=saga,
225
+ event=normalized_event,
226
+ result=NotifyResult.EXPIRED,
227
+ )
228
+ return NotifyResult.EXPIRED
229
+
230
+ if normalized_event is not None:
117
231
  events = saga.context.setdefault("events", [])
118
- events.append(self._serialize_value(event))
119
- saga.context["latest_event"] = self._serialize_value(event)
232
+ events.append(self._serialize_value(normalized_event.payload))
233
+ saga.context["latest_event"] = self._serialize_value(
234
+ normalized_event.payload
235
+ )
236
+ saga.context["latest_event_meta"] = self._serialize_value(
237
+ normalized_event.model_dump(mode="json")
238
+ )
239
+ if idempotency_key is not None:
240
+ processed_ids.append(idempotency_key)
241
+
242
+ saga.context.pop("awaiting_event_type", None)
243
+ saga.context.pop("awaiting_correlation_id", None)
244
+ saga.context.pop("awaiting_until", None)
120
245
  saga.status = SagaStatus.RUNNING
121
246
  step_def = self._registry[saga.context["saga_name"]].steps[
122
247
  saga.current_step_index
@@ -126,9 +251,50 @@ class SagaEngine(Generic[ModelT]):
126
251
  now=datetime.now(UTC),
127
252
  )
128
253
  saga.step_execution_token = uuid.uuid4()
254
+ self._append_notify_log(
255
+ saga=saga,
256
+ event=normalized_event,
257
+ result=NotifyResult.ACCEPTED,
258
+ )
129
259
 
130
260
  await self._drive(saga_id)
131
- return True
261
+ return NotifyResult.ACCEPTED
262
+
263
+ async def await_event(
264
+ self,
265
+ *,
266
+ saga_id: UUID,
267
+ event: AwaitingEvent,
268
+ ) -> UUID:
269
+ """Configure a suspended saga to wait for a specific external event."""
270
+ async with self._session_maker() as session:
271
+ async with session.begin():
272
+ saga = await self._repository.get_for_update(session, saga_id)
273
+ if saga.status != SagaStatus.SUSPENDED:
274
+ raise SagaStateError(
275
+ "Cannot configure external wait unless saga is suspended "
276
+ f"(status={saga.status.value})"
277
+ )
278
+
279
+ if event.event_type is None:
280
+ saga.context.pop("awaiting_event_type", None)
281
+ else:
282
+ saga.context["awaiting_event_type"] = event.event_type
283
+
284
+ if event.correlation_id is None:
285
+ saga.context.pop("awaiting_correlation_id", None)
286
+ else:
287
+ saga.context["awaiting_correlation_id"] = event.correlation_id
288
+
289
+ if event.until is None:
290
+ saga.context.pop("awaiting_until", None)
291
+ else:
292
+ saga.context["awaiting_until"] = event.until.isoformat()
293
+
294
+ # External event waits should not be auto-resumed by run_due.
295
+ saga.deadline_at = None
296
+ saga.step_execution_token = uuid.uuid4()
297
+ return saga.step_execution_token
132
298
 
133
299
  async def run_due(self, *, limit: int = 100) -> int:
134
300
  """Resume due running, suspended, and compensating sagas."""
@@ -444,6 +610,31 @@ class SagaEngine(Generic[ModelT]):
444
610
  return False
445
611
 
446
612
  if error is None and step_output is not None:
613
+ if step_def.outbox_map is not None:
614
+ if self._outbox_writer is None:
615
+ raise SagaStateError(
616
+ "outbox_map is configured for step "
617
+ f"'{step_def.step_id}', but outbox writer is not configured in SagaEngine"
618
+ )
619
+ outbox_events = (
620
+ step_def.outbox_map(step_input, step_output) or []
621
+ )
622
+ if outbox_events:
623
+ now = datetime.now(UTC)
624
+ outbox_messages = (
625
+ self._outbox_message_factory.build_messages(
626
+ saga_id=saga.id,
627
+ aggregation_id=saga.aggregation_id,
628
+ step_id=step_def.step_id,
629
+ trace_id=saga.trace_id,
630
+ step_input=step_input,
631
+ step_output=step_output,
632
+ events=outbox_events,
633
+ now=now,
634
+ serializer=self._outbox_serializer,
635
+ )
636
+ )
637
+ await self._outbox_writer.save(session, outbox_messages)
447
638
  saga.step_history.append(
448
639
  self._history_entry(
449
640
  phase="execute",
@@ -697,6 +888,60 @@ class SagaEngine(Generic[ModelT]):
697
888
  return [self._serialize_value(item) for item in value]
698
889
  return value
699
890
 
891
+ def _normalize_notify_event(
892
+ self,
893
+ event: NotifyEvent | dict[str, Any] | Any | None,
894
+ ) -> tuple[NotifyEvent | None, str | None]:
895
+ if event is None:
896
+ return None, None
897
+ if isinstance(event, NotifyEvent):
898
+ return event, event.event_id
899
+ if isinstance(event, dict):
900
+ envelope_keys = {
901
+ "event_id",
902
+ "event_type",
903
+ "correlation_id",
904
+ "payload",
905
+ "source",
906
+ "occurred_at",
907
+ }
908
+ if any(key in event for key in envelope_keys):
909
+ notify_event = NotifyEvent.model_validate(event)
910
+ return notify_event, notify_event.event_id
911
+ return NotifyEvent(payload=self._serialize_value(event)), None
912
+ return NotifyEvent(payload=self._serialize_value(event)), None
913
+
914
+ @staticmethod
915
+ def _parse_iso_datetime(value: Any) -> datetime | None:
916
+ if not isinstance(value, str):
917
+ return None
918
+ normalized = value.replace("Z", "+00:00")
919
+ try:
920
+ parsed = datetime.fromisoformat(normalized)
921
+ except ValueError:
922
+ return None
923
+ if parsed.tzinfo is None:
924
+ return parsed.replace(tzinfo=UTC)
925
+ return parsed
926
+
927
+ def _append_notify_log(
928
+ self,
929
+ *,
930
+ saga: ModelT,
931
+ event: NotifyEvent | None,
932
+ result: NotifyResult,
933
+ ) -> None:
934
+ inbox = saga.context.setdefault("notify_inbox", [])
935
+ inbox.append(
936
+ {
937
+ "timestamp": datetime.now(UTC).isoformat(),
938
+ "result": result.value,
939
+ "event_id": event.event_id if event is not None else None,
940
+ "event_type": event.event_type if event is not None else None,
941
+ "correlation_id": (event.correlation_id if event is not None else None),
942
+ }
943
+ )
944
+
700
945
  def _history_entry(
701
946
  self,
702
947
  *,
@@ -8,7 +8,18 @@ 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.contracts import OutboxWriter
19
+ from ..outbox.factory import OutboxMessageFactory
20
+ from ..outbox.models import OutboxMessageMixin
21
+ from ..outbox.repository import OutboxRepository
22
+ from ..outbox.serialization import OutboxSerializer
12
23
  from .engine import SagaEngine
13
24
  from .repository import SagaRepository
14
25
 
@@ -23,12 +34,20 @@ class SagaOrchestrator(Generic[ModelT]):
23
34
  *,
24
35
  model_class: type[ModelT],
25
36
  session_maker: async_sessionmaker[AsyncSession],
37
+ outbox_model_class: type[OutboxMessageMixin] | None = None,
38
+ outbox_writer: OutboxWriter | None = None,
39
+ outbox_serializer: OutboxSerializer | None = None,
40
+ outbox_message_factory: OutboxMessageFactory | None = None,
26
41
  execution_lease: timedelta = timedelta(minutes=5),
27
42
  ) -> None:
28
43
  """Initialize the orchestrator facade."""
29
44
  self._engine = SagaEngine(
30
45
  model_class=model_class,
31
46
  session_maker=session_maker,
47
+ outbox_model_class=outbox_model_class,
48
+ outbox_writer=outbox_writer,
49
+ outbox_serializer=outbox_serializer,
50
+ outbox_message_factory=outbox_message_factory,
32
51
  execution_lease=execution_lease,
33
52
  )
34
53
 
@@ -42,6 +61,16 @@ class SagaOrchestrator(Generic[ModelT]):
42
61
  """Return the repository used by the engine."""
43
62
  return self._engine.repository
44
63
 
64
+ @property
65
+ def outbox_repository(self) -> OutboxRepository[OutboxMessageMixin] | None:
66
+ """Return the outbox repository used by the engine."""
67
+ return self._engine.outbox_repository
68
+
69
+ @property
70
+ def outbox_writer(self) -> OutboxWriter | None:
71
+ """Return the outbox writer used by the engine."""
72
+ return self._engine.outbox_writer
73
+
45
74
  def register(self, name: str, saga_definition: SagaDefinition) -> None:
46
75
  """Register a saga definition under a runtime name."""
47
76
  self._engine.register(name, saga_definition)
@@ -63,11 +92,38 @@ class SagaOrchestrator(Generic[ModelT]):
63
92
  )
64
93
 
65
94
  async def notify(
66
- self, *, saga_id: UUID, token: UUID, event: Any | None = None
95
+ self,
96
+ *,
97
+ saga_id: UUID,
98
+ token: UUID,
99
+ event: NotifyEvent | dict[str, Any] | Any | None = None,
67
100
  ) -> bool:
68
101
  """Resume a suspended saga when the provided execution token matches."""
69
102
  return await self._engine.notify(saga_id=saga_id, token=token, event=event)
70
103
 
104
+ async def notify_detailed(
105
+ self,
106
+ *,
107
+ saga_id: UUID,
108
+ token: UUID,
109
+ event: NotifyEvent | dict[str, Any] | Any | None = None,
110
+ ) -> NotifyResult:
111
+ """Resume a suspended saga and return a detailed notify outcome."""
112
+ return await self._engine.notify_detailed(
113
+ saga_id=saga_id,
114
+ token=token,
115
+ event=event,
116
+ )
117
+
118
+ async def await_event(
119
+ self,
120
+ *,
121
+ saga_id: UUID,
122
+ event: AwaitingEvent,
123
+ ) -> UUID:
124
+ """Configure a suspended saga to wait for an external event."""
125
+ return await self._engine.await_event(saga_id=saga_id, event=event)
126
+
71
127
  async def run_due(self, *, limit: int = 100) -> int:
72
128
  """Resume due running, suspended, and compensating sagas."""
73
129
  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",