python-saga-orchestrator 0.5.0__tar.gz → 0.6.0__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 (90) hide show
  1. python_saga_orchestrator-0.6.0/Makefile +16 -0
  2. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/PKG-INFO +1 -1
  3. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/python_saga_orchestrator.egg-info/PKG-INFO +1 -1
  4. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/_version.py +2 -2
  5. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/core/engine.py +16 -27
  6. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/models/context.py +0 -3
  7. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/integration/helpers.py +15 -0
  8. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/integration/test_context_persistence.py +8 -3
  9. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/integration/test_core_flow.py +127 -8
  10. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/integration/test_notification_flow.py +4 -3
  11. python_saga_orchestrator-0.5.0/Makefile +0 -12
  12. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/.github/workflows/ci.yml +0 -0
  13. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/.github/workflows/publish.yml +0 -0
  14. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/.gitignore +0 -0
  15. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/Dockerfile +0 -0
  16. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/LICENSE +0 -0
  17. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/README.md +0 -0
  18. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/docker-compose.yaml +0 -0
  19. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/examples/admin_skip.py +0 -0
  20. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/examples/common.py +0 -0
  21. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/examples/compensation_flow.py +0 -0
  22. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/examples/http_and_queue.py +0 -0
  23. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/examples/llm_deploy.py +0 -0
  24. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/examples/retry_recovery.py +0 -0
  25. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/pyproject.toml +0 -0
  26. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/python_saga_orchestrator.egg-info/SOURCES.txt +0 -0
  27. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
  28. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/python_saga_orchestrator.egg-info/requires.txt +0 -0
  29. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
  30. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/__init__.py +0 -0
  31. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/admin/__init__.py +0 -0
  32. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/admin/api.py +0 -0
  33. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/core/__init__.py +0 -0
  34. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/core/builder.py +0 -0
  35. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/core/orchestrator.py +0 -0
  36. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/core/repository.py +0 -0
  37. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/__init__.py +0 -0
  38. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
  39. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/exceptions/saga.py +0 -0
  40. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/mixins/__init__.py +0 -0
  41. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
  42. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/mixins/saga_step_histrory.py +0 -0
  43. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/mixins/types.py +0 -0
  44. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/models/__init__.py +0 -0
  45. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/models/builder.py +0 -0
  46. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
  47. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/models/enums/base_str_enum.py +0 -0
  48. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/models/enums/saga_status.py +0 -0
  49. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/models/enums/saga_step_phase.py +0 -0
  50. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/models/enums/saga_step_status.py +0 -0
  51. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/models/notify.py +0 -0
  52. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/models/retry.py +0 -0
  53. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
  54. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/domain/models/step.py +0 -0
  55. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/inbox/__init__.py +0 -0
  56. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/inbox/contracts.py +0 -0
  57. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/inbox/dispatcher.py +0 -0
  58. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/inbox/models.py +0 -0
  59. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/inbox/repository.py +0 -0
  60. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/inbox/retry.py +0 -0
  61. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/outbox/__init__.py +0 -0
  62. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/outbox/contracts.py +0 -0
  63. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/outbox/dispatcher.py +0 -0
  64. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/outbox/event.py +0 -0
  65. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/outbox/factory.py +0 -0
  66. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/outbox/models.py +0 -0
  67. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/outbox/repository.py +0 -0
  68. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/outbox/retry.py +0 -0
  69. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/saga_orchestrator/outbox/serialization.py +0 -0
  70. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/setup.cfg +0 -0
  71. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/task.md +0 -0
  72. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/__init__.py +0 -0
  73. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/conftest.py +0 -0
  74. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/integration/__init__.py +0 -0
  75. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/integration/conftest.py +0 -0
  76. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/integration/models.py +0 -0
  77. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/integration/test_admin_api.py +0 -0
  78. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/integration/test_compensation_flow.py +0 -0
  79. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/integration/test_inbox_flow.py +0 -0
  80. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/integration/test_lifecycle_hooks.py +0 -0
  81. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/integration/test_outbox_flow.py +0 -0
  82. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/integration/test_repository.py +0 -0
  83. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/unit/__init__.py +0 -0
  84. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/unit/test_builder.py +0 -0
  85. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/unit/test_inbox_extensibility.py +0 -0
  86. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/unit/test_input_context.py +0 -0
  87. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/unit/test_orchestrator_helpers.py +0 -0
  88. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/unit/test_outbox_extensibility.py +0 -0
  89. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/unit/test_retry.py +0 -0
  90. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.6.0}/tests/unit/test_step_type_resolution.py +0 -0
@@ -0,0 +1,16 @@
1
+ .PHONY: format tests lint
2
+
3
+ lint: format
4
+ test: tests
5
+
6
+
7
+ format:
8
+ @ruff format .
9
+ @ruff check . --fix
10
+
11
+ tests:
12
+ docker compose up \
13
+ --build \
14
+ --abort-on-container-exit \
15
+ --exit-code-from tests
16
+ docker compose down -v
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-saga-orchestrator
3
- Version: 0.5.0
3
+ Version: 0.6.0
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-saga-orchestrator
3
- Version: 0.5.0
3
+ Version: 0.6.0
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
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.5.0'
22
- __version_tuple__ = version_tuple = (0, 5, 0)
21
+ __version__ = version = '0.6.0'
22
+ __version_tuple__ = version_tuple = (0, 6, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -261,23 +261,17 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
261
261
  return NotifyResult.DUPLICATE
262
262
 
263
263
  expected_types: tuple[str, ...] = context.awaiting_event_types
264
- expected_type: str | None = context.awaiting_event_type
265
- if normalized_event is None and (
266
- expected_type is not None
267
- or (
268
- isinstance(expected_types, (list, tuple))
269
- and len(expected_types) > 0
270
- )
271
- ):
264
+
265
+ if normalized_event is None and expected_types:
272
266
  self._append_notify_log(
273
267
  saga=saga,
274
268
  event=normalized_event,
275
269
  result=NotifyResult.EVENT_TYPE_MISMATCH,
276
270
  )
277
271
  return NotifyResult.EVENT_TYPE_MISMATCH
272
+
278
273
  if (
279
274
  normalized_event is not None
280
- and isinstance(expected_types, (list, tuple))
281
275
  and expected_types
282
276
  and normalized_event.event_type not in expected_types
283
277
  ):
@@ -287,17 +281,6 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
287
281
  result=NotifyResult.EVENT_TYPE_MISMATCH,
288
282
  )
289
283
  return NotifyResult.EVENT_TYPE_MISMATCH
290
- if (
291
- expected_type is not None
292
- and normalized_event is not None
293
- and normalized_event.event_type != expected_type
294
- ):
295
- self._append_notify_log(
296
- saga=saga,
297
- event=normalized_event,
298
- result=NotifyResult.EVENT_TYPE_MISMATCH,
299
- )
300
- return NotifyResult.EVENT_TYPE_MISMATCH
301
284
 
302
285
  expected_correlation: str | None = context.awaiting_correlation_id
303
286
  if (
@@ -497,16 +480,17 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
497
480
 
498
481
  def _is_waiting_for_events(self, saga) -> bool:
499
482
  """Проверяет, ожидает ли сага внешних событий."""
500
- context = saga.context
501
- return bool(context.awaiting_event_type or context.awaiting_event_types)
483
+ context: SagaContext = saga.context
484
+ return bool(context.awaiting_event_types or context.awaiting_correlation_id)
502
485
 
503
486
  def _handle_saga_timeout(self, saga, now: datetime) -> None:
504
- """логика обработки таймаута ожидания событий."""
487
+ """Логика обработки таймаута ожидания событий."""
505
488
  context = saga.context
506
- event_types = context.awaiting_event_types or context.awaiting_event_type
507
489
 
508
490
  saga.status = SagaStatus.TIMEOUT
509
- saga.last_error = f"Timed out waiting for event(s): {event_types}"
491
+ saga.last_error = (
492
+ f"Timed out waiting for event(s): {context.awaiting_event_types}"
493
+ )
510
494
  saga.deadline_at = None
511
495
  saga.step_execution_token = uuid.uuid4()
512
496
 
@@ -528,6 +512,7 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
528
512
  skipped=False,
529
513
  )
530
514
  )
515
+ context.clear_awaiting_state()
531
516
 
532
517
  async def get_snapshot(self, saga_id: UUID) -> SagaSnapshot:
533
518
  """Return the snapshot view of one saga."""
@@ -612,6 +597,7 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
612
597
  step_def,
613
598
  now=datetime.now(UTC),
614
599
  )
600
+ saga.context.clear_awaiting_state()
615
601
 
616
602
  await self._drive(saga_id)
617
603
 
@@ -623,12 +609,13 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
623
609
  if saga.status not in {
624
610
  SagaStatus.SUSPENDED,
625
611
  SagaStatus.FAILED,
612
+ SagaStatus.TIMEOUT,
626
613
  SagaStatus.COMPENSATING,
627
614
  SagaStatus.COMPENSATING_SUSPENDED,
628
615
  }:
629
616
  raise SagaStateError(
630
617
  "Cannot start compensation unless saga is suspended, failed, "
631
- f"or already compensating (status={saga.status})"
618
+ f"timed out, or already compensating (status={saga.status})"
632
619
  )
633
620
  if saga.current_step_index <= 0:
634
621
  raise SagaStateError(
@@ -639,6 +626,7 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
639
626
  saga.retry_counter = 0
640
627
  saga.step_execution_token = uuid.uuid4()
641
628
  saga.deadline_at = datetime.now(UTC) + self._execution_lease
629
+ saga.context.clear_awaiting_state()
642
630
 
643
631
  await self._run_compensation(saga_id)
644
632
 
@@ -679,6 +667,7 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
679
667
  saga.deadline_at = None
680
668
  saga.last_error = error_message
681
669
  saga.step_execution_token = uuid.uuid4()
670
+ saga.context.clear_awaiting_state()
682
671
 
683
672
  async def skip_step(
684
673
  self,
@@ -731,7 +720,7 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
731
720
  saga.context.save_step_output(
732
721
  step_def.step_id, output_model.model_dump(mode="json")
733
722
  )
734
-
723
+ saga.context.clear_awaiting_state()
735
724
  saga.current_step_index += 1
736
725
  saga.retry_counter = 0
737
726
  saga.last_error = None
@@ -78,7 +78,6 @@ class SagaContext(BaseModel):
78
78
  ) # For idempotency
79
79
 
80
80
  # -- Awaiting state --
81
- awaiting_event_type: str | None = None
82
81
  awaiting_event_types: tuple[str, ...] = Field(default_factory=tuple)
83
82
  awaiting_correlation_id: str | None = None
84
83
  awaiting_until: str | None = None # ISO 8601 format
@@ -111,12 +110,10 @@ class SagaContext(BaseModel):
111
110
  until: str | None,
112
111
  ) -> None:
113
112
  self.awaiting_event_types = event_types
114
- self.awaiting_event_type = event_types[0] if event_types else None
115
113
  self.awaiting_correlation_id = correlation_id
116
114
  self.awaiting_until = until
117
115
 
118
116
  def clear_awaiting_state(self) -> None:
119
- self.awaiting_event_type = None
120
117
  self.awaiting_event_types = ()
121
118
  self.awaiting_correlation_id = None
122
119
  self.awaiting_until = None
@@ -334,6 +334,21 @@ class WaitingWithTimeoutStep(BaseStep[StartInput, StartOutput]):
334
334
  )
335
335
 
336
336
 
337
+ class RetryWaitInput(BaseModel):
338
+ value: int
339
+ event_type: str | None = None
340
+
341
+
342
+ class RetryWaitStep(BaseStep[RetryWaitInput, StartOutput]):
343
+ async def execute(self, inp: RetryWaitInput) -> StepAwaitEvent | StartOutput:
344
+ if inp.event_type is None:
345
+ return StepAwaitEvent(
346
+ event_types=("some.event",),
347
+ until=timedelta(milliseconds=10),
348
+ )
349
+ return StartOutput(value=inp.value + 1)
350
+
351
+
337
352
  class SagaStartedEvent(BaseModel):
338
353
  saga_id: uuid.UUID
339
354
  initial_value: int
@@ -7,6 +7,7 @@ import pytest
7
7
  from sqlalchemy.ext.asyncio import AsyncSession
8
8
  from sqlalchemy.orm import selectinload
9
9
 
10
+ from saga_orchestrator import SagaStateMixin
10
11
  from saga_orchestrator.domain.models.context import SagaContext
11
12
  from saga_orchestrator.domain.models.enums import SagaStepPhase, SagaStepStatus
12
13
  from tests.integration.helpers import StartInput
@@ -70,12 +71,16 @@ async def test_top_level_attribute_change_is_persisted(session_maker):
70
71
 
71
72
  async with session_maker() as session:
72
73
  saga = await session.get(IntegrationSagaState, saga_id)
73
- saga.context.awaiting_event_type = new_awaiting_type
74
+ saga.context.awaiting_event_types = (new_awaiting_type,)
74
75
  await session.commit()
75
76
 
76
77
  async with session_maker() as session:
77
- reloaded_saga = await session.get(IntegrationSagaState, saga_id)
78
- assert reloaded_saga.context.awaiting_event_type == new_awaiting_type
78
+ reloaded_saga: SagaStateMixin | None = await session.get(
79
+ IntegrationSagaState, saga_id
80
+ )
81
+ assert reloaded_saga is not None
82
+ assert len(reloaded_saga.context.awaiting_event_types) == 1
83
+ assert new_awaiting_type in reloaded_saga.context.awaiting_event_types
79
84
 
80
85
 
81
86
  @pytest.mark.asyncio
@@ -5,7 +5,13 @@ from datetime import timedelta
5
5
 
6
6
  import pytest
7
7
 
8
- from saga_orchestrator import SagaAdmin, SagaBuilder, SagaStepPhase, SagaStepStatus
8
+ from saga_orchestrator import (
9
+ SagaAdmin,
10
+ SagaAdminSnapshot,
11
+ SagaBuilder,
12
+ SagaStepPhase,
13
+ SagaStepStatus,
14
+ )
9
15
  from saga_orchestrator.core.orchestrator import SagaOrchestrator
10
16
  from saga_orchestrator.domain.exceptions import ActiveSagaAlreadyExistsError
11
17
  from saga_orchestrator.domain.models import ExponentialRetry
@@ -17,6 +23,7 @@ from tests.integration.helpers import (
17
23
  ActivateQueueStep,
18
24
  AddOneStep,
19
25
  AlwaysFailStep,
26
+ CompensatingStep,
20
27
  FailsOnceStep,
21
28
  FlakyStep,
22
29
  HttpInput,
@@ -25,6 +32,8 @@ from tests.integration.helpers import (
25
32
  RecoverableStep,
26
33
  ReserveQueueInput,
27
34
  ReserveQueueStep,
35
+ RetryWaitInput,
36
+ RetryWaitStep,
28
37
  StartInput,
29
38
  WaitingWithTimeoutStep,
30
39
  )
@@ -308,10 +317,12 @@ async def test_three_step_http_and_queue_style_flow(session_maker):
308
317
  aggregation_id="agg-http-queue-3",
309
318
  )
310
319
 
311
- state_after_start = await admin.get_saga(saga_id)
320
+ state_after_start: SagaAdminSnapshot = await admin.get_saga(saga_id)
312
321
  assert state_after_start.status == SagaStatus.SUSPENDED
313
322
  assert state_after_start.current_step_index == 1
314
- assert state_after_start.context.awaiting_event_type == "reserve.success"
323
+ assert len(state_after_start.context.awaiting_event_types) == 2
324
+ assert "reserve.success" in state_after_start.context.awaiting_event_types
325
+ assert "reserve.failed" in state_after_start.context.awaiting_event_types
315
326
 
316
327
  processed = await dispatcher.run_once(limit=10)
317
328
  assert processed == 1
@@ -323,11 +334,11 @@ async def test_three_step_http_and_queue_style_flow(session_maker):
323
334
  reserve_token = (await admin.get_saga(saga_id)).step_execution_token
324
335
  await orchestrator.notify(
325
336
  saga_id=saga_id,
326
- token=reserve_token, # type: ignore[arg-type]
337
+ token=reserve_token,
327
338
  event=NotifyEvent(
328
339
  event_id="evt-reserve-1",
329
340
  event_type="reserve.success",
330
- correlation_id=first_headers["correlation_id"], # type: ignore[index]
341
+ correlation_id=first_headers["correlation_id"],
331
342
  payload={"reservation_id": "res-200"},
332
343
  ),
333
344
  )
@@ -335,7 +346,9 @@ async def test_three_step_http_and_queue_style_flow(session_maker):
335
346
  state_after_reserve = await admin.get_saga(saga_id)
336
347
  assert state_after_reserve.status == SagaStatus.SUSPENDED
337
348
  assert state_after_reserve.current_step_index == 2
338
- assert state_after_reserve.context.awaiting_event_type == "activate.success"
349
+ assert len(state_after_start.context.awaiting_event_types) == 2
350
+ assert "activate.success" in state_after_reserve.context.awaiting_event_types
351
+ assert "activate.failed" in state_after_reserve.context.awaiting_event_types
339
352
 
340
353
  processed = await dispatcher.run_once(limit=10)
341
354
  assert processed == 1
@@ -347,11 +360,11 @@ async def test_three_step_http_and_queue_style_flow(session_maker):
347
360
  activate_token = (await admin.get_saga(saga_id)).step_execution_token
348
361
  await orchestrator.notify(
349
362
  saga_id=saga_id,
350
- token=activate_token, # type: ignore[arg-type]
363
+ token=activate_token,
351
364
  event=NotifyEvent(
352
365
  event_id="evt-activate-1",
353
366
  event_type="activate.success",
354
- correlation_id=second_headers["correlation_id"], # type: ignore[index]
367
+ correlation_id=second_headers["correlation_id"],
355
368
  payload={"deployment_id": "dep-200"},
356
369
  ),
357
370
  )
@@ -446,3 +459,109 @@ async def test_timed_out_status_on_await_event_deadline(session_maker):
446
459
  assert state_after.step_history[1].status == SagaStepStatus.TIMEOUT
447
460
  assert state_after.step_history[1].phase == SagaStepPhase.EXECUTE
448
461
  assert "Timed out" in state_after.step_history[1].error
462
+
463
+ assert state_after.context.awaiting_event_types == ()
464
+ assert state_after.context.awaiting_correlation_id is None
465
+ assert state_after.context.awaiting_until is None
466
+
467
+
468
+ @pytest.mark.asyncio
469
+ async def test_compensate_after_timeout_resolves_deadlock(session_maker):
470
+ """
471
+ Проверяет возможность запуска компенсации для саги, зависшей со статусом TIMEOUT.
472
+ Убеждается, что админ может спасти сагу без правки БД.
473
+ """
474
+ first_step = CompensatingStep()
475
+ builder = SagaBuilder()
476
+ ref = builder.add_step(
477
+ step=first_step,
478
+ input_map=lambda ctx: StartInput(value=ctx.initial_data["value"]),
479
+ )
480
+ builder.add_step(
481
+ step=WaitingWithTimeoutStep(),
482
+ depends_on=ref,
483
+ input_map=lambda out: StartInput(value=out.value),
484
+ )
485
+
486
+ orchestrator = SagaOrchestrator[IntegrationSagaState, IntegrationSagaHistory](
487
+ model_class=IntegrationSagaState,
488
+ history_model_class=IntegrationSagaHistory,
489
+ session_maker=session_maker,
490
+ )
491
+ admin = SagaAdmin[IntegrationSagaState, IntegrationSagaHistory](
492
+ engine=orchestrator.engine
493
+ )
494
+ orchestrator.register("comp-timeout", builder.build())
495
+
496
+ saga_id = await orchestrator.start(
497
+ saga_name="comp-timeout",
498
+ initial_data={"value": 1},
499
+ aggregation_id="agg-comp-timeout",
500
+ )
501
+
502
+ await asyncio.sleep(0.1)
503
+ await orchestrator.run_due()
504
+
505
+ state = await admin.get_saga(saga_id)
506
+ assert state.status == SagaStatus.TIMEOUT
507
+
508
+ await admin.compensate_step(saga_id)
509
+
510
+ final_state = await admin.get_saga(saga_id)
511
+ assert final_state.status == SagaStatus.COMPENSATED
512
+ assert first_step.compensated is True
513
+
514
+
515
+ @pytest.mark.asyncio
516
+ async def test_retry_after_timeout_processes_successfully(session_maker):
517
+ """
518
+ Проверяет успешный ретрай саги после таймаута. Убеждается, что нет мгновенного
519
+ повторного таймаута из-за старого неочищенного кеша ожидания.
520
+ """
521
+
522
+ builder = SagaBuilder()
523
+ builder.add_step(
524
+ step=RetryWaitStep(),
525
+ input_map=lambda ctx: RetryWaitInput(
526
+ value=ctx.initial_data["value"],
527
+ event_type=ctx.latest_event_type,
528
+ ),
529
+ )
530
+
531
+ orchestrator = SagaOrchestrator[IntegrationSagaState, IntegrationSagaHistory](
532
+ model_class=IntegrationSagaState,
533
+ history_model_class=IntegrationSagaHistory,
534
+ session_maker=session_maker,
535
+ )
536
+ admin = SagaAdmin[IntegrationSagaState, IntegrationSagaHistory](
537
+ engine=orchestrator.engine
538
+ )
539
+ orchestrator.register("retry-timeout", builder.build())
540
+
541
+ saga_id = await orchestrator.start(
542
+ saga_name="retry-timeout",
543
+ initial_data={"value": 1},
544
+ aggregation_id="agg-retry-timeout",
545
+ )
546
+
547
+ await asyncio.sleep(0.1)
548
+ await orchestrator.run_due()
549
+ state1 = await admin.get_saga(saga_id)
550
+ assert state1.status == SagaStatus.TIMEOUT
551
+
552
+ await admin.retry_step(saga_id)
553
+ state2 = await admin.get_saga(saga_id)
554
+
555
+ assert state2.status == SagaStatus.SUSPENDED
556
+ assert state2.context.awaiting_event_types == ("some.event",)
557
+
558
+ await orchestrator.notify(
559
+ saga_id=saga_id,
560
+ token=state2.step_execution_token,
561
+ event=NotifyEvent(
562
+ event_id="evt-retry-123", event_type="some.event", payload={"value": 42}
563
+ ),
564
+ )
565
+
566
+ final_state = await admin.get_saga(saga_id)
567
+ assert final_state.status == SagaStatus.COMPLETED
@@ -5,7 +5,7 @@ from datetime import timedelta
5
5
 
6
6
  import pytest
7
7
 
8
- from saga_orchestrator import SagaAdmin, SagaBuilder
8
+ from saga_orchestrator import SagaAdmin, SagaAdminSnapshot, SagaBuilder
9
9
  from saga_orchestrator.core.orchestrator import SagaOrchestrator
10
10
  from saga_orchestrator.domain.models import ExponentialRetry
11
11
  from saga_orchestrator.domain.models.enums import SagaStatus
@@ -94,12 +94,13 @@ async def test_await_event_configures_wait_contract(session_maker):
94
94
  ),
95
95
  )
96
96
 
97
- state_after = await admin.get_saga(saga_id)
97
+ state_after: SagaAdminSnapshot = await admin.get_saga(saga_id)
98
98
  assert state_after.status == SagaStatus.SUSPENDED
99
99
  assert state_after.step_execution_token == new_token
100
100
  assert state_after.step_execution_token != state_before.step_execution_token
101
101
  assert state_after.deadline_at is None
102
- assert state_after.context.awaiting_event_type == "model.approved"
102
+ assert len(state_after.context.awaiting_event_types) == 1
103
+ assert "model.approved" in state_after.context.awaiting_event_types
103
104
  assert state_after.context.awaiting_correlation_id == "corr-await"
104
105
  assert state_after.context.awaiting_until == "2999-01-01T00:00:00+00:00"
105
106
 
@@ -1,12 +0,0 @@
1
- .PHONY: format tests lint
2
-
3
- lint: format
4
- test: tests
5
-
6
-
7
- format:
8
- @ruff format .
9
- @ruff check . --fix
10
-
11
- tests:
12
- @docker compose up --build --abort-on-container-exit