python-saga-orchestrator 0.3.0__tar.gz → 0.5.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 (89) hide show
  1. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/PKG-INFO +1 -1
  2. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/python_saga_orchestrator.egg-info/PKG-INFO +1 -1
  3. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/_version.py +2 -2
  4. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/core/engine.py +100 -20
  5. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/core/repository.py +1 -1
  6. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/mixins/saga_step_histrory.py +11 -1
  7. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/enums/saga_status.py +1 -0
  8. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/enums/saga_step_status.py +1 -0
  9. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/helpers.py +8 -1
  10. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_admin_api.py +90 -3
  11. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_core_flow.py +49 -1
  12. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/.github/workflows/ci.yml +0 -0
  13. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/.github/workflows/publish.yml +0 -0
  14. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/.gitignore +0 -0
  15. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/Dockerfile +0 -0
  16. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/LICENSE +0 -0
  17. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/Makefile +0 -0
  18. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/README.md +0 -0
  19. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/docker-compose.yaml +0 -0
  20. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/examples/admin_skip.py +0 -0
  21. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/examples/common.py +0 -0
  22. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/examples/compensation_flow.py +0 -0
  23. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/examples/http_and_queue.py +0 -0
  24. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/examples/llm_deploy.py +0 -0
  25. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/examples/retry_recovery.py +0 -0
  26. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/pyproject.toml +0 -0
  27. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/python_saga_orchestrator.egg-info/SOURCES.txt +0 -0
  28. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
  29. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/python_saga_orchestrator.egg-info/requires.txt +0 -0
  30. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
  31. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/__init__.py +0 -0
  32. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/admin/__init__.py +0 -0
  33. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/admin/api.py +0 -0
  34. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/core/__init__.py +0 -0
  35. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/core/builder.py +0 -0
  36. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/core/orchestrator.py +0 -0
  37. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/__init__.py +0 -0
  38. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
  39. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/exceptions/saga.py +0 -0
  40. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/mixins/__init__.py +0 -0
  41. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
  42. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/mixins/types.py +0 -0
  43. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/__init__.py +0 -0
  44. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/builder.py +0 -0
  45. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/context.py +0 -0
  46. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
  47. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/enums/base_str_enum.py +0 -0
  48. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/enums/saga_step_phase.py +0 -0
  49. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/notify.py +0 -0
  50. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/retry.py +0 -0
  51. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
  52. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/step.py +0 -0
  53. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/inbox/__init__.py +0 -0
  54. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/inbox/contracts.py +0 -0
  55. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/inbox/dispatcher.py +0 -0
  56. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/inbox/models.py +0 -0
  57. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/inbox/repository.py +0 -0
  58. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/inbox/retry.py +0 -0
  59. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/__init__.py +0 -0
  60. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/contracts.py +0 -0
  61. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/dispatcher.py +0 -0
  62. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/event.py +0 -0
  63. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/factory.py +0 -0
  64. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/models.py +0 -0
  65. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/repository.py +0 -0
  66. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/retry.py +0 -0
  67. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/serialization.py +0 -0
  68. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/setup.cfg +0 -0
  69. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/task.md +0 -0
  70. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/__init__.py +0 -0
  71. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/conftest.py +0 -0
  72. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/__init__.py +0 -0
  73. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/conftest.py +0 -0
  74. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/models.py +0 -0
  75. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_compensation_flow.py +0 -0
  76. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_context_persistence.py +0 -0
  77. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_inbox_flow.py +0 -0
  78. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_lifecycle_hooks.py +0 -0
  79. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_notification_flow.py +0 -0
  80. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_outbox_flow.py +0 -0
  81. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_repository.py +0 -0
  82. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/__init__.py +0 -0
  83. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_builder.py +0 -0
  84. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_inbox_extensibility.py +0 -0
  85. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_input_context.py +0 -0
  86. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_orchestrator_helpers.py +0 -0
  87. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_outbox_extensibility.py +0 -0
  88. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_retry.py +0 -0
  89. {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_step_type_resolution.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-saga-orchestrator
3
- Version: 0.3.0
3
+ Version: 0.5.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.3.0
3
+ Version: 0.5.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.3.0'
22
- __version_tuple__ = version_tuple = (0, 3, 0)
21
+ __version__ = version = '0.5.0'
22
+ __version_tuple__ = version_tuple = (0, 5, 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),
@@ -4,7 +4,7 @@ import uuid
4
4
  from datetime import datetime
5
5
  from typing import Any
6
6
 
7
- from sqlalchemy import Boolean, DateTime, Enum, Integer, String, Text
7
+ from sqlalchemy import Boolean, DateTime, Enum, Integer, String, Text, func
8
8
  from sqlalchemy.dialects.postgresql import UUID
9
9
  from sqlalchemy.orm import Mapped, declarative_mixin, declared_attr, mapped_column
10
10
 
@@ -43,3 +43,13 @@ class SagaStepHistoryMixin:
43
43
 
44
44
  error: Mapped[str | None] = mapped_column(Text, nullable=True)
45
45
  skipped: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
46
+
47
+ created_at: Mapped[datetime] = mapped_column(
48
+ DateTime(timezone=True), nullable=False, server_default=func.now()
49
+ )
50
+ updated_at: Mapped[datetime] = mapped_column(
51
+ DateTime(timezone=True),
52
+ nullable=False,
53
+ server_default=func.now(),
54
+ onupdate=func.now(),
55
+ )
@@ -11,6 +11,7 @@ class SagaStatus(BaseStrEnum):
11
11
  COMPLETED = auto()
12
12
  COMPENSATING_SUSPENDED = auto()
13
13
  COMPENSATED = auto()
14
+ TIMEOUT = auto()
14
15
 
15
16
  @property
16
17
  def is_terminal(self) -> bool:
@@ -7,3 +7,4 @@ class SagaStepStatus(BaseStrEnum):
7
7
  SUCCESS = auto()
8
8
  ERROR = auto()
9
9
  WAITING = auto()
10
+ TIMEOUT = auto()
@@ -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
 
@@ -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