python-saga-orchestrator 0.2.4__tar.gz → 0.4.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 (92) hide show
  1. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/PKG-INFO +1 -1
  2. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/python_saga_orchestrator.egg-info/PKG-INFO +1 -1
  3. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/python_saga_orchestrator.egg-info/SOURCES.txt +1 -0
  4. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/_version.py +2 -2
  5. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/core/engine.py +100 -20
  6. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/core/repository.py +1 -1
  7. python_saga_orchestrator-0.4.0/saga_orchestrator/domain/models/enums/base_str_enum.py +10 -0
  8. python_saga_orchestrator-0.4.0/saga_orchestrator/domain/models/enums/saga_status.py +18 -0
  9. python_saga_orchestrator-0.4.0/saga_orchestrator/domain/models/enums/saga_step_phase.py +9 -0
  10. python_saga_orchestrator-0.4.0/saga_orchestrator/domain/models/enums/saga_step_status.py +10 -0
  11. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/domain/models/step.py +16 -7
  12. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/integration/helpers.py +8 -1
  13. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/integration/test_admin_api.py +93 -6
  14. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/integration/test_core_flow.py +49 -1
  15. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/unit/test_input_context.py +3 -3
  16. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/unit/test_step_type_resolution.py +11 -5
  17. python_saga_orchestrator-0.2.4/saga_orchestrator/domain/models/enums/saga_status.py +0 -15
  18. python_saga_orchestrator-0.2.4/saga_orchestrator/domain/models/enums/saga_step_phase.py +0 -7
  19. python_saga_orchestrator-0.2.4/saga_orchestrator/domain/models/enums/saga_step_status.py +0 -7
  20. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/.github/workflows/ci.yml +0 -0
  21. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/.github/workflows/publish.yml +0 -0
  22. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/.gitignore +0 -0
  23. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/Dockerfile +0 -0
  24. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/LICENSE +0 -0
  25. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/Makefile +0 -0
  26. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/README.md +0 -0
  27. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/docker-compose.yaml +0 -0
  28. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/examples/admin_skip.py +0 -0
  29. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/examples/common.py +0 -0
  30. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/examples/compensation_flow.py +0 -0
  31. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/examples/http_and_queue.py +0 -0
  32. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/examples/llm_deploy.py +0 -0
  33. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/examples/retry_recovery.py +0 -0
  34. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/pyproject.toml +0 -0
  35. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
  36. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/python_saga_orchestrator.egg-info/requires.txt +0 -0
  37. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
  38. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/__init__.py +0 -0
  39. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/admin/__init__.py +0 -0
  40. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/admin/api.py +0 -0
  41. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/core/__init__.py +0 -0
  42. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/core/builder.py +0 -0
  43. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/core/orchestrator.py +0 -0
  44. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/domain/__init__.py +0 -0
  45. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
  46. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/domain/exceptions/saga.py +0 -0
  47. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/domain/mixins/__init__.py +0 -0
  48. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
  49. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/domain/mixins/saga_step_histrory.py +0 -0
  50. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/domain/mixins/types.py +0 -0
  51. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/domain/models/__init__.py +0 -0
  52. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/domain/models/builder.py +0 -0
  53. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/domain/models/context.py +0 -0
  54. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
  55. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/domain/models/notify.py +0 -0
  56. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/domain/models/retry.py +0 -0
  57. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
  58. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/inbox/__init__.py +0 -0
  59. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/inbox/contracts.py +0 -0
  60. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/inbox/dispatcher.py +0 -0
  61. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/inbox/models.py +0 -0
  62. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/inbox/repository.py +0 -0
  63. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/inbox/retry.py +0 -0
  64. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/outbox/__init__.py +0 -0
  65. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/outbox/contracts.py +0 -0
  66. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/outbox/dispatcher.py +0 -0
  67. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/outbox/event.py +0 -0
  68. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/outbox/factory.py +0 -0
  69. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/outbox/models.py +0 -0
  70. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/outbox/repository.py +0 -0
  71. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/outbox/retry.py +0 -0
  72. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/saga_orchestrator/outbox/serialization.py +0 -0
  73. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/setup.cfg +0 -0
  74. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/task.md +0 -0
  75. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/__init__.py +0 -0
  76. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/conftest.py +0 -0
  77. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/integration/__init__.py +0 -0
  78. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/integration/conftest.py +0 -0
  79. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/integration/models.py +0 -0
  80. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/integration/test_compensation_flow.py +0 -0
  81. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/integration/test_context_persistence.py +0 -0
  82. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/integration/test_inbox_flow.py +0 -0
  83. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/integration/test_lifecycle_hooks.py +0 -0
  84. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/integration/test_notification_flow.py +0 -0
  85. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/integration/test_outbox_flow.py +0 -0
  86. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/integration/test_repository.py +0 -0
  87. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/unit/__init__.py +0 -0
  88. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/unit/test_builder.py +0 -0
  89. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/unit/test_inbox_extensibility.py +0 -0
  90. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/unit/test_orchestrator_helpers.py +0 -0
  91. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/unit/test_outbox_extensibility.py +0 -0
  92. {python_saga_orchestrator-0.2.4 → python_saga_orchestrator-0.4.0}/tests/unit/test_retry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-saga-orchestrator
3
- Version: 0.2.4
3
+ Version: 0.4.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.2.4
3
+ Version: 0.4.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
@@ -43,6 +43,7 @@ saga_orchestrator/domain/models/retry.py
43
43
  saga_orchestrator/domain/models/saga_snapshot.py
44
44
  saga_orchestrator/domain/models/step.py
45
45
  saga_orchestrator/domain/models/enums/__init__.py
46
+ saga_orchestrator/domain/models/enums/base_str_enum.py
46
47
  saga_orchestrator/domain/models/enums/saga_status.py
47
48
  saga_orchestrator/domain/models/enums/saga_step_phase.py
48
49
  saga_orchestrator/domain/models/enums/saga_step_status.py
@@ -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.2.4'
22
- __version_tuple__ = version_tuple = (0, 2, 4)
21
+ __version__ = version = '0.4.0'
22
+ __version_tuple__ = version_tuple = (0, 4, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -444,43 +444,91 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
444
444
 
445
445
  async with self._session_maker() as session:
446
446
  async with session.begin():
447
- due_running = await self._repository.due_running(
447
+ sagas_running = await self._repository.due_running(
448
448
  session, now=now, limit=limit
449
449
  )
450
- remaining = max(limit - len(due_running), 0)
451
- due_suspended = await self._repository.due_suspended(
452
- session, now=now, limit=remaining
450
+ limit = max(0, limit - len(sagas_running))
451
+
452
+ sagas_suspended = await self._repository.due_suspended(
453
+ session, now=now, limit=limit
453
454
  )
454
- remaining -= len(due_suspended)
455
- due_compensating = await self._repository.due_compensating(
456
- session, now=now, limit=remaining
455
+ limit = max(0, limit - len(sagas_suspended))
456
+
457
+ sagas_compensating = await self._repository.due_compensating(
458
+ session, now=now, limit=limit
457
459
  )
458
- remaining -= len(due_compensating)
460
+ limit = max(0, limit - len(sagas_compensating))
459
461
 
460
- due_compensating_suspended = (
462
+ sagas_comp_suspended = (
461
463
  await self._repository.due_compensating_suspended(
462
- session, now=now, limit=max(remaining, 0)
464
+ session, now=now, limit=limit
463
465
  )
464
466
  )
465
467
 
466
- for saga in [*due_running, *due_suspended]:
467
- saga.status = SagaStatus.RUNNING
468
- saga.step_execution_token = uuid.uuid4()
469
- saga.deadline_at = now + self._execution_lease
468
+ for saga in sagas_running:
469
+ self._prepare_saga_for_resume(saga, SagaStatus.RUNNING, now)
470
470
  ready_ids.append(saga.id)
471
471
 
472
- for saga in [*due_compensating, *due_compensating_suspended]:
473
- saga.status = SagaStatus.COMPENSATING
474
- saga.step_execution_token = uuid.uuid4()
475
- saga.deadline_at = now + self._execution_lease
472
+ for saga in sagas_suspended:
473
+ if self._is_waiting_for_events(saga):
474
+ self._handle_saga_timeout(saga, now)
475
+ else:
476
+ self._prepare_saga_for_resume(saga, SagaStatus.RUNNING, now)
477
+ ready_ids.append(saga.id)
478
+
479
+ for saga in [*sagas_compensating, *sagas_comp_suspended]:
480
+ self._prepare_saga_for_resume(saga, SagaStatus.COMPENSATING, now)
476
481
  compensation_ids.append(saga.id)
477
482
 
478
483
  for saga_id in ready_ids:
479
484
  await self._drive(saga_id)
480
485
  for saga_id in compensation_ids:
481
486
  await self._run_compensation(saga_id)
487
+
482
488
  return len(ready_ids) + len(compensation_ids)
483
489
 
490
+ def _prepare_saga_for_resume(
491
+ self, saga, status: "SagaStatus", now: datetime
492
+ ) -> None:
493
+ """Устанавливает общие поля для продолжения работы саги."""
494
+ saga.status = status
495
+ saga.step_execution_token = uuid.uuid4()
496
+ saga.deadline_at = now + self._execution_lease
497
+
498
+ def _is_waiting_for_events(self, saga) -> bool:
499
+ """Проверяет, ожидает ли сага внешних событий."""
500
+ context = saga.context
501
+ return bool(context.awaiting_event_type or context.awaiting_event_types)
502
+
503
+ def _handle_saga_timeout(self, saga, now: datetime) -> None:
504
+ """логика обработки таймаута ожидания событий."""
505
+ context = saga.context
506
+ event_types = context.awaiting_event_types or context.awaiting_event_type
507
+
508
+ saga.status = SagaStatus.TIMEOUT
509
+ saga.last_error = f"Timed out waiting for event(s): {event_types}"
510
+ saga.deadline_at = None
511
+ saga.step_execution_token = uuid.uuid4()
512
+
513
+ definition = self._registry[context.saga_name]
514
+ step_def = definition.steps[saga.current_step_index]
515
+
516
+ saga.step_history.append(
517
+ self._history_model_class(
518
+ timestamp=now,
519
+ phase=SagaStepPhase.EXECUTE,
520
+ status=SagaStepStatus.TIMEOUT,
521
+ step_id=step_def.step_id,
522
+ step_name=type(step_def.step).__name__,
523
+ attempt=saga.retry_counter + 1,
524
+ token=saga.step_execution_token,
525
+ input={"_system": "timeout"},
526
+ output=None,
527
+ error=saga.last_error,
528
+ skipped=False,
529
+ )
530
+ )
531
+
484
532
  async def get_snapshot(self, saga_id: UUID) -> SagaSnapshot:
485
533
  """Return the snapshot view of one saga."""
486
534
  async with self._session_maker() as session:
@@ -533,7 +581,11 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
533
581
  async with self._session_maker() as session:
534
582
  async with session.begin():
535
583
  saga = await self._repository.get_for_update(session, saga_id)
536
- if saga.status not in {SagaStatus.SUSPENDED, SagaStatus.FAILED}:
584
+ if saga.status not in {
585
+ SagaStatus.SUSPENDED,
586
+ SagaStatus.FAILED,
587
+ SagaStatus.TIMEOUT,
588
+ }:
537
589
  raise SagaStateError(
538
590
  f"Cannot retry step when saga status is {saga.status}"
539
591
  )
@@ -595,9 +647,37 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
595
647
  async with self._session_maker() as session:
596
648
  async with session.begin():
597
649
  saga = await self._repository.get_for_update(session, saga_id)
650
+ error_message = saga.last_error or "Aborted by admin"
651
+
652
+ try:
653
+ saga_name = saga.context.saga_name
654
+ definition = self._registry[saga_name]
655
+ step_def = definition.steps[saga.current_step_index]
656
+ step_id = step_def.step_id
657
+ step_name = type(step_def.step).__name__
658
+ except (KeyError, IndexError):
659
+ step_id = "__unknown__"
660
+ step_name = "Unknown"
661
+
662
+ saga.step_history.append(
663
+ self._history_model_class(
664
+ timestamp=datetime.now(UTC),
665
+ phase=SagaStepPhase.EXECUTE,
666
+ status=SagaStepStatus.ERROR,
667
+ step_id=step_id,
668
+ step_name=step_name,
669
+ attempt=saga.retry_counter + 1,
670
+ token=saga.step_execution_token,
671
+ input={"_admin": "abort"},
672
+ output=None,
673
+ error=error_message,
674
+ skipped=True,
675
+ )
676
+ )
677
+
598
678
  saga.status = SagaStatus.FAILED
599
679
  saga.deadline_at = None
600
- saga.last_error = saga.last_error or "Aborted by admin"
680
+ saga.last_error = error_message
601
681
  saga.step_execution_token = uuid.uuid4()
602
682
 
603
683
  async def skip_step(
@@ -139,7 +139,7 @@ class SagaRepository(Generic[ModelT]):
139
139
  ) -> list[ModelT]:
140
140
  """Return due saga rows for one status ordered by deadline."""
141
141
  stmt = (
142
- select(self.model_class)
142
+ self._base_query()
143
143
  .where(
144
144
  self.model_class.status == status,
145
145
  self.model_class.deadline_at.is_not(None),
@@ -0,0 +1,10 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class BaseStrEnum(StrEnum):
5
+ @staticmethod
6
+ def _generate_next_value_(name, start, count, last_values) -> str:
7
+ """
8
+ Return the upper-cased version of the member name.
9
+ """
10
+ return name.upper()
@@ -0,0 +1,18 @@
1
+ from enum import auto
2
+
3
+ from .base_str_enum import BaseStrEnum
4
+
5
+
6
+ class SagaStatus(BaseStrEnum):
7
+ RUNNING = auto()
8
+ SUSPENDED = auto()
9
+ FAILED = auto()
10
+ COMPENSATING = auto()
11
+ COMPLETED = auto()
12
+ COMPENSATING_SUSPENDED = auto()
13
+ COMPENSATED = auto()
14
+ TIMEOUT = auto()
15
+
16
+ @property
17
+ def is_terminal(self) -> bool:
18
+ return self in {self.FAILED, self.COMPLETED, self.COMPENSATED}
@@ -0,0 +1,9 @@
1
+ from enum import auto
2
+
3
+ from .base_str_enum import BaseStrEnum
4
+
5
+
6
+ class SagaStepPhase(BaseStrEnum):
7
+ COMPENSATE = auto()
8
+ SUCCESS = auto()
9
+ EXECUTE = auto()
@@ -0,0 +1,10 @@
1
+ from enum import auto
2
+
3
+ from .base_str_enum import BaseStrEnum
4
+
5
+
6
+ class SagaStepStatus(BaseStrEnum):
7
+ SUCCESS = auto()
8
+ ERROR = auto()
9
+ WAITING = auto()
10
+ TIMEOUT = auto()
@@ -119,19 +119,24 @@ class BaseStep(Generic[InputModelT, OutputModelT]):
119
119
 
120
120
  concrete_args = get_args(generic_base)
121
121
  if not concrete_args or any(isinstance(arg, TypeVar) for arg in concrete_args):
122
- raise TypeValidationError(f"Step '{cls.__name__}' inherits from a generic Step "
123
- "but was not parameterized with concrete Input/Output models.")
122
+ raise TypeValidationError(
123
+ f"Step '{cls.__name__}' inherits from a generic Step "
124
+ "but was not parameterized with concrete Input/Output models."
125
+ )
124
126
 
125
127
  concrete_input_model, concrete_output_model = concrete_args
126
128
  try:
127
- hints = get_type_hints(cls.execute, globalns=inspect.getmodule(cls).__dict__, include_extras=True)
129
+ hints = get_type_hints(
130
+ cls.execute,
131
+ globalns=inspect.getmodule(cls).__dict__,
132
+ include_extras=True,
133
+ )
128
134
  except (AttributeError, NameError) as e:
129
- raise TypeValidationError(
135
+ raise TypeValidationError(
130
136
  f"Could not resolve type hints for '{cls.__name__}.execute'. "
131
137
  f"Ensure all types are correctly imported. Original error: {e}"
132
138
  )
133
139
 
134
-
135
140
  if "inp" not in hints or "return" not in hints:
136
141
  raise TypeValidationError(
137
142
  f"Step '{cls.__name__}' must type annotate execute(inp) and return type"
@@ -144,7 +149,9 @@ class BaseStep(Generic[InputModelT, OutputModelT]):
144
149
  input_model = input_annotation
145
150
 
146
151
  return_annotation = hints["return"]
147
- output_model = cls._resolve_output_model(return_annotation, concrete_output_model)
152
+ output_model = cls._resolve_output_model(
153
+ return_annotation, concrete_output_model
154
+ )
148
155
  if not (inspect.isclass(input_model) and issubclass(input_model, BaseModel)):
149
156
  raise TypeValidationError(
150
157
  f"Step '{cls.__name__}' input must inherit from pydantic BaseModel"
@@ -153,7 +160,9 @@ class BaseStep(Generic[InputModelT, OutputModelT]):
153
160
  cls.output_model = output_model
154
161
 
155
162
  @staticmethod
156
- def _resolve_output_model(annotation: Any, concrete_model: type[BaseModel] | None = None) -> type[BaseModel]:
163
+ def _resolve_output_model(
164
+ annotation: Any, concrete_model: type[BaseModel] | None = None
165
+ ) -> type[BaseModel]:
157
166
  def find_model_in_args(args_tuple: tuple) -> list[type[BaseModel]]:
158
167
  candidates = []
159
168
  for arg in args_tuple:
@@ -324,7 +324,14 @@ class WaitingStep(BaseStep[NextInput, NextOutput]):
324
324
  raise RuntimeError("pending approval")
325
325
 
326
326
 
327
- # --- Hook Helper Models & Functions ---
327
+ class WaitingWithTimeoutStep(BaseStep[StartInput, StartOutput]):
328
+ """Шаг, который всегда ждет события с небольшим таймаутом."""
329
+
330
+ async def execute(self, inp: StartInput) -> StepAwaitEvent | StartOutput:
331
+ return StepAwaitEvent(
332
+ event_types=("some.event",),
333
+ until=timedelta(milliseconds=10),
334
+ )
328
335
 
329
336
 
330
337
  class SagaStartedEvent(BaseModel):
@@ -1,5 +1,3 @@
1
- # tests/integration/test_admin_api.py
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  import asyncio
@@ -7,7 +5,7 @@ from datetime import timedelta
7
5
 
8
6
  import pytest
9
7
 
10
- from saga_orchestrator import SagaAdmin, SagaBuilder
8
+ from saga_orchestrator import SagaAdmin, SagaBuilder, SagaStepStatus
11
9
  from saga_orchestrator.core.orchestrator import SagaOrchestrator
12
10
  from saga_orchestrator.domain.exceptions import SagaStateError
13
11
  from saga_orchestrator.domain.models import ExponentialRetry
@@ -23,6 +21,7 @@ from tests.integration.helpers import (
23
21
  StartInput,
24
22
  StartOutput,
25
23
  WaitingStep,
24
+ WaitingWithTimeoutStep,
26
25
  )
27
26
  from tests.integration.models import IntegrationSagaHistory, IntegrationSagaState
28
27
 
@@ -58,7 +57,7 @@ async def test_admin_retry_rejects_failed_saga_after_compensation(session_maker)
58
57
  )
59
58
  state = await admin.get_saga(saga_id)
60
59
  assert state.status == SagaStatus.COMPENSATED
61
- assert any(entry.phase == "compensate" for entry in state.step_history)
60
+ assert any(entry.phase == "COMPENSATE" for entry in state.step_history)
62
61
 
63
62
  with pytest.raises(SagaStateError):
64
63
  await admin.retry_step(saga_id)
@@ -150,7 +149,7 @@ async def test_admin_compensate_rolls_back_suspended_saga(session_maker):
150
149
  state_after = await admin.get_saga(saga_id)
151
150
  assert state_after.status == SagaStatus.COMPENSATED
152
151
  assert compensating_step.compensated is True
153
- assert any(entry.phase == "compensate" for entry in state_after.step_history)
152
+ assert any(entry.phase == "COMPENSATE" for entry in state_after.step_history)
154
153
 
155
154
 
156
155
  @pytest.mark.asyncio
@@ -225,7 +224,7 @@ async def test_get_admin_snapshot_returns(session_maker):
225
224
  assert isinstance(admin_snapshot.step_history, list)
226
225
  assert len(admin_snapshot.step_history) == 1
227
226
  assert admin_snapshot.step_history[0].step_name == "AddOneStep"
228
- assert admin_snapshot.step_history[0].status == "success"
227
+ assert admin_snapshot.step_history[0].status == "SUCCESS"
229
228
 
230
229
 
231
230
  @pytest.mark.asyncio
@@ -307,3 +306,91 @@ async def test_can_retry_step_on_forward_failure_without_compensation(session_ma
307
306
  state_after = await admin.get_saga(saga_id)
308
307
  assert state_after.status == SagaStatus.COMPLETED
309
308
  assert fails_once_step.calls == 2
309
+
310
+
311
+ @pytest.mark.asyncio
312
+ async def test_admin_abort_adds_history_entry(session_maker):
313
+ """
314
+ Проверяет, что вызов admin.abort() добавляет запись об ошибке в историю шага.
315
+ """
316
+ builder = SagaBuilder()
317
+ builder.add_step(
318
+ step=WaitingWithTimeoutStep(),
319
+ input_map=lambda ctx: StartInput(value=ctx.initial_data["value"]),
320
+ retry_policy=ExponentialRetry(max_attempts=1, base_delay=timedelta(hours=1)),
321
+ )
322
+
323
+ orchestrator = SagaOrchestrator[IntegrationSagaState, IntegrationSagaHistory](
324
+ model_class=IntegrationSagaState,
325
+ history_model_class=IntegrationSagaHistory,
326
+ session_maker=session_maker,
327
+ )
328
+ admin = SagaAdmin[IntegrationSagaState, IntegrationSagaHistory](
329
+ engine=orchestrator.engine
330
+ )
331
+ orchestrator.register("abort-test", builder.build())
332
+
333
+ saga_id = await orchestrator.start(
334
+ saga_name="abort-test",
335
+ initial_data={"value": 1},
336
+ aggregation_id="agg-abort-test",
337
+ )
338
+
339
+ state_before = await admin.get_saga(saga_id)
340
+ assert state_before.status == SagaStatus.SUSPENDED
341
+ assert len(state_before.step_history) == 1
342
+ await admin.abort(saga_id)
343
+
344
+ state_after = await admin.get_saga(saga_id)
345
+ assert state_after.status == SagaStatus.FAILED
346
+ assert state_after.last_error == "Aborted by admin"
347
+ assert len(state_after.step_history) == 2
348
+
349
+ abort_entry = state_after.step_history[-1]
350
+ assert abort_entry.status == SagaStepStatus.ERROR
351
+ assert abort_entry.error == "Aborted by admin"
352
+ assert abort_entry.skipped is True
353
+ assert abort_entry.input == {"_admin": "abort"}
354
+
355
+
356
+ @pytest.mark.asyncio
357
+ async def test_admin_retry_step_from_timed_out_status(session_maker):
358
+ """
359
+ Проверяет, что можно перезапустить шаг из статуса TIMEOUT.
360
+ """
361
+ builder = SagaBuilder()
362
+ builder.add_step(
363
+ step=WaitingWithTimeoutStep(),
364
+ input_map=lambda ctx: StartInput(value=ctx.initial_data["value"]),
365
+ )
366
+
367
+ orchestrator = SagaOrchestrator[IntegrationSagaState, IntegrationSagaHistory](
368
+ model_class=IntegrationSagaState,
369
+ history_model_class=IntegrationSagaHistory,
370
+ session_maker=session_maker,
371
+ )
372
+ admin = SagaAdmin[IntegrationSagaState, IntegrationSagaHistory](
373
+ engine=orchestrator.engine
374
+ )
375
+ orchestrator.register("retry-from-timeout", builder.build())
376
+
377
+ saga_id = await orchestrator.start(
378
+ saga_name="retry-from-timeout",
379
+ initial_data={"value": 1},
380
+ aggregation_id="agg-retry-from-timeout",
381
+ )
382
+ await asyncio.sleep(0.1)
383
+ await orchestrator.run_due()
384
+
385
+ state_before = await admin.get_saga(saga_id)
386
+ assert state_before.status == SagaStatus.TIMEOUT
387
+ assert len(state_before.step_history) == 2
388
+ assert state_before.step_history[0].status == SagaStepStatus.WAITING
389
+ assert state_before.step_history[1].status == SagaStepStatus.TIMEOUT
390
+ await admin.retry_step(saga_id)
391
+ state_after = await admin.get_saga(saga_id)
392
+ assert state_after.status == SagaStatus.SUSPENDED
393
+ assert state_after.retry_counter == 0
394
+
395
+ assert len(state_after.step_history) == 3
396
+ assert state_after.step_history[2].status == SagaStepStatus.WAITING
@@ -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, SagaBuilder, SagaStepPhase, SagaStepStatus
9
9
  from saga_orchestrator.core.orchestrator import SagaOrchestrator
10
10
  from saga_orchestrator.domain.exceptions import ActiveSagaAlreadyExistsError
11
11
  from saga_orchestrator.domain.models import ExponentialRetry
@@ -26,6 +26,7 @@ from tests.integration.helpers import (
26
26
  ReserveQueueInput,
27
27
  ReserveQueueStep,
28
28
  StartInput,
29
+ WaitingWithTimeoutStep,
29
30
  )
30
31
  from tests.integration.models import (
31
32
  IntegrationOutboxMessage,
@@ -398,3 +399,50 @@ async def test_get_snapshot_returns(session_maker):
398
399
  assert snapshot.current_step_index == 1
399
400
  assert snapshot.deadline_at is None
400
401
  assert snapshot.last_error is None
402
+
403
+
404
+ @pytest.mark.asyncio
405
+ async def test_timed_out_status_on_await_event_deadline(session_maker):
406
+ """
407
+ Проверяет, что сага переходит в статус TIMEOUT, если истекает время ожидания события.
408
+ """
409
+ builder = SagaBuilder()
410
+ builder.add_step(
411
+ step=WaitingWithTimeoutStep(),
412
+ input_map=lambda ctx: StartInput(value=ctx.initial_data["value"]),
413
+ )
414
+
415
+ orchestrator = SagaOrchestrator[IntegrationSagaState, IntegrationSagaHistory](
416
+ model_class=IntegrationSagaState,
417
+ history_model_class=IntegrationSagaHistory,
418
+ session_maker=session_maker,
419
+ )
420
+ admin = SagaAdmin[IntegrationSagaState, IntegrationSagaHistory](
421
+ engine=orchestrator.engine
422
+ )
423
+ orchestrator.register("await-timeout", builder.build())
424
+ saga_id = await orchestrator.start(
425
+ saga_name="await-timeout",
426
+ initial_data={"value": 1},
427
+ aggregation_id="agg-await-timeout",
428
+ )
429
+
430
+ state_before = await admin.get_saga(saga_id)
431
+ assert state_before.status == SagaStatus.SUSPENDED
432
+ assert state_before.deadline_at is not None
433
+
434
+ await asyncio.sleep(0.1)
435
+ resumed = await orchestrator.run_due()
436
+ assert resumed == 0
437
+ state_after = await admin.get_saga(saga_id)
438
+ assert state_after.status == SagaStatus.TIMEOUT
439
+ assert "Timed out waiting for event" in state_after.last_error
440
+ assert state_after.deadline_at is None
441
+
442
+ assert len(state_after.step_history) == 2
443
+ assert state_after.step_history[0].status == SagaStepStatus.WAITING
444
+ assert state_after.step_history[0].phase == SagaStepPhase.EXECUTE
445
+ assert not state_after.step_history[0].error
446
+ assert state_after.step_history[1].status == SagaStepStatus.TIMEOUT
447
+ assert state_after.step_history[1].phase == SagaStepPhase.EXECUTE
448
+ assert "Timed out" in state_after.step_history[1].error
@@ -81,6 +81,6 @@ def test_input_context_properties_parametrized(
81
81
  )
82
82
 
83
83
  assert input_ctx.latest_event_type == expected_type, f"Failed on test: {test_id}"
84
- assert (
85
- input_ctx.latest_event_payload == expected_payload
86
- ), f"Failed on test: {test_id}"
84
+ assert input_ctx.latest_event_payload == expected_payload, (
85
+ f"Failed on test: {test_id}"
86
+ )
@@ -40,7 +40,6 @@ class StepAwaitEvent:
40
40
  event_types: tuple[str, ...] | None = None
41
41
 
42
42
 
43
-
44
43
  IntermediateInputT = TypeVar("IntermediateInputT", bound=BaseModel)
45
44
  IntermediateOutputT = TypeVar("IntermediateOutputT", bound=BaseModel)
46
45
 
@@ -48,18 +47,19 @@ IntermediateOutputT = TypeVar("IntermediateOutputT", bound=BaseModel)
48
47
  class AbstractIntermediateStep(
49
48
  BaseStep[IntermediateInputT, IntermediateOutputT],
50
49
  ABC,
51
- Generic[IntermediateInputT, IntermediateOutputT]
50
+ Generic[IntermediateInputT, IntermediateOutputT],
52
51
  ):
53
52
  @abstractmethod
54
53
  def some_abstract_method(self):
55
54
  pass
56
55
 
57
- async def execute(self, inp: IntermediateInputT) -> IntermediateOutputT | StepAwaitEvent:
56
+ async def execute(
57
+ self, inp: IntermediateInputT
58
+ ) -> IntermediateOutputT | StepAwaitEvent:
58
59
  pass
59
60
 
60
61
 
61
62
  class TestTypeResolution:
62
-
63
63
  def test_indirect_inheritance_resolves_types_correctly(self):
64
64
  """
65
65
  GIVEN a concrete step class inheriting from a generic abstract step
@@ -97,6 +97,7 @@ class TestTypeResolution:
97
97
  THEN TypeValidationError should be raised.
98
98
  """
99
99
  with pytest.raises(TypeValidationError, match="must type annotate execute"):
100
+
100
101
  class FaultyStep(BaseStep[InputModelA, OutputModelA]):
101
102
  # `inp` не аннотирован
102
103
  async def execute(self, inp) -> OutputModelA:
@@ -114,6 +115,7 @@ class TestTypeResolution:
114
115
  pass
115
116
 
116
117
  with pytest.raises(TypeValidationError, match="Could not resolve type hints"):
118
+
117
119
  class FaultyStep(BaseStep[InputModelA, OutputModelA]):
118
120
  async def execute(self, inp: NotAModel) -> OutputModelA:
119
121
  pass
@@ -124,7 +126,11 @@ class TestTypeResolution:
124
126
  WHEN the class is defined
125
127
  THEN TypeValidationError should be raised by our custom logic.
126
128
  """
127
- with pytest.raises(TypeValidationError, match="inherits from a generic Step but was not parameterized with concrete Input/Output models"):
129
+ with pytest.raises(
130
+ TypeValidationError,
131
+ match="inherits from a generic Step but was not parameterized with concrete Input/Output models",
132
+ ):
133
+
128
134
  class UnparameterizedStep(AbstractIntermediateStep):
129
135
  def some_abstract_method(self):
130
136
  pass
@@ -1,15 +0,0 @@
1
- from enum import StrEnum
2
-
3
-
4
- class SagaStatus(StrEnum):
5
- RUNNING = "RUNNING"
6
- SUSPENDED = "SUSPENDED"
7
- FAILED = "FAILED"
8
- COMPENSATING = "COMPENSATING"
9
- COMPLETED = "COMPLETED"
10
- COMPENSATING_SUSPENDED = "COMPENSATING_SUSPENDED"
11
- COMPENSATED = "COMPENSATED"
12
-
13
- @property
14
- def is_terminal(self) -> bool:
15
- return self in {self.FAILED, self.COMPLETED, self.COMPENSATED}
@@ -1,7 +0,0 @@
1
- from enum import StrEnum, auto
2
-
3
-
4
- class SagaStepPhase(StrEnum):
5
- COMPENSATE = auto()
6
- SUCCESS = auto()
7
- EXECUTE = auto()
@@ -1,7 +0,0 @@
1
- from enum import StrEnum, auto
2
-
3
-
4
- class SagaStepStatus(StrEnum):
5
- SUCCESS = auto()
6
- ERROR = auto()
7
- WAITING = auto()