python-saga-orchestrator 0.1.3__tar.gz → 0.1.4__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 (46) hide show
  1. {python_saga_orchestrator-0.1.3/python_saga_orchestrator.egg-info → python_saga_orchestrator-0.1.4}/PKG-INFO +20 -1
  2. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/README.md +19 -0
  3. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/pyproject.toml +1 -1
  4. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4/python_saga_orchestrator.egg-info}/PKG-INFO +20 -1
  5. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/python_saga_orchestrator.egg-info/SOURCES.txt +6 -0
  6. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/__init__.py +28 -0
  7. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/core/engine.py +263 -4
  8. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/core/orchestrator.py +35 -0
  9. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/__init__.py +2 -0
  10. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/notify.py +1 -0
  11. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/step.py +42 -7
  12. python_saga_orchestrator-0.1.4/saga_orchestrator/inbox/__init__.py +27 -0
  13. python_saga_orchestrator-0.1.4/saga_orchestrator/inbox/contracts.py +81 -0
  14. python_saga_orchestrator-0.1.4/saga_orchestrator/inbox/dispatcher.py +120 -0
  15. python_saga_orchestrator-0.1.4/saga_orchestrator/inbox/models.py +84 -0
  16. python_saga_orchestrator-0.1.4/saga_orchestrator/inbox/repository.py +165 -0
  17. python_saga_orchestrator-0.1.4/saga_orchestrator/inbox/retry.py +20 -0
  18. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/LICENSE +0 -0
  19. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
  20. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/python_saga_orchestrator.egg-info/requires.txt +0 -0
  21. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
  22. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/admin/__init__.py +0 -0
  23. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/admin/api.py +0 -0
  24. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/core/__init__.py +0 -0
  25. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/core/builder.py +0 -0
  26. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/core/repository.py +0 -0
  27. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/__init__.py +0 -0
  28. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
  29. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/exceptions/saga.py +0 -0
  30. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/mixins/__init__.py +0 -0
  31. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
  32. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/builder.py +0 -0
  33. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
  34. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/enums/saga_status.py +0 -0
  35. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/retry.py +0 -0
  36. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
  37. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/__init__.py +0 -0
  38. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/contracts.py +0 -0
  39. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/dispatcher.py +0 -0
  40. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/event.py +0 -0
  41. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/factory.py +0 -0
  42. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/models.py +0 -0
  43. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/repository.py +0 -0
  44. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/retry.py +0 -0
  45. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/serialization.py +0 -0
  46. {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-saga-orchestrator
3
- Version: 0.1.3
3
+ Version: 0.1.4
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
@@ -51,6 +51,7 @@ Unlike external workflow platforms, this library runs inside your service and st
51
51
  - persisted saga state through `SagaStateMixin`
52
52
  - runtime execution through `SagaOrchestrator` and `SagaEngine`
53
53
  - retry, timeout, recovery, and compensation
54
+ - async queue-style steps through `StepAwaitEvent` and `notify(...)`
54
55
  - administrative operations through `SagaAdmin`
55
56
  - PostgreSQL-first reliability using `SELECT ... FOR UPDATE`
56
57
 
@@ -277,6 +278,23 @@ token = await orchestrator.await_event(
277
278
 
278
279
  The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
279
280
 
281
+ For distributed consumers, use transactional inbox ingestion first, then process inbox rows:
282
+
283
+ ```python
284
+ stored = await orchestrator.ingest_event(
285
+ aggregation_id="order-123",
286
+ event={
287
+ "event_id": "evt-123",
288
+ "event_type": "payment.completed",
289
+ "correlation_id": "corr-123",
290
+ "payload": {"payment_id": "pay-1"},
291
+ },
292
+ )
293
+
294
+ if stored:
295
+ await orchestrator.run_inbox_due(limit=100)
296
+ ```
297
+
280
298
  ## Administrative operations
281
299
 
282
300
  Get the full persisted state:
@@ -334,6 +352,7 @@ A runnable end-to-end example is available in:
334
352
  - [`examples/retry_recovery.py`](./examples/retry_recovery.py)
335
353
  - [`examples/compensation_flow.py`](./examples/compensation_flow.py)
336
354
  - [`examples/admin_skip.py`](./examples/admin_skip.py)
355
+ - [`examples/http_and_queue.py`](./examples/http_and_queue.py)
337
356
 
338
357
  These examples demonstrate:
339
358
  - basic model deployment
@@ -17,6 +17,7 @@ Unlike external workflow platforms, this library runs inside your service and st
17
17
  - persisted saga state through `SagaStateMixin`
18
18
  - runtime execution through `SagaOrchestrator` and `SagaEngine`
19
19
  - retry, timeout, recovery, and compensation
20
+ - async queue-style steps through `StepAwaitEvent` and `notify(...)`
20
21
  - administrative operations through `SagaAdmin`
21
22
  - PostgreSQL-first reliability using `SELECT ... FOR UPDATE`
22
23
 
@@ -243,6 +244,23 @@ token = await orchestrator.await_event(
243
244
 
244
245
  The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
245
246
 
247
+ For distributed consumers, use transactional inbox ingestion first, then process inbox rows:
248
+
249
+ ```python
250
+ stored = await orchestrator.ingest_event(
251
+ aggregation_id="order-123",
252
+ event={
253
+ "event_id": "evt-123",
254
+ "event_type": "payment.completed",
255
+ "correlation_id": "corr-123",
256
+ "payload": {"payment_id": "pay-1"},
257
+ },
258
+ )
259
+
260
+ if stored:
261
+ await orchestrator.run_inbox_due(limit=100)
262
+ ```
263
+
246
264
  ## Administrative operations
247
265
 
248
266
  Get the full persisted state:
@@ -300,6 +318,7 @@ A runnable end-to-end example is available in:
300
318
  - [`examples/retry_recovery.py`](./examples/retry_recovery.py)
301
319
  - [`examples/compensation_flow.py`](./examples/compensation_flow.py)
302
320
  - [`examples/admin_skip.py`](./examples/admin_skip.py)
321
+ - [`examples/http_and_queue.py`](./examples/http_and_queue.py)
303
322
 
304
323
  These examples demonstrate:
305
324
  - basic model deployment
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-saga-orchestrator"
7
- version = "0.1.3"
7
+ version = "0.1.4"
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.3
3
+ Version: 0.1.4
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
@@ -51,6 +51,7 @@ Unlike external workflow platforms, this library runs inside your service and st
51
51
  - persisted saga state through `SagaStateMixin`
52
52
  - runtime execution through `SagaOrchestrator` and `SagaEngine`
53
53
  - retry, timeout, recovery, and compensation
54
+ - async queue-style steps through `StepAwaitEvent` and `notify(...)`
54
55
  - administrative operations through `SagaAdmin`
55
56
  - PostgreSQL-first reliability using `SELECT ... FOR UPDATE`
56
57
 
@@ -277,6 +278,23 @@ token = await orchestrator.await_event(
277
278
 
278
279
  The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
279
280
 
281
+ For distributed consumers, use transactional inbox ingestion first, then process inbox rows:
282
+
283
+ ```python
284
+ stored = await orchestrator.ingest_event(
285
+ aggregation_id="order-123",
286
+ event={
287
+ "event_id": "evt-123",
288
+ "event_type": "payment.completed",
289
+ "correlation_id": "corr-123",
290
+ "payload": {"payment_id": "pay-1"},
291
+ },
292
+ )
293
+
294
+ if stored:
295
+ await orchestrator.run_inbox_due(limit=100)
296
+ ```
297
+
280
298
  ## Administrative operations
281
299
 
282
300
  Get the full persisted state:
@@ -334,6 +352,7 @@ A runnable end-to-end example is available in:
334
352
  - [`examples/retry_recovery.py`](./examples/retry_recovery.py)
335
353
  - [`examples/compensation_flow.py`](./examples/compensation_flow.py)
336
354
  - [`examples/admin_skip.py`](./examples/admin_skip.py)
355
+ - [`examples/http_and_queue.py`](./examples/http_and_queue.py)
337
356
 
338
357
  These examples demonstrate:
339
358
  - basic model deployment
@@ -27,6 +27,12 @@ saga_orchestrator/domain/models/saga_snapshot.py
27
27
  saga_orchestrator/domain/models/step.py
28
28
  saga_orchestrator/domain/models/enums/__init__.py
29
29
  saga_orchestrator/domain/models/enums/saga_status.py
30
+ saga_orchestrator/inbox/__init__.py
31
+ saga_orchestrator/inbox/contracts.py
32
+ saga_orchestrator/inbox/dispatcher.py
33
+ saga_orchestrator/inbox/models.py
34
+ saga_orchestrator/inbox/repository.py
35
+ saga_orchestrator/inbox/retry.py
30
36
  saga_orchestrator/outbox/__init__.py
31
37
  saga_orchestrator/outbox/contracts.py
32
38
  saga_orchestrator/outbox/dispatcher.py
@@ -24,11 +24,26 @@ from .domain.models import (
24
24
  SagaAdminSnapshot,
25
25
  SagaDefinition,
26
26
  SagaSnapshot,
27
+ StepAwaitEvent,
27
28
  StepDefinition,
28
29
  StepInputMap,
29
30
  StepRef,
30
31
  )
31
32
  from .domain.models.enums import SagaStatus
33
+ from .inbox import (
34
+ ClaimedInboxMessage,
35
+ FixedInboxRetry,
36
+ InboxDispatcher,
37
+ InboxMessageMixin,
38
+ InboxProcessor,
39
+ InboxProcessOutcome,
40
+ InboxProcessStatus,
41
+ InboxRepository,
42
+ InboxRetryPolicy,
43
+ InboxStatus,
44
+ InboxWriteMessage,
45
+ InboxWriter,
46
+ )
32
47
  from .outbox import (
33
48
  ClaimedOutboxMessage,
34
49
  DefaultOutboxMessageFactory,
@@ -51,11 +66,23 @@ __all__ = [
51
66
  "ActiveSagaAlreadyExistsError",
52
67
  "AwaitingEvent",
53
68
  "BaseStep",
69
+ "ClaimedInboxMessage",
54
70
  "ClaimedOutboxMessage",
55
71
  "DefaultOutboxMessageFactory",
56
72
  "ExponentialRetry",
73
+ "FixedInboxRetry",
57
74
  "FixedOutboxDispatchRetry",
58
75
  "FixedRetry",
76
+ "InboxDispatcher",
77
+ "InboxMessageMixin",
78
+ "InboxProcessOutcome",
79
+ "InboxProcessStatus",
80
+ "InboxProcessor",
81
+ "InboxRepository",
82
+ "InboxRetryPolicy",
83
+ "InboxStatus",
84
+ "InboxWriteMessage",
85
+ "InboxWriter",
59
86
  "InputContext",
60
87
  "JsonOutboxSerializer",
61
88
  "NotifyEvent",
@@ -88,6 +115,7 @@ __all__ = [
88
115
  "SagaStateMixin",
89
116
  "SagaStatus",
90
117
  "StepDefinition",
118
+ "StepAwaitEvent",
91
119
  "StepInputMap",
92
120
  "StepRef",
93
121
  "TypeValidationError",
@@ -10,7 +10,7 @@ from loguru import logger
10
10
  from pydantic import BaseModel
11
11
  from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
12
12
 
13
- from ..domain.exceptions import SagaDefinitionError, SagaStateError
13
+ from ..domain.exceptions import SagaDefinitionError, SagaNotFoundError, SagaStateError
14
14
  from ..domain.mixins import SagaStateMixin
15
15
  from ..domain.models import (
16
16
  AwaitingEvent,
@@ -20,10 +20,15 @@ from ..domain.models import (
20
20
  SagaAdminSnapshot,
21
21
  SagaDefinition,
22
22
  SagaSnapshot,
23
+ StepAwaitEvent,
23
24
  StepDefinition,
24
25
  )
25
26
  from ..domain.models.enums import SagaStatus
26
- from ..outbox.contracts import OutboxWriter
27
+ from ..inbox.contracts import ClaimedInboxMessage, InboxWriteMessage, InboxWriter
28
+ from ..inbox.dispatcher import InboxDispatcher, InboxProcessOutcome, InboxProcessStatus
29
+ from ..inbox.models import InboxMessageMixin
30
+ from ..inbox.repository import InboxRepository
31
+ from ..outbox.contracts import OutboxWriteMessage, OutboxWriter
27
32
  from ..outbox.factory import DefaultOutboxMessageFactory, OutboxMessageFactory
28
33
  from ..outbox.models import OutboxMessageMixin
29
34
  from ..outbox.repository import OutboxRepository
@@ -41,6 +46,8 @@ class SagaEngine(Generic[ModelT]):
41
46
  *,
42
47
  model_class: type[ModelT],
43
48
  session_maker: async_sessionmaker[AsyncSession],
49
+ inbox_model_class: type[InboxMessageMixin] | None = None,
50
+ inbox_writer: InboxWriter | None = None,
44
51
  outbox_model_class: type[OutboxMessageMixin] | None = None,
45
52
  outbox_writer: OutboxWriter | None = None,
46
53
  outbox_serializer: OutboxSerializer | None = None,
@@ -52,6 +59,21 @@ class SagaEngine(Generic[ModelT]):
52
59
  self._session_maker = session_maker
53
60
  self._execution_lease = execution_lease
54
61
  self._repository = SagaRepository(model_class)
62
+ self._inbox_repository: InboxRepository[InboxMessageMixin] | None = None
63
+ if inbox_writer is not None:
64
+ self._inbox_writer: InboxWriter | None = inbox_writer
65
+ elif inbox_model_class is not None:
66
+ self._inbox_repository = InboxRepository(inbox_model_class)
67
+ self._inbox_writer = self._inbox_repository
68
+ else:
69
+ self._inbox_writer = None
70
+ self._inbox_dispatcher: InboxDispatcher | None = None
71
+ if self._inbox_writer is not None:
72
+ self._inbox_dispatcher = InboxDispatcher(
73
+ session_maker=session_maker,
74
+ writer=self._inbox_writer,
75
+ processor=self,
76
+ )
55
77
  self._outbox_repository: OutboxRepository[OutboxMessageMixin] | None = None
56
78
  if outbox_writer is not None:
57
79
  self._outbox_writer: OutboxWriter | None = outbox_writer
@@ -73,6 +95,16 @@ class SagaEngine(Generic[ModelT]):
73
95
  """Return the repository used by the engine."""
74
96
  return self._repository
75
97
 
98
+ @property
99
+ def inbox_repository(self) -> InboxRepository[InboxMessageMixin] | None:
100
+ """Return the inbox repository used by the engine."""
101
+ return self._inbox_repository
102
+
103
+ @property
104
+ def inbox_writer(self) -> InboxWriter | None:
105
+ """Return the inbox writer used by the engine."""
106
+ return self._inbox_writer
107
+
76
108
  @property
77
109
  def outbox_repository(self) -> OutboxRepository[OutboxMessageMixin] | None:
78
110
  """Return the outbox repository used by the engine."""
@@ -190,7 +222,30 @@ class SagaEngine(Generic[ModelT]):
190
222
  )
191
223
  return NotifyResult.DUPLICATE
192
224
 
225
+ expected_types = saga.context.get("awaiting_event_types")
193
226
  expected_type = saga.context.get("awaiting_event_type")
227
+ if normalized_event is None and (
228
+ expected_type is not None
229
+ or (isinstance(expected_types, list) and len(expected_types) > 0)
230
+ ):
231
+ self._append_notify_log(
232
+ saga=saga,
233
+ event=normalized_event,
234
+ result=NotifyResult.EVENT_TYPE_MISMATCH,
235
+ )
236
+ return NotifyResult.EVENT_TYPE_MISMATCH
237
+ if (
238
+ normalized_event is not None
239
+ and isinstance(expected_types, list)
240
+ and expected_types
241
+ and normalized_event.event_type not in expected_types
242
+ ):
243
+ self._append_notify_log(
244
+ saga=saga,
245
+ event=normalized_event,
246
+ result=NotifyResult.EVENT_TYPE_MISMATCH,
247
+ )
248
+ return NotifyResult.EVENT_TYPE_MISMATCH
194
249
  if (
195
250
  expected_type is not None
196
251
  and normalized_event is not None
@@ -240,6 +295,7 @@ class SagaEngine(Generic[ModelT]):
240
295
  processed_ids.append(idempotency_key)
241
296
 
242
297
  saga.context.pop("awaiting_event_type", None)
298
+ saga.context.pop("awaiting_event_types", None)
243
299
  saga.context.pop("awaiting_correlation_id", None)
244
300
  saga.context.pop("awaiting_until", None)
245
301
  saga.status = SagaStatus.RUNNING
@@ -260,6 +316,48 @@ class SagaEngine(Generic[ModelT]):
260
316
  await self._drive(saga_id)
261
317
  return NotifyResult.ACCEPTED
262
318
 
319
+ async def ingest_event(
320
+ self,
321
+ *,
322
+ event: NotifyEvent | dict[str, Any] | Any,
323
+ saga_id: UUID | None = None,
324
+ aggregation_id: str | None = None,
325
+ ) -> bool:
326
+ """Persist one inbound event into inbox storage for asynchronous processing."""
327
+ if self._inbox_writer is None:
328
+ raise SagaStateError("Inbox writer is not configured in SagaEngine")
329
+
330
+ normalized_event, idempotency_key = self._normalize_notify_event(event)
331
+ if normalized_event is None:
332
+ raise SagaStateError("Inbox ingestion requires a non-empty event payload")
333
+ if idempotency_key is None:
334
+ raise SagaStateError("Inbox ingestion requires event.event_id")
335
+ if saga_id is None and aggregation_id is None:
336
+ raise SagaStateError(
337
+ "Inbox ingestion requires saga_id or aggregation_id for routing"
338
+ )
339
+
340
+ message = InboxWriteMessage(
341
+ event_id=idempotency_key,
342
+ saga_id=saga_id,
343
+ aggregation_id=aggregation_id,
344
+ event_type=normalized_event.event_type,
345
+ correlation_id=normalized_event.correlation_id,
346
+ payload=self._serialize_value(normalized_event.payload),
347
+ source=normalized_event.source,
348
+ occurred_at=normalized_event.occurred_at,
349
+ )
350
+
351
+ async with self._session_maker() as session:
352
+ async with session.begin():
353
+ return await self._inbox_writer.save(session, message)
354
+
355
+ async def run_inbox_due(self, *, limit: int = 100) -> int:
356
+ """Process due inbox events through the configured inbox dispatcher."""
357
+ if self._inbox_dispatcher is None:
358
+ return 0
359
+ return await self._inbox_dispatcher.run_once(limit=limit)
360
+
263
361
  async def await_event(
264
362
  self,
265
363
  *,
@@ -280,6 +378,12 @@ class SagaEngine(Generic[ModelT]):
280
378
  saga.context.pop("awaiting_event_type", None)
281
379
  else:
282
380
  saga.context["awaiting_event_type"] = event.event_type
381
+ if event.event_types is None:
382
+ saga.context.pop("awaiting_event_types", None)
383
+ else:
384
+ saga.context["awaiting_event_types"] = list(event.event_types)
385
+ if event.event_type is None:
386
+ saga.context["awaiting_event_type"] = event.event_types[0]
283
387
 
284
388
  if event.correlation_id is None:
285
389
  saga.context.pop("awaiting_correlation_id", None)
@@ -522,16 +626,21 @@ class SagaEngine(Generic[ModelT]):
522
626
 
523
627
  success = False
524
628
  step_output: BaseModel | None = None
629
+ wait_spec: StepAwaitEvent | None = None
525
630
  error: Exception | None = None
526
631
 
527
632
  try:
528
633
  if step_def.timeout is None:
529
- step_output = await step_def.step.execute(step_input)
634
+ step_result = await step_def.step.execute(step_input)
530
635
  else:
531
- step_output = await asyncio.wait_for(
636
+ step_result = await asyncio.wait_for(
532
637
  step_def.step.execute(step_input),
533
638
  timeout=step_def.timeout.total_seconds(),
534
639
  )
640
+ if isinstance(step_result, StepAwaitEvent):
641
+ wait_spec = step_result
642
+ else:
643
+ step_output = step_result
535
644
  success = True
536
645
  except Exception as exc: # noqa: BLE001
537
646
  error = exc
@@ -542,6 +651,7 @@ class SagaEngine(Generic[ModelT]):
542
651
  token=step_token,
543
652
  step_input=step_input,
544
653
  step_output=step_output,
654
+ wait_spec=wait_spec,
545
655
  error=error,
546
656
  attempt_number=attempt_number,
547
657
  )
@@ -595,6 +705,7 @@ class SagaEngine(Generic[ModelT]):
595
705
  token: UUID,
596
706
  step_input: BaseModel,
597
707
  step_output: BaseModel | None,
708
+ wait_spec: StepAwaitEvent | None,
598
709
  error: Exception | None,
599
710
  attempt_number: int,
600
711
  ) -> bool:
@@ -609,6 +720,75 @@ class SagaEngine(Generic[ModelT]):
609
720
  logger.info("Stale step result ignored for saga_id=%s", saga_id)
610
721
  return False
611
722
 
723
+ if error is None and wait_spec is not None:
724
+ if wait_spec.outbox_events:
725
+ if self._outbox_writer is None:
726
+ raise SagaStateError(
727
+ "Step returned StepAwaitEvent with outbox_events, "
728
+ "but outbox writer is not configured in SagaEngine"
729
+ )
730
+ now = datetime.now(UTC)
731
+ await_messages = [
732
+ OutboxWriteMessage(
733
+ saga_id=saga.id,
734
+ aggregation_id=saga.aggregation_id,
735
+ step_id=step_def.step_id,
736
+ trace_id=saga.trace_id,
737
+ topic=event.topic,
738
+ key=event.key,
739
+ payload=self._outbox_serializer.serialize_payload(
740
+ event.payload
741
+ ),
742
+ headers=self._outbox_serializer.serialize_headers(
743
+ event.headers
744
+ ),
745
+ next_attempt_at=now,
746
+ )
747
+ for event in wait_spec.outbox_events
748
+ ]
749
+ await self._outbox_writer.save(session, await_messages)
750
+
751
+ saga.step_history.append(
752
+ self._history_entry(
753
+ phase="execute",
754
+ status="WAITING",
755
+ step_def=step_def,
756
+ token=token,
757
+ attempt=attempt_number,
758
+ step_input=step_input,
759
+ step_output=None,
760
+ error=None,
761
+ )
762
+ )
763
+ if wait_spec.event_types is None:
764
+ saga.context.pop("awaiting_event_types", None)
765
+ saga.context.pop("awaiting_event_type", None)
766
+ else:
767
+ saga.context["awaiting_event_types"] = list(
768
+ wait_spec.event_types
769
+ )
770
+ saga.context["awaiting_event_type"] = wait_spec.event_types[0]
771
+ if wait_spec.correlation_id is None:
772
+ saga.context.pop("awaiting_correlation_id", None)
773
+ else:
774
+ saga.context["awaiting_correlation_id"] = (
775
+ wait_spec.correlation_id
776
+ )
777
+ if wait_spec.until is None:
778
+ saga.context.pop("awaiting_until", None)
779
+ saga.deadline_at = None
780
+ else:
781
+ until = datetime.now(UTC) + wait_spec.until
782
+ saga.context["awaiting_until"] = until.isoformat()
783
+ saga.deadline_at = until
784
+
785
+ saga.status = SagaStatus.SUSPENDED
786
+ saga.last_error = None
787
+ saga.context.pop("latest_event", None)
788
+ saga.context.pop("latest_event_meta", None)
789
+ saga.step_execution_token = uuid.uuid4()
790
+ return False
791
+
612
792
  if error is None and step_output is not None:
613
793
  if step_def.outbox_map is not None:
614
794
  if self._outbox_writer is None:
@@ -650,6 +830,7 @@ class SagaEngine(Generic[ModelT]):
650
830
  outputs = saga.context.setdefault("step_outputs", {})
651
831
  outputs[step_def.step_id] = self._serialize_value(step_output)
652
832
  saga.context.pop("latest_event", None)
833
+ saga.context.pop("latest_event_meta", None)
653
834
  saga.current_step_index += 1
654
835
  saga.retry_counter = 0
655
836
  saga.deadline_at = None
@@ -842,6 +1023,84 @@ class SagaEngine(Generic[ModelT]):
842
1023
  saga.deadline_at = datetime.now(UTC) + self._execution_lease
843
1024
  return True
844
1025
 
1026
+ async def process(self, message: ClaimedInboxMessage) -> InboxProcessOutcome:
1027
+ """Process one claimed inbox message and map outcome to dispatcher semantics."""
1028
+ saga_id = message.saga_id
1029
+ token: UUID | None = None
1030
+
1031
+ if saga_id is not None:
1032
+ async with self._session_maker() as session:
1033
+ async with session.begin():
1034
+ try:
1035
+ saga = await self._repository.get_for_update(session, saga_id)
1036
+ except SagaNotFoundError:
1037
+ return InboxProcessOutcome(
1038
+ status=InboxProcessStatus.IGNORED,
1039
+ reason="Saga not found",
1040
+ )
1041
+ token = saga.step_execution_token
1042
+ elif message.aggregation_id is not None:
1043
+ async with self._session_maker() as session:
1044
+ async with session.begin():
1045
+ saga = (
1046
+ await self._repository.get_active_by_aggregation_id_for_update(
1047
+ session,
1048
+ message.aggregation_id,
1049
+ )
1050
+ )
1051
+ if saga is None:
1052
+ return InboxProcessOutcome(
1053
+ status=InboxProcessStatus.RETRY,
1054
+ reason="Active saga for aggregation_id not found",
1055
+ )
1056
+ saga_id = saga.id
1057
+ token = saga.step_execution_token
1058
+ else:
1059
+ return InboxProcessOutcome(
1060
+ status=InboxProcessStatus.IGNORED,
1061
+ reason="Inbox message has no saga_id or aggregation_id",
1062
+ )
1063
+
1064
+ if saga_id is None or token is None:
1065
+ return InboxProcessOutcome(
1066
+ status=InboxProcessStatus.RETRY,
1067
+ reason="Saga execution token is not available yet",
1068
+ )
1069
+
1070
+ notify_result = await self.notify_detailed(
1071
+ saga_id=saga_id,
1072
+ token=token,
1073
+ event=NotifyEvent(
1074
+ event_id=message.event_id,
1075
+ event_type=message.event_type,
1076
+ correlation_id=message.correlation_id,
1077
+ payload=message.payload,
1078
+ source=message.source,
1079
+ occurred_at=message.occurred_at,
1080
+ ),
1081
+ )
1082
+ if notify_result in {NotifyResult.ACCEPTED, NotifyResult.DUPLICATE}:
1083
+ return InboxProcessOutcome(status=InboxProcessStatus.APPLIED)
1084
+ if notify_result == NotifyResult.NOT_SUSPENDED:
1085
+ return InboxProcessOutcome(
1086
+ status=InboxProcessStatus.IGNORED,
1087
+ reason=notify_result.value,
1088
+ )
1089
+ if notify_result in {
1090
+ NotifyResult.STALE_TOKEN,
1091
+ NotifyResult.EVENT_TYPE_MISMATCH,
1092
+ NotifyResult.CORRELATION_MISMATCH,
1093
+ NotifyResult.EXPIRED,
1094
+ }:
1095
+ return InboxProcessOutcome(
1096
+ status=InboxProcessStatus.IGNORED,
1097
+ reason=notify_result.value,
1098
+ )
1099
+ return InboxProcessOutcome(
1100
+ status=InboxProcessStatus.IGNORED,
1101
+ reason=notify_result.value,
1102
+ )
1103
+
845
1104
  @staticmethod
846
1105
  def _build_step_input(
847
1106
  step_def: StepDefinition[Any, Any],
@@ -15,6 +15,9 @@ from ..domain.models import (
15
15
  SagaDefinition,
16
16
  SagaSnapshot,
17
17
  )
18
+ from ..inbox.contracts import InboxWriter
19
+ from ..inbox.models import InboxMessageMixin
20
+ from ..inbox.repository import InboxRepository
18
21
  from ..outbox.contracts import OutboxWriter
19
22
  from ..outbox.factory import OutboxMessageFactory
20
23
  from ..outbox.models import OutboxMessageMixin
@@ -34,6 +37,8 @@ class SagaOrchestrator(Generic[ModelT]):
34
37
  *,
35
38
  model_class: type[ModelT],
36
39
  session_maker: async_sessionmaker[AsyncSession],
40
+ inbox_model_class: type[InboxMessageMixin] | None = None,
41
+ inbox_writer: InboxWriter | None = None,
37
42
  outbox_model_class: type[OutboxMessageMixin] | None = None,
38
43
  outbox_writer: OutboxWriter | None = None,
39
44
  outbox_serializer: OutboxSerializer | None = None,
@@ -44,6 +49,8 @@ class SagaOrchestrator(Generic[ModelT]):
44
49
  self._engine = SagaEngine(
45
50
  model_class=model_class,
46
51
  session_maker=session_maker,
52
+ inbox_model_class=inbox_model_class,
53
+ inbox_writer=inbox_writer,
47
54
  outbox_model_class=outbox_model_class,
48
55
  outbox_writer=outbox_writer,
49
56
  outbox_serializer=outbox_serializer,
@@ -66,6 +73,16 @@ class SagaOrchestrator(Generic[ModelT]):
66
73
  """Return the outbox repository used by the engine."""
67
74
  return self._engine.outbox_repository
68
75
 
76
+ @property
77
+ def inbox_repository(self) -> InboxRepository[InboxMessageMixin] | None:
78
+ """Return the inbox repository used by the engine."""
79
+ return self._engine.inbox_repository
80
+
81
+ @property
82
+ def inbox_writer(self) -> InboxWriter | None:
83
+ """Return the inbox writer used by the engine."""
84
+ return self._engine.inbox_writer
85
+
69
86
  @property
70
87
  def outbox_writer(self) -> OutboxWriter | None:
71
88
  """Return the outbox writer used by the engine."""
@@ -101,6 +118,20 @@ class SagaOrchestrator(Generic[ModelT]):
101
118
  """Resume a suspended saga when the provided execution token matches."""
102
119
  return await self._engine.notify(saga_id=saga_id, token=token, event=event)
103
120
 
121
+ async def ingest_event(
122
+ self,
123
+ *,
124
+ event: NotifyEvent | dict[str, Any] | Any,
125
+ saga_id: UUID | None = None,
126
+ aggregation_id: str | None = None,
127
+ ) -> bool:
128
+ """Persist one inbound event into inbox storage for asynchronous processing."""
129
+ return await self._engine.ingest_event(
130
+ event=event,
131
+ saga_id=saga_id,
132
+ aggregation_id=aggregation_id,
133
+ )
134
+
104
135
  async def notify_detailed(
105
136
  self,
106
137
  *,
@@ -128,6 +159,10 @@ class SagaOrchestrator(Generic[ModelT]):
128
159
  """Resume due running, suspended, and compensating sagas."""
129
160
  return await self._engine.run_due(limit=limit)
130
161
 
162
+ async def run_inbox_due(self, *, limit: int = 100) -> int:
163
+ """Process due inbox events through the configured inbox dispatcher."""
164
+ return await self._engine.run_inbox_due(limit=limit)
165
+
131
166
  async def get_snapshot(self, saga_id: UUID) -> SagaSnapshot:
132
167
  """Return the snapshot view of one saga."""
133
168
  return await self._engine.get_snapshot(saga_id)
@@ -8,6 +8,7 @@ from .step import (
8
8
  BaseStep,
9
9
  InputContext,
10
10
  OutboxMap,
11
+ StepAwaitEvent,
11
12
  StepDefinition,
12
13
  StepInputMap,
13
14
  StepRef,
@@ -27,6 +28,7 @@ __all__ = [
27
28
  "StepRef",
28
29
  "InputContext",
29
30
  "OutboxMap",
31
+ "StepAwaitEvent",
30
32
  "StepInputMap",
31
33
  "StepDefinition",
32
34
  "BaseStep",