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.
Files changed (43) hide show
  1. {python_saga_orchestrator-0.1.2/python_saga_orchestrator.egg-info → python_saga_orchestrator-0.1.3}/PKG-INFO +1 -1
  2. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/pyproject.toml +1 -1
  3. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3/python_saga_orchestrator.egg-info}/PKG-INFO +1 -1
  4. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/SOURCES.txt +5 -1
  5. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/__init__.py +18 -0
  6. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/engine.py +41 -28
  7. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/orchestrator.py +14 -0
  8. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/__init__.py +33 -0
  9. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/contracts.py +87 -0
  10. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/dispatcher.py +84 -0
  11. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/factory.py +62 -0
  12. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/repository.py +154 -0
  13. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/retry.py +20 -0
  14. python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/serialization.py +47 -0
  15. python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/__init__.py +0 -15
  16. python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/dispatcher.py +0 -95
  17. python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/repository.py +0 -73
  18. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/LICENSE +0 -0
  19. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/README.md +0 -0
  20. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
  21. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/requires.txt +0 -0
  22. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
  23. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/admin/__init__.py +0 -0
  24. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/admin/api.py +0 -0
  25. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/__init__.py +0 -0
  26. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/builder.py +0 -0
  27. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/repository.py +0 -0
  28. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/__init__.py +0 -0
  29. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
  30. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/exceptions/saga.py +0 -0
  31. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/mixins/__init__.py +0 -0
  32. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
  33. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/__init__.py +0 -0
  34. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/builder.py +0 -0
  35. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
  36. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/enums/saga_status.py +0 -0
  37. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/notify.py +0 -0
  38. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/retry.py +0 -0
  39. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
  40. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/step.py +0 -0
  41. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/outbox/event.py +0 -0
  42. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/saga_orchestrator/outbox/models.py +0 -0
  43. {python_saga_orchestrator-0.1.2 → python_saga_orchestrator-0.1.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-saga-orchestrator
3
- Version: 0.1.2
3
+ Version: 0.1.3
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-saga-orchestrator"
7
- version = "0.1.2"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-saga-orchestrator
3
- Version: 0.1.2
3
+ Version: 0.1.3
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
@@ -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
@@ -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",
@@ -23,8 +23,11 @@ from ..domain.models import (
23
23
  StepDefinition,
24
24
  )
25
25
  from ..domain.models.enums import SagaStatus
26
- from ..outbox.models import OutboxMessageMixin, OutboxStatus
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 outbox_model_class is not None:
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 outbox_model_class is not configured in SagaEngine"
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
- now = datetime.now(UTC)
605
- outbox_messages = [
606
- self._outbox_model_class(
607
- saga_id=saga.id,
608
- aggregation_id=saga.aggregation_id,
609
- step_id=step_def.step_id,
610
- trace_id=saga.trace_id,
611
- topic=event.topic,
612
- message_key=event.key,
613
- payload=self._serialize_value(event.payload),
614
- headers=self._serialize_value(event.headers),
615
- status=OutboxStatus.PENDING,
616
- next_attempt_at=now,
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"