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.
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/PKG-INFO +1 -1
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/python_saga_orchestrator.egg-info/PKG-INFO +1 -1
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/_version.py +2 -2
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/core/engine.py +100 -20
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/core/repository.py +1 -1
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/mixins/saga_step_histrory.py +11 -1
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/enums/saga_status.py +1 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/enums/saga_step_status.py +1 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/helpers.py +8 -1
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_admin_api.py +90 -3
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_core_flow.py +49 -1
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/.github/workflows/ci.yml +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/.github/workflows/publish.yml +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/.gitignore +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/Dockerfile +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/LICENSE +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/Makefile +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/README.md +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/docker-compose.yaml +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/examples/admin_skip.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/examples/common.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/examples/compensation_flow.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/examples/http_and_queue.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/examples/llm_deploy.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/examples/retry_recovery.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/pyproject.toml +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/python_saga_orchestrator.egg-info/SOURCES.txt +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/python_saga_orchestrator.egg-info/requires.txt +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/__init__.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/admin/__init__.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/admin/api.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/core/__init__.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/core/builder.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/core/orchestrator.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/__init__.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/exceptions/saga.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/mixins/__init__.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/mixins/types.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/__init__.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/builder.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/context.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/enums/base_str_enum.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/enums/saga_step_phase.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/notify.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/retry.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/domain/models/step.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/inbox/__init__.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/inbox/contracts.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/inbox/dispatcher.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/inbox/models.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/inbox/repository.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/inbox/retry.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/__init__.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/contracts.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/dispatcher.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/event.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/factory.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/models.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/repository.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/retry.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/serialization.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/setup.cfg +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/task.md +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/__init__.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/conftest.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/__init__.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/conftest.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/models.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_compensation_flow.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_context_persistence.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_inbox_flow.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_lifecycle_hooks.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_notification_flow.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_outbox_flow.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/test_repository.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/__init__.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_builder.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_inbox_extensibility.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_input_context.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_orchestrator_helpers.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_outbox_extensibility.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_retry.py +0 -0
- {python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_step_type_resolution.py +0 -0
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/_version.py
RENAMED
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.5.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 5, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/core/engine.py
RENAMED
|
@@ -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
|
-
|
|
447
|
+
sagas_running = await self._repository.due_running(
|
|
448
448
|
session, now=now, limit=limit
|
|
449
449
|
)
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
460
|
+
limit = max(0, limit - len(sagas_compensating))
|
|
459
461
|
|
|
460
|
-
|
|
462
|
+
sagas_comp_suspended = (
|
|
461
463
|
await self._repository.due_compensating_suspended(
|
|
462
|
-
session, now=now, limit=
|
|
464
|
+
session, now=now, limit=limit
|
|
463
465
|
)
|
|
464
466
|
)
|
|
465
467
|
|
|
466
|
-
for saga in
|
|
467
|
-
saga
|
|
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
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
+
)
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/helpers.py
RENAMED
|
@@ -324,7 +324,14 @@ class WaitingStep(BaseStep[NextInput, NextOutput]):
|
|
|
324
324
|
raise RuntimeError("pending approval")
|
|
325
325
|
|
|
326
326
|
|
|
327
|
-
|
|
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
|
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/.github/workflows/publish.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/examples/compensation_flow.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/examples/http_and_queue.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/examples/retry_recovery.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/admin/api.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/core/__init__.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/core/builder.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/inbox/models.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/inbox/retry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/event.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/models.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/saga_orchestrator/outbox/retry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/__init__.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/conftest.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/integration/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_builder.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.3.0 → python_saga_orchestrator-0.5.0}/tests/unit/test_input_context.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|