python-saga-orchestrator 0.1.2__tar.gz → 0.1.3__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.1.2/python_saga_orchestrator.egg-info → python_saga_orchestrator-0.1.3}/PKG-INFO +1 -1
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/pyproject.toml +1 -1
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3/python_saga_orchestrator.egg-info}/PKG-INFO +1 -1
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/SOURCES.txt +5 -1
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/__init__.py +18 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/engine.py +41 -28
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/orchestrator.py +14 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/__init__.py +33 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/contracts.py +87 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/dispatcher.py +84 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/factory.py +62 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/repository.py +154 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/retry.py +20 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/serialization.py +47 -0
- python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/__init__.py +0 -15
- python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/dispatcher.py +0 -95
- python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/repository.py +0 -73
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/LICENSE +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/README.md +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/requires.txt +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/admin/__init__.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/admin/api.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/__init__.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/builder.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/repository.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/__init__.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/exceptions/saga.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/mixins/__init__.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/__init__.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/builder.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/enums/saga_status.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/notify.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/retry.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/step.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/outbox/event.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/outbox/models.py +0 -0
- {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/setup.cfg +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-saga-orchestrator"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "Lightweight embedded saga orchestrator for asyncio Python services"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -28,7 +28,11 @@ saga_orchestrator/domain/models/step.py
|
|
|
28
28
|
saga_orchestrator/domain/models/enums/__init__.py
|
|
29
29
|
saga_orchestrator/domain/models/enums/saga_status.py
|
|
30
30
|
saga_orchestrator/outbox/__init__.py
|
|
31
|
+
saga_orchestrator/outbox/contracts.py
|
|
31
32
|
saga_orchestrator/outbox/dispatcher.py
|
|
32
33
|
saga_orchestrator/outbox/event.py
|
|
34
|
+
saga_orchestrator/outbox/factory.py
|
|
33
35
|
saga_orchestrator/outbox/models.py
|
|
34
|
-
saga_orchestrator/outbox/repository.py
|
|
36
|
+
saga_orchestrator/outbox/repository.py
|
|
37
|
+
saga_orchestrator/outbox/retry.py
|
|
38
|
+
saga_orchestrator/outbox/serialization.py
|
{python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/__init__.py
RENAMED
|
@@ -30,31 +30,49 @@ from .domain.models import (
|
|
|
30
30
|
)
|
|
31
31
|
from .domain.models.enums import SagaStatus
|
|
32
32
|
from .outbox import (
|
|
33
|
+
ClaimedOutboxMessage,
|
|
34
|
+
DefaultOutboxMessageFactory,
|
|
35
|
+
FixedOutboxDispatchRetry,
|
|
36
|
+
JsonOutboxSerializer,
|
|
33
37
|
OutboxDispatcher,
|
|
38
|
+
OutboxDispatchRetryPolicy,
|
|
34
39
|
OutboxEvent,
|
|
40
|
+
OutboxMessageFactory,
|
|
35
41
|
OutboxMessageMixin,
|
|
36
42
|
OutboxPublisher,
|
|
37
43
|
OutboxRepository,
|
|
44
|
+
OutboxSerializer,
|
|
38
45
|
OutboxStatus,
|
|
46
|
+
OutboxWriteMessage,
|
|
47
|
+
OutboxWriter,
|
|
39
48
|
)
|
|
40
49
|
|
|
41
50
|
__all__ = [
|
|
42
51
|
"ActiveSagaAlreadyExistsError",
|
|
43
52
|
"AwaitingEvent",
|
|
44
53
|
"BaseStep",
|
|
54
|
+
"ClaimedOutboxMessage",
|
|
55
|
+
"DefaultOutboxMessageFactory",
|
|
45
56
|
"ExponentialRetry",
|
|
57
|
+
"FixedOutboxDispatchRetry",
|
|
46
58
|
"FixedRetry",
|
|
47
59
|
"InputContext",
|
|
60
|
+
"JsonOutboxSerializer",
|
|
48
61
|
"NotifyEvent",
|
|
49
62
|
"NotifyResult",
|
|
50
63
|
"NoRetry",
|
|
51
64
|
"OutboxDispatcher",
|
|
65
|
+
"OutboxDispatchRetryPolicy",
|
|
52
66
|
"OutboxEvent",
|
|
67
|
+
"OutboxMessageFactory",
|
|
53
68
|
"OutboxMap",
|
|
54
69
|
"OutboxMessageMixin",
|
|
55
70
|
"OutboxPublisher",
|
|
56
71
|
"OutboxRepository",
|
|
72
|
+
"OutboxSerializer",
|
|
57
73
|
"OutboxStatus",
|
|
74
|
+
"OutboxWriteMessage",
|
|
75
|
+
"OutboxWriter",
|
|
58
76
|
"RetryPolicy",
|
|
59
77
|
"SagaAdmin",
|
|
60
78
|
"SagaAdminSnapshot",
|
{python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/engine.py
RENAMED
|
@@ -23,8 +23,11 @@ from ..domain.models import (
|
|
|
23
23
|
StepDefinition,
|
|
24
24
|
)
|
|
25
25
|
from ..domain.models.enums import SagaStatus
|
|
26
|
-
from ..outbox.
|
|
26
|
+
from ..outbox.contracts import OutboxWriter
|
|
27
|
+
from ..outbox.factory import DefaultOutboxMessageFactory, OutboxMessageFactory
|
|
28
|
+
from ..outbox.models import OutboxMessageMixin
|
|
27
29
|
from ..outbox.repository import OutboxRepository
|
|
30
|
+
from ..outbox.serialization import JsonOutboxSerializer, OutboxSerializer
|
|
28
31
|
from .repository import SagaRepository
|
|
29
32
|
|
|
30
33
|
ModelT = TypeVar("ModelT", bound=SagaStateMixin)
|
|
@@ -39,6 +42,9 @@ class SagaEngine(Generic[ModelT]):
|
|
|
39
42
|
model_class: type[ModelT],
|
|
40
43
|
session_maker: async_sessionmaker[AsyncSession],
|
|
41
44
|
outbox_model_class: type[OutboxMessageMixin] | None = None,
|
|
45
|
+
outbox_writer: OutboxWriter | None = None,
|
|
46
|
+
outbox_serializer: OutboxSerializer | None = None,
|
|
47
|
+
outbox_message_factory: OutboxMessageFactory | None = None,
|
|
42
48
|
execution_lease: timedelta = timedelta(minutes=5),
|
|
43
49
|
) -> None:
|
|
44
50
|
"""Initialize the engine dependencies and execution lease."""
|
|
@@ -46,10 +52,20 @@ class SagaEngine(Generic[ModelT]):
|
|
|
46
52
|
self._session_maker = session_maker
|
|
47
53
|
self._execution_lease = execution_lease
|
|
48
54
|
self._repository = SagaRepository(model_class)
|
|
49
|
-
self._outbox_model_class = outbox_model_class
|
|
50
55
|
self._outbox_repository: OutboxRepository[OutboxMessageMixin] | None = None
|
|
51
|
-
if
|
|
56
|
+
if outbox_writer is not None:
|
|
57
|
+
self._outbox_writer: OutboxWriter | None = outbox_writer
|
|
58
|
+
elif outbox_model_class is not None:
|
|
52
59
|
self._outbox_repository = OutboxRepository(outbox_model_class)
|
|
60
|
+
self._outbox_writer = self._outbox_repository
|
|
61
|
+
else:
|
|
62
|
+
self._outbox_writer = None
|
|
63
|
+
self._outbox_serializer = outbox_serializer or JsonOutboxSerializer(
|
|
64
|
+
normalize=self._serialize_value
|
|
65
|
+
)
|
|
66
|
+
self._outbox_message_factory = (
|
|
67
|
+
outbox_message_factory or DefaultOutboxMessageFactory()
|
|
68
|
+
)
|
|
53
69
|
self._registry: dict[str, SagaDefinition] = {}
|
|
54
70
|
|
|
55
71
|
@property
|
|
@@ -62,6 +78,11 @@ class SagaEngine(Generic[ModelT]):
|
|
|
62
78
|
"""Return the outbox repository used by the engine."""
|
|
63
79
|
return self._outbox_repository
|
|
64
80
|
|
|
81
|
+
@property
|
|
82
|
+
def outbox_writer(self) -> OutboxWriter | None:
|
|
83
|
+
"""Return the outbox writer used by the engine."""
|
|
84
|
+
return self._outbox_writer
|
|
85
|
+
|
|
65
86
|
def register(self, name: str, saga_definition: SagaDefinition) -> None:
|
|
66
87
|
"""Register a saga definition under a runtime name."""
|
|
67
88
|
if name in self._registry:
|
|
@@ -590,38 +611,30 @@ class SagaEngine(Generic[ModelT]):
|
|
|
590
611
|
|
|
591
612
|
if error is None and step_output is not None:
|
|
592
613
|
if step_def.outbox_map is not None:
|
|
593
|
-
if
|
|
594
|
-
self._outbox_model_class is None
|
|
595
|
-
or self._outbox_repository is None
|
|
596
|
-
):
|
|
614
|
+
if self._outbox_writer is None:
|
|
597
615
|
raise SagaStateError(
|
|
598
616
|
"outbox_map is configured for step "
|
|
599
|
-
f"'{step_def.step_id}', but
|
|
617
|
+
f"'{step_def.step_id}', but outbox writer is not configured in SagaEngine"
|
|
600
618
|
)
|
|
601
619
|
outbox_events = (
|
|
602
620
|
step_def.outbox_map(step_input, step_output) or []
|
|
603
621
|
)
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
for event in outbox_events
|
|
619
|
-
]
|
|
620
|
-
if outbox_messages:
|
|
621
|
-
await self._outbox_repository.create_many(
|
|
622
|
-
session,
|
|
623
|
-
outbox_messages,
|
|
622
|
+
if outbox_events:
|
|
623
|
+
now = datetime.now(UTC)
|
|
624
|
+
outbox_messages = (
|
|
625
|
+
self._outbox_message_factory.build_messages(
|
|
626
|
+
saga_id=saga.id,
|
|
627
|
+
aggregation_id=saga.aggregation_id,
|
|
628
|
+
step_id=step_def.step_id,
|
|
629
|
+
trace_id=saga.trace_id,
|
|
630
|
+
step_input=step_input,
|
|
631
|
+
step_output=step_output,
|
|
632
|
+
events=outbox_events,
|
|
633
|
+
now=now,
|
|
634
|
+
serializer=self._outbox_serializer,
|
|
635
|
+
)
|
|
624
636
|
)
|
|
637
|
+
await self._outbox_writer.save(session, outbox_messages)
|
|
625
638
|
saga.step_history.append(
|
|
626
639
|
self._history_entry(
|
|
627
640
|
phase="execute",
|
|
@@ -15,8 +15,11 @@ from ..domain.models import (
|
|
|
15
15
|
SagaDefinition,
|
|
16
16
|
SagaSnapshot,
|
|
17
17
|
)
|
|
18
|
+
from ..outbox.contracts import OutboxWriter
|
|
19
|
+
from ..outbox.factory import OutboxMessageFactory
|
|
18
20
|
from ..outbox.models import OutboxMessageMixin
|
|
19
21
|
from ..outbox.repository import OutboxRepository
|
|
22
|
+
from ..outbox.serialization import OutboxSerializer
|
|
20
23
|
from .engine import SagaEngine
|
|
21
24
|
from .repository import SagaRepository
|
|
22
25
|
|
|
@@ -32,6 +35,9 @@ class SagaOrchestrator(Generic[ModelT]):
|
|
|
32
35
|
model_class: type[ModelT],
|
|
33
36
|
session_maker: async_sessionmaker[AsyncSession],
|
|
34
37
|
outbox_model_class: type[OutboxMessageMixin] | None = None,
|
|
38
|
+
outbox_writer: OutboxWriter | None = None,
|
|
39
|
+
outbox_serializer: OutboxSerializer | None = None,
|
|
40
|
+
outbox_message_factory: OutboxMessageFactory | None = None,
|
|
35
41
|
execution_lease: timedelta = timedelta(minutes=5),
|
|
36
42
|
) -> None:
|
|
37
43
|
"""Initialize the orchestrator facade."""
|
|
@@ -39,6 +45,9 @@ class SagaOrchestrator(Generic[ModelT]):
|
|
|
39
45
|
model_class=model_class,
|
|
40
46
|
session_maker=session_maker,
|
|
41
47
|
outbox_model_class=outbox_model_class,
|
|
48
|
+
outbox_writer=outbox_writer,
|
|
49
|
+
outbox_serializer=outbox_serializer,
|
|
50
|
+
outbox_message_factory=outbox_message_factory,
|
|
42
51
|
execution_lease=execution_lease,
|
|
43
52
|
)
|
|
44
53
|
|
|
@@ -57,6 +66,11 @@ class SagaOrchestrator(Generic[ModelT]):
|
|
|
57
66
|
"""Return the outbox repository used by the engine."""
|
|
58
67
|
return self._engine.outbox_repository
|
|
59
68
|
|
|
69
|
+
@property
|
|
70
|
+
def outbox_writer(self) -> OutboxWriter | None:
|
|
71
|
+
"""Return the outbox writer used by the engine."""
|
|
72
|
+
return self._engine.outbox_writer
|
|
73
|
+
|
|
60
74
|
def register(self, name: str, saga_definition: SagaDefinition) -> None:
|
|
61
75
|
"""Register a saga definition under a runtime name."""
|
|
62
76
|
self._engine.register(name, saga_definition)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Outbox module."""
|
|
2
|
+
|
|
3
|
+
from .contracts import (
|
|
4
|
+
ClaimedOutboxMessage,
|
|
5
|
+
OutboxPublisher,
|
|
6
|
+
OutboxWriteMessage,
|
|
7
|
+
OutboxWriter,
|
|
8
|
+
)
|
|
9
|
+
from .dispatcher import OutboxDispatcher
|
|
10
|
+
from .event import OutboxEvent
|
|
11
|
+
from .factory import DefaultOutboxMessageFactory, OutboxMessageFactory
|
|
12
|
+
from .models import OutboxMessageMixin, OutboxStatus
|
|
13
|
+
from .repository import OutboxRepository
|
|
14
|
+
from .retry import FixedOutboxDispatchRetry, OutboxDispatchRetryPolicy
|
|
15
|
+
from .serialization import JsonOutboxSerializer, OutboxSerializer
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ClaimedOutboxMessage",
|
|
19
|
+
"DefaultOutboxMessageFactory",
|
|
20
|
+
"FixedOutboxDispatchRetry",
|
|
21
|
+
"JsonOutboxSerializer",
|
|
22
|
+
"OutboxDispatcher",
|
|
23
|
+
"OutboxEvent",
|
|
24
|
+
"OutboxMessageFactory",
|
|
25
|
+
"OutboxMessageMixin",
|
|
26
|
+
"OutboxPublisher",
|
|
27
|
+
"OutboxRepository",
|
|
28
|
+
"OutboxStatus",
|
|
29
|
+
"OutboxWriteMessage",
|
|
30
|
+
"OutboxWriter",
|
|
31
|
+
"OutboxDispatchRetryPolicy",
|
|
32
|
+
"OutboxSerializer",
|
|
33
|
+
]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from .models import OutboxStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class OutboxWriteMessage:
|
|
16
|
+
"""Represent one outbox message ready to be persisted."""
|
|
17
|
+
|
|
18
|
+
saga_id: UUID
|
|
19
|
+
aggregation_id: str
|
|
20
|
+
step_id: str
|
|
21
|
+
trace_id: str
|
|
22
|
+
topic: str
|
|
23
|
+
payload: dict[str, Any]
|
|
24
|
+
key: str | None = None
|
|
25
|
+
headers: dict[str, Any] = field(default_factory=dict)
|
|
26
|
+
status: OutboxStatus = OutboxStatus.PENDING
|
|
27
|
+
next_attempt_at: datetime | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ClaimedOutboxMessage:
|
|
32
|
+
"""Represent one claimed outbox message ready to be dispatched."""
|
|
33
|
+
|
|
34
|
+
id: UUID
|
|
35
|
+
topic: str
|
|
36
|
+
payload: dict[str, Any]
|
|
37
|
+
key: str | None = None
|
|
38
|
+
headers: dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
attempts: int = 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OutboxWriter(Protocol):
|
|
43
|
+
"""Define persistence operations required by engine and dispatcher."""
|
|
44
|
+
|
|
45
|
+
async def save(
|
|
46
|
+
self,
|
|
47
|
+
session: AsyncSession,
|
|
48
|
+
messages: Sequence[OutboxWriteMessage],
|
|
49
|
+
) -> None: ...
|
|
50
|
+
|
|
51
|
+
async def claim_due(
|
|
52
|
+
self,
|
|
53
|
+
session: AsyncSession,
|
|
54
|
+
*,
|
|
55
|
+
now: datetime,
|
|
56
|
+
limit: int,
|
|
57
|
+
) -> list[ClaimedOutboxMessage]: ...
|
|
58
|
+
|
|
59
|
+
async def mark_sent(
|
|
60
|
+
self,
|
|
61
|
+
session: AsyncSession,
|
|
62
|
+
message_id: UUID,
|
|
63
|
+
*,
|
|
64
|
+
sent_at: datetime,
|
|
65
|
+
) -> bool: ...
|
|
66
|
+
|
|
67
|
+
async def mark_failed(
|
|
68
|
+
self,
|
|
69
|
+
session: AsyncSession,
|
|
70
|
+
message_id: UUID,
|
|
71
|
+
*,
|
|
72
|
+
error: str,
|
|
73
|
+
next_attempt_at: datetime,
|
|
74
|
+
) -> bool: ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class OutboxPublisher(Protocol):
|
|
78
|
+
"""Define transport publish operation for outbox dispatch."""
|
|
79
|
+
|
|
80
|
+
async def publish(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
topic: str,
|
|
84
|
+
payload: dict[str, Any],
|
|
85
|
+
key: str | None = None,
|
|
86
|
+
headers: dict[str, Any] | None = None,
|
|
87
|
+
) -> None: ...
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime, timedelta
|
|
4
|
+
from typing import TypeVar
|
|
5
|
+
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
7
|
+
|
|
8
|
+
from .contracts import OutboxPublisher, OutboxWriter
|
|
9
|
+
from .models import OutboxMessageMixin
|
|
10
|
+
from .repository import OutboxRepository
|
|
11
|
+
from .retry import FixedOutboxDispatchRetry, OutboxDispatchRetryPolicy
|
|
12
|
+
from .serialization import JsonOutboxSerializer, OutboxSerializer
|
|
13
|
+
|
|
14
|
+
OutboxModelT = TypeVar("OutboxModelT", bound=OutboxMessageMixin)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OutboxDispatcher:
|
|
18
|
+
"""Dispatch outbox rows to an external transport."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
session_maker: async_sessionmaker[AsyncSession],
|
|
24
|
+
publisher: OutboxPublisher,
|
|
25
|
+
writer: OutboxWriter | None = None,
|
|
26
|
+
model_class: type[OutboxModelT] | None = None,
|
|
27
|
+
serializer: OutboxSerializer | None = None,
|
|
28
|
+
retry_policy: OutboxDispatchRetryPolicy | None = None,
|
|
29
|
+
failure_backoff: timedelta = timedelta(seconds=30),
|
|
30
|
+
) -> None:
|
|
31
|
+
self._session_maker = session_maker
|
|
32
|
+
self._publisher = publisher
|
|
33
|
+
if writer is None:
|
|
34
|
+
if model_class is None:
|
|
35
|
+
raise ValueError("Either writer or model_class must be provided")
|
|
36
|
+
self._writer: OutboxWriter = OutboxRepository(model_class)
|
|
37
|
+
else:
|
|
38
|
+
self._writer = writer
|
|
39
|
+
self._serializer = serializer or JsonOutboxSerializer()
|
|
40
|
+
self._retry_policy = retry_policy or FixedOutboxDispatchRetry(
|
|
41
|
+
delay=failure_backoff
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
async def run_once(self, *, limit: int = 100) -> int:
|
|
45
|
+
"""Claim due outbox messages and attempt to publish them once."""
|
|
46
|
+
now = datetime.now(UTC)
|
|
47
|
+
|
|
48
|
+
async with self._session_maker() as session:
|
|
49
|
+
async with session.begin():
|
|
50
|
+
claimed = await self._writer.claim_due(
|
|
51
|
+
session,
|
|
52
|
+
now=now,
|
|
53
|
+
limit=limit,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
for message in claimed:
|
|
57
|
+
try:
|
|
58
|
+
await self._publisher.publish(
|
|
59
|
+
topic=message.topic,
|
|
60
|
+
payload=self._serializer.deserialize_payload(message.payload),
|
|
61
|
+
key=message.key,
|
|
62
|
+
headers=self._serializer.deserialize_headers(message.headers),
|
|
63
|
+
)
|
|
64
|
+
except Exception as exc: # noqa: BLE001
|
|
65
|
+
delay = self._retry_policy.next_delay(message.attempts + 1, exc)
|
|
66
|
+
async with self._session_maker() as session:
|
|
67
|
+
async with session.begin():
|
|
68
|
+
await self._writer.mark_failed(
|
|
69
|
+
session,
|
|
70
|
+
message.id,
|
|
71
|
+
error=repr(exc),
|
|
72
|
+
next_attempt_at=datetime.now(UTC) + delay,
|
|
73
|
+
)
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
async with self._session_maker() as session:
|
|
77
|
+
async with session.begin():
|
|
78
|
+
await self._writer.mark_sent(
|
|
79
|
+
session,
|
|
80
|
+
message.id,
|
|
81
|
+
sent_at=datetime.now(UTC),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return len(claimed)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from .contracts import OutboxWriteMessage
|
|
11
|
+
from .event import OutboxEvent
|
|
12
|
+
from .serialization import OutboxSerializer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OutboxMessageFactory(Protocol):
|
|
16
|
+
"""Define conversion from step outbox events to persisted outbox messages."""
|
|
17
|
+
|
|
18
|
+
def build_messages(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
saga_id: UUID,
|
|
22
|
+
aggregation_id: str,
|
|
23
|
+
step_id: str,
|
|
24
|
+
trace_id: str,
|
|
25
|
+
step_input: BaseModel,
|
|
26
|
+
step_output: BaseModel,
|
|
27
|
+
events: Sequence[OutboxEvent],
|
|
28
|
+
now: datetime,
|
|
29
|
+
serializer: OutboxSerializer,
|
|
30
|
+
) -> list[OutboxWriteMessage]: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DefaultOutboxMessageFactory:
|
|
34
|
+
"""Build default outbox messages from mapped step events."""
|
|
35
|
+
|
|
36
|
+
def build_messages(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
saga_id: UUID,
|
|
40
|
+
aggregation_id: str,
|
|
41
|
+
step_id: str,
|
|
42
|
+
trace_id: str,
|
|
43
|
+
step_input: BaseModel,
|
|
44
|
+
step_output: BaseModel,
|
|
45
|
+
events: Sequence[OutboxEvent],
|
|
46
|
+
now: datetime,
|
|
47
|
+
serializer: OutboxSerializer,
|
|
48
|
+
) -> list[OutboxWriteMessage]:
|
|
49
|
+
return [
|
|
50
|
+
OutboxWriteMessage(
|
|
51
|
+
saga_id=saga_id,
|
|
52
|
+
aggregation_id=aggregation_id,
|
|
53
|
+
step_id=step_id,
|
|
54
|
+
trace_id=trace_id,
|
|
55
|
+
topic=event.topic,
|
|
56
|
+
key=event.key,
|
|
57
|
+
payload=serializer.serialize_payload(event.payload),
|
|
58
|
+
headers=serializer.serialize_headers(event.headers),
|
|
59
|
+
next_attempt_at=now,
|
|
60
|
+
)
|
|
61
|
+
for event in events
|
|
62
|
+
]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Generic, TypeVar
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import Select, select
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from .contracts import ClaimedOutboxMessage, OutboxWriteMessage
|
|
12
|
+
from .models import OutboxMessageMixin, OutboxStatus
|
|
13
|
+
|
|
14
|
+
OutboxModelT = TypeVar("OutboxModelT", bound=OutboxMessageMixin)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OutboxRepository(Generic[OutboxModelT]):
|
|
18
|
+
"""Provide persistence operations for outbox rows."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, model_class: type[OutboxModelT]) -> None:
|
|
21
|
+
self.model_class = model_class
|
|
22
|
+
|
|
23
|
+
async def create_many(
|
|
24
|
+
self,
|
|
25
|
+
session: AsyncSession,
|
|
26
|
+
messages: list[OutboxModelT],
|
|
27
|
+
) -> None:
|
|
28
|
+
session.add_all(messages)
|
|
29
|
+
await session.flush()
|
|
30
|
+
|
|
31
|
+
async def save(
|
|
32
|
+
self,
|
|
33
|
+
session: AsyncSession,
|
|
34
|
+
messages: Sequence[OutboxWriteMessage],
|
|
35
|
+
) -> None:
|
|
36
|
+
if not messages:
|
|
37
|
+
return
|
|
38
|
+
rows = [
|
|
39
|
+
self.model_class(
|
|
40
|
+
saga_id=message.saga_id,
|
|
41
|
+
aggregation_id=message.aggregation_id,
|
|
42
|
+
step_id=message.step_id,
|
|
43
|
+
trace_id=message.trace_id,
|
|
44
|
+
topic=message.topic,
|
|
45
|
+
message_key=message.key,
|
|
46
|
+
payload=message.payload,
|
|
47
|
+
headers=message.headers,
|
|
48
|
+
status=message.status,
|
|
49
|
+
next_attempt_at=message.next_attempt_at or datetime.now(UTC),
|
|
50
|
+
)
|
|
51
|
+
for message in messages
|
|
52
|
+
]
|
|
53
|
+
await self.create_many(session, rows)
|
|
54
|
+
|
|
55
|
+
async def due_for_dispatch(
|
|
56
|
+
self,
|
|
57
|
+
session: AsyncSession,
|
|
58
|
+
*,
|
|
59
|
+
now: datetime,
|
|
60
|
+
limit: int,
|
|
61
|
+
) -> list[OutboxModelT]:
|
|
62
|
+
stmt: Select[tuple[OutboxModelT]] = (
|
|
63
|
+
select(self.model_class)
|
|
64
|
+
.where(
|
|
65
|
+
self.model_class.status.in_(
|
|
66
|
+
(OutboxStatus.PENDING, OutboxStatus.FAILED),
|
|
67
|
+
),
|
|
68
|
+
self.model_class.next_attempt_at <= now,
|
|
69
|
+
)
|
|
70
|
+
.order_by(
|
|
71
|
+
self.model_class.next_attempt_at.asc(),
|
|
72
|
+
self.model_class.created_at.asc(),
|
|
73
|
+
)
|
|
74
|
+
.limit(limit)
|
|
75
|
+
)
|
|
76
|
+
if self._supports_skip_locked(session):
|
|
77
|
+
stmt = stmt.with_for_update(skip_locked=True)
|
|
78
|
+
else:
|
|
79
|
+
stmt = stmt.with_for_update(nowait=False)
|
|
80
|
+
result = await session.execute(stmt)
|
|
81
|
+
return list(result.scalars().all())
|
|
82
|
+
|
|
83
|
+
async def claim_due(
|
|
84
|
+
self,
|
|
85
|
+
session: AsyncSession,
|
|
86
|
+
*,
|
|
87
|
+
now: datetime,
|
|
88
|
+
limit: int,
|
|
89
|
+
) -> list[ClaimedOutboxMessage]:
|
|
90
|
+
due = await self.due_for_dispatch(session, now=now, limit=limit)
|
|
91
|
+
claimed: list[ClaimedOutboxMessage] = []
|
|
92
|
+
for message in due:
|
|
93
|
+
message.status = OutboxStatus.DISPATCHING
|
|
94
|
+
claimed.append(
|
|
95
|
+
ClaimedOutboxMessage(
|
|
96
|
+
id=message.id,
|
|
97
|
+
topic=message.topic,
|
|
98
|
+
payload=message.payload,
|
|
99
|
+
key=message.message_key,
|
|
100
|
+
headers=message.headers,
|
|
101
|
+
attempts=message.attempts,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
return claimed
|
|
105
|
+
|
|
106
|
+
async def get_for_update(
|
|
107
|
+
self,
|
|
108
|
+
session: AsyncSession,
|
|
109
|
+
message_id: UUID,
|
|
110
|
+
) -> OutboxModelT | None:
|
|
111
|
+
stmt: Select[tuple[OutboxModelT]] = (
|
|
112
|
+
select(self.model_class)
|
|
113
|
+
.where(self.model_class.id == message_id)
|
|
114
|
+
.with_for_update(nowait=False)
|
|
115
|
+
)
|
|
116
|
+
result = await session.execute(stmt)
|
|
117
|
+
return result.scalar_one_or_none()
|
|
118
|
+
|
|
119
|
+
async def mark_sent(
|
|
120
|
+
self,
|
|
121
|
+
session: AsyncSession,
|
|
122
|
+
message_id: UUID,
|
|
123
|
+
*,
|
|
124
|
+
sent_at: datetime,
|
|
125
|
+
) -> bool:
|
|
126
|
+
row = await self.get_for_update(session, message_id)
|
|
127
|
+
if row is None or row.status != OutboxStatus.DISPATCHING:
|
|
128
|
+
return False
|
|
129
|
+
row.status = OutboxStatus.SENT
|
|
130
|
+
row.sent_at = sent_at
|
|
131
|
+
row.last_error = None
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
async def mark_failed(
|
|
135
|
+
self,
|
|
136
|
+
session: AsyncSession,
|
|
137
|
+
message_id: UUID,
|
|
138
|
+
*,
|
|
139
|
+
error: str,
|
|
140
|
+
next_attempt_at: datetime,
|
|
141
|
+
) -> bool:
|
|
142
|
+
row = await self.get_for_update(session, message_id)
|
|
143
|
+
if row is None or row.status != OutboxStatus.DISPATCHING:
|
|
144
|
+
return False
|
|
145
|
+
row.status = OutboxStatus.FAILED
|
|
146
|
+
row.attempts += 1
|
|
147
|
+
row.last_error = error
|
|
148
|
+
row.next_attempt_at = next_attempt_at
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _supports_skip_locked(session: AsyncSession) -> bool:
|
|
153
|
+
bind = session.get_bind()
|
|
154
|
+
return bind is not None and bind.dialect.name == "postgresql"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OutboxDispatchRetryPolicy(Protocol):
|
|
8
|
+
"""Define scheduling of the next outbox dispatch attempt after failure."""
|
|
9
|
+
|
|
10
|
+
def next_delay(self, attempt: int, error: Exception) -> timedelta: ...
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FixedOutboxDispatchRetry:
|
|
14
|
+
"""Return a fixed delay for each failed dispatch attempt."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, *, delay: timedelta = timedelta(seconds=30)) -> None:
|
|
17
|
+
self._delay = delay
|
|
18
|
+
|
|
19
|
+
def next_delay(self, attempt: int, error: Exception) -> timedelta:
|
|
20
|
+
return self._delay
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OutboxSerializer(Protocol):
|
|
8
|
+
"""Define serialization and deserialization for outbox payload and headers."""
|
|
9
|
+
|
|
10
|
+
def serialize_payload(self, payload: Any) -> dict[str, Any]: ...
|
|
11
|
+
|
|
12
|
+
def serialize_headers(self, headers: Any) -> dict[str, Any]: ...
|
|
13
|
+
|
|
14
|
+
def deserialize_payload(self, payload: Any) -> dict[str, Any]: ...
|
|
15
|
+
|
|
16
|
+
def deserialize_headers(self, headers: Any) -> dict[str, Any]: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JsonOutboxSerializer:
|
|
20
|
+
"""Serialize payload and headers into JSON-compatible dictionaries."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, *, normalize: Callable[[Any], Any] | None = None) -> None:
|
|
23
|
+
self._normalize = normalize
|
|
24
|
+
|
|
25
|
+
def serialize_payload(self, payload: Any) -> dict[str, Any]:
|
|
26
|
+
return self._to_dict(payload, field_name="payload")
|
|
27
|
+
|
|
28
|
+
def serialize_headers(self, headers: Any) -> dict[str, Any]:
|
|
29
|
+
if headers is None:
|
|
30
|
+
return {}
|
|
31
|
+
return self._to_dict(headers, field_name="headers")
|
|
32
|
+
|
|
33
|
+
def deserialize_payload(self, payload: Any) -> dict[str, Any]:
|
|
34
|
+
return self._to_dict(payload, field_name="payload")
|
|
35
|
+
|
|
36
|
+
def deserialize_headers(self, headers: Any) -> dict[str, Any]:
|
|
37
|
+
if headers is None:
|
|
38
|
+
return {}
|
|
39
|
+
return self._to_dict(headers, field_name="headers")
|
|
40
|
+
|
|
41
|
+
def _to_dict(self, value: Any, *, field_name: str) -> dict[str, Any]:
|
|
42
|
+
normalized = self._normalize(value) if self._normalize is not None else value
|
|
43
|
+
if isinstance(normalized, dict):
|
|
44
|
+
return normalized
|
|
45
|
+
raise TypeError(
|
|
46
|
+
f"Outbox {field_name} must serialize to dict, got {type(value)!r}"
|
|
47
|
+
)
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
"""Outbox module."""
|
|
2
|
-
|
|
3
|
-
from .dispatcher import OutboxDispatcher, OutboxPublisher
|
|
4
|
-
from .event import OutboxEvent
|
|
5
|
-
from .models import OutboxMessageMixin, OutboxStatus
|
|
6
|
-
from .repository import OutboxRepository
|
|
7
|
-
|
|
8
|
-
__all__ = [
|
|
9
|
-
"OutboxDispatcher",
|
|
10
|
-
"OutboxEvent",
|
|
11
|
-
"OutboxMessageMixin",
|
|
12
|
-
"OutboxPublisher",
|
|
13
|
-
"OutboxRepository",
|
|
14
|
-
"OutboxStatus",
|
|
15
|
-
]
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from datetime import UTC, datetime, timedelta
|
|
4
|
-
from typing import Any, Protocol, TypeVar
|
|
5
|
-
from uuid import UUID
|
|
6
|
-
|
|
7
|
-
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
8
|
-
|
|
9
|
-
from .models import OutboxMessageMixin, OutboxStatus
|
|
10
|
-
from .repository import OutboxRepository
|
|
11
|
-
|
|
12
|
-
OutboxModelT = TypeVar("OutboxModelT", bound=OutboxMessageMixin)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class OutboxPublisher(Protocol):
|
|
16
|
-
async def publish(
|
|
17
|
-
self,
|
|
18
|
-
*,
|
|
19
|
-
topic: str,
|
|
20
|
-
payload: dict[str, Any],
|
|
21
|
-
key: str | None = None,
|
|
22
|
-
headers: dict[str, Any] | None = None,
|
|
23
|
-
) -> None: ...
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class OutboxDispatcher:
|
|
27
|
-
"""Dispatch outbox rows to an external transport."""
|
|
28
|
-
|
|
29
|
-
def __init__(
|
|
30
|
-
self,
|
|
31
|
-
*,
|
|
32
|
-
session_maker: async_sessionmaker[AsyncSession],
|
|
33
|
-
model_class: type[OutboxModelT],
|
|
34
|
-
publisher: OutboxPublisher,
|
|
35
|
-
failure_backoff: timedelta = timedelta(seconds=30),
|
|
36
|
-
) -> None:
|
|
37
|
-
self._session_maker = session_maker
|
|
38
|
-
self._repository = OutboxRepository(model_class)
|
|
39
|
-
self._publisher = publisher
|
|
40
|
-
self._failure_backoff = failure_backoff
|
|
41
|
-
|
|
42
|
-
async def run_once(self, *, limit: int = 100) -> int:
|
|
43
|
-
"""Claim due outbox messages and attempt to publish them once."""
|
|
44
|
-
now = datetime.now(UTC)
|
|
45
|
-
claimed: list[tuple[UUID, str, dict[str, Any], str | None, dict[str, Any]]] = []
|
|
46
|
-
|
|
47
|
-
async with self._session_maker() as session:
|
|
48
|
-
async with session.begin():
|
|
49
|
-
due = await self._repository.due_for_dispatch(
|
|
50
|
-
session,
|
|
51
|
-
now=now,
|
|
52
|
-
limit=limit,
|
|
53
|
-
)
|
|
54
|
-
for message in due:
|
|
55
|
-
message.status = OutboxStatus.DISPATCHING
|
|
56
|
-
claimed.append(
|
|
57
|
-
(
|
|
58
|
-
message.id,
|
|
59
|
-
message.topic,
|
|
60
|
-
message.payload,
|
|
61
|
-
message.message_key,
|
|
62
|
-
message.headers,
|
|
63
|
-
)
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
for message_id, topic, payload, key, headers in claimed:
|
|
67
|
-
try:
|
|
68
|
-
await self._publisher.publish(
|
|
69
|
-
topic=topic,
|
|
70
|
-
payload=payload,
|
|
71
|
-
key=key,
|
|
72
|
-
headers=headers,
|
|
73
|
-
)
|
|
74
|
-
except Exception as exc: # noqa: BLE001
|
|
75
|
-
async with self._session_maker() as session:
|
|
76
|
-
async with session.begin():
|
|
77
|
-
row = await self._repository.get_for_update(session, message_id)
|
|
78
|
-
if row is None or row.status != OutboxStatus.DISPATCHING:
|
|
79
|
-
continue
|
|
80
|
-
row.status = OutboxStatus.FAILED
|
|
81
|
-
row.attempts += 1
|
|
82
|
-
row.last_error = repr(exc)
|
|
83
|
-
row.next_attempt_at = datetime.now(UTC) + self._failure_backoff
|
|
84
|
-
continue
|
|
85
|
-
|
|
86
|
-
async with self._session_maker() as session:
|
|
87
|
-
async with session.begin():
|
|
88
|
-
row = await self._repository.get_for_update(session, message_id)
|
|
89
|
-
if row is None or row.status != OutboxStatus.DISPATCHING:
|
|
90
|
-
continue
|
|
91
|
-
row.status = OutboxStatus.SENT
|
|
92
|
-
row.sent_at = datetime.now(UTC)
|
|
93
|
-
row.last_error = None
|
|
94
|
-
|
|
95
|
-
return len(claimed)
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from datetime import datetime
|
|
4
|
-
from typing import Generic, TypeVar
|
|
5
|
-
from uuid import UUID
|
|
6
|
-
|
|
7
|
-
from sqlalchemy import Select, select
|
|
8
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
-
|
|
10
|
-
from .models import OutboxMessageMixin, OutboxStatus
|
|
11
|
-
|
|
12
|
-
OutboxModelT = TypeVar("OutboxModelT", bound=OutboxMessageMixin)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class OutboxRepository(Generic[OutboxModelT]):
|
|
16
|
-
"""Provide persistence operations for outbox rows."""
|
|
17
|
-
|
|
18
|
-
def __init__(self, model_class: type[OutboxModelT]) -> None:
|
|
19
|
-
self.model_class = model_class
|
|
20
|
-
|
|
21
|
-
async def create_many(
|
|
22
|
-
self,
|
|
23
|
-
session: AsyncSession,
|
|
24
|
-
messages: list[OutboxModelT],
|
|
25
|
-
) -> None:
|
|
26
|
-
session.add_all(messages)
|
|
27
|
-
await session.flush()
|
|
28
|
-
|
|
29
|
-
async def due_for_dispatch(
|
|
30
|
-
self,
|
|
31
|
-
session: AsyncSession,
|
|
32
|
-
*,
|
|
33
|
-
now: datetime,
|
|
34
|
-
limit: int,
|
|
35
|
-
) -> list[OutboxModelT]:
|
|
36
|
-
stmt: Select[tuple[OutboxModelT]] = (
|
|
37
|
-
select(self.model_class)
|
|
38
|
-
.where(
|
|
39
|
-
self.model_class.status.in_(
|
|
40
|
-
(OutboxStatus.PENDING, OutboxStatus.FAILED),
|
|
41
|
-
),
|
|
42
|
-
self.model_class.next_attempt_at <= now,
|
|
43
|
-
)
|
|
44
|
-
.order_by(
|
|
45
|
-
self.model_class.next_attempt_at.asc(),
|
|
46
|
-
self.model_class.created_at.asc(),
|
|
47
|
-
)
|
|
48
|
-
.limit(limit)
|
|
49
|
-
)
|
|
50
|
-
if self._supports_skip_locked(session):
|
|
51
|
-
stmt = stmt.with_for_update(skip_locked=True)
|
|
52
|
-
else:
|
|
53
|
-
stmt = stmt.with_for_update(nowait=False)
|
|
54
|
-
result = await session.execute(stmt)
|
|
55
|
-
return list(result.scalars().all())
|
|
56
|
-
|
|
57
|
-
async def get_for_update(
|
|
58
|
-
self,
|
|
59
|
-
session: AsyncSession,
|
|
60
|
-
message_id: UUID,
|
|
61
|
-
) -> OutboxModelT | None:
|
|
62
|
-
stmt: Select[tuple[OutboxModelT]] = (
|
|
63
|
-
select(self.model_class)
|
|
64
|
-
.where(self.model_class.id == message_id)
|
|
65
|
-
.with_for_update(nowait=False)
|
|
66
|
-
)
|
|
67
|
-
result = await session.execute(stmt)
|
|
68
|
-
return result.scalar_one_or_none()
|
|
69
|
-
|
|
70
|
-
@staticmethod
|
|
71
|
-
def _supports_skip_locked(session: AsyncSession) -> bool:
|
|
72
|
-
bind = session.get_bind()
|
|
73
|
-
return bind is not None and bind.dialect.name == "postgresql"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/admin/api.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/__init__.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/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
|
{python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/outbox/event.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/outbox/models.py
RENAMED
|
File without changes
|
|
File without changes
|