edda-framework 0.1.0__py3-none-any.whl

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.
edda/outbox/relayer.py ADDED
@@ -0,0 +1,274 @@
1
+ """
2
+ Outbox Relayer - Background publisher for transactional outbox pattern.
3
+
4
+ This module implements a background task that polls the database for pending
5
+ outbox events and publishes them to a Message Broker as CloudEvents.
6
+ """
7
+
8
+ import asyncio
9
+ import contextlib
10
+ import logging
11
+ from datetime import UTC, datetime, timedelta
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ import httpx
15
+ from cloudevents.conversion import to_structured
16
+ from cloudevents.http import CloudEvent
17
+
18
+ if TYPE_CHECKING:
19
+ from edda.storage.protocol import StorageProtocol
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class OutboxRelayer:
25
+ """
26
+ Background relayer for publishing outbox events.
27
+
28
+ The relayer polls the database for pending events and publishes them
29
+ to a Message Broker. It implements exponential backoff for retries
30
+ and graceful shutdown.
31
+
32
+ Example:
33
+ >>> storage = SQLiteStorage("saga.db")
34
+ >>> relayer = OutboxRelayer(
35
+ ... storage=storage,
36
+ ... broker_url="http://broker-ingress.svc.cluster.local/default/default",
37
+ ... poll_interval=1.0,
38
+ ... max_retries=3
39
+ ... )
40
+ >>> await relayer.start()
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ storage: "StorageProtocol",
46
+ broker_url: str,
47
+ poll_interval: float = 1.0,
48
+ max_retries: int = 3,
49
+ batch_size: int = 10,
50
+ max_age_hours: float | None = None,
51
+ ):
52
+ """
53
+ Initialize the Outbox Relayer.
54
+
55
+ Args:
56
+ storage: Storage backend for outbox events
57
+ broker_url: Message Broker URL for publishing events
58
+ poll_interval: Polling interval in seconds (default: 1.0)
59
+ max_retries: Maximum retry attempts (default: 3)
60
+ batch_size: Number of events to process per batch (default: 10)
61
+ max_age_hours: Maximum event age in hours before expiration (default: None, disabled)
62
+ Events older than this are marked as 'expired' and won't be retried.
63
+ """
64
+ self.storage = storage
65
+ self.broker_url = broker_url
66
+ self.poll_interval = poll_interval
67
+ self.max_retries = max_retries
68
+ self.batch_size = batch_size
69
+ self.max_age_hours = max_age_hours
70
+
71
+ self._task: asyncio.Task[Any] | None = None
72
+ self._running = False
73
+ self._http_client: httpx.AsyncClient | None = None
74
+
75
+ async def start(self) -> None:
76
+ """
77
+ Start the background relayer task.
78
+
79
+ This creates an HTTP client and starts the polling loop in a background task.
80
+ """
81
+ if self._running:
82
+ logger.warning("Outbox relayer is already running")
83
+ return
84
+
85
+ self._running = True
86
+ self._http_client = httpx.AsyncClient(timeout=30.0)
87
+
88
+ # Start background task
89
+ self._task = asyncio.create_task(self._poll_loop())
90
+ logger.info(
91
+ f"Outbox relayer started (broker={self.broker_url}, "
92
+ f"poll_interval={self.poll_interval}s)"
93
+ )
94
+
95
+ async def stop(self) -> None:
96
+ """
97
+ Stop the background relayer task gracefully.
98
+
99
+ This cancels the polling loop and closes the HTTP client.
100
+ """
101
+ if not self._running:
102
+ return
103
+
104
+ self._running = False
105
+
106
+ # Cancel background task
107
+ if self._task:
108
+ self._task.cancel()
109
+ with contextlib.suppress(asyncio.CancelledError):
110
+ await self._task
111
+
112
+ # Close HTTP client
113
+ if self._http_client:
114
+ await self._http_client.aclose()
115
+
116
+ logger.info("Outbox relayer stopped")
117
+
118
+ async def _poll_loop(self) -> None:
119
+ """
120
+ Main polling loop.
121
+
122
+ Continuously polls the database for pending events and publishes them.
123
+ """
124
+ while self._running:
125
+ try:
126
+ await self._poll_and_publish()
127
+ except Exception as e:
128
+ logger.error(f"Error in outbox relayer poll loop: {e}")
129
+
130
+ # Wait before next poll
131
+ await asyncio.sleep(self.poll_interval)
132
+
133
+ async def _poll_and_publish(self) -> None:
134
+ """
135
+ Poll for pending events and publish them.
136
+
137
+ Fetches a batch of pending events from the database and attempts
138
+ to publish each one to the Message Broker.
139
+ """
140
+ # Get pending events
141
+ events = await self.storage.get_pending_outbox_events(limit=self.batch_size)
142
+
143
+ if not events:
144
+ return
145
+
146
+ logger.debug(f"Processing {len(events)} pending outbox events")
147
+
148
+ # Publish each event
149
+ for event in events:
150
+ try:
151
+ await self._publish_event(event)
152
+ except Exception as e:
153
+ logger.error(
154
+ f"Failed to publish event {event['event_id']}: {e}",
155
+ exc_info=True,
156
+ )
157
+
158
+ async def _publish_event(self, event: dict[str, Any]) -> None:
159
+ """
160
+ Publish a single outbox event to the Message Broker.
161
+
162
+ Args:
163
+ event: Outbox event record from database
164
+
165
+ Raises:
166
+ Exception: If publishing fails after max retries
167
+ """
168
+ event_id = event["event_id"]
169
+ retry_count = event["retry_count"]
170
+
171
+ # Check if max age exceeded (optional feature)
172
+ if self.max_age_hours is not None:
173
+ created_at = event["created_at"]
174
+ # Convert to datetime if it's a string (from database)
175
+ if isinstance(created_at, str):
176
+ created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
177
+ elif not hasattr(created_at, "tzinfo") or created_at.tzinfo is None:
178
+ # Assume UTC if no timezone info
179
+ created_at = created_at.replace(tzinfo=UTC)
180
+
181
+ event_age = datetime.now(UTC) - created_at
182
+ max_age = timedelta(hours=self.max_age_hours)
183
+
184
+ if event_age > max_age:
185
+ logger.warning(
186
+ f"Event {event_id} exceeded max age "
187
+ f"({self.max_age_hours} hours, age={event_age}), "
188
+ "marking as expired"
189
+ )
190
+ await self.storage.mark_outbox_expired(
191
+ event_id,
192
+ f"Exceeded max age ({self.max_age_hours} hours, age={event_age})",
193
+ )
194
+ return
195
+
196
+ # Check if max retries exceeded
197
+ if retry_count >= self.max_retries:
198
+ logger.warning(
199
+ f"Event {event_id} exceeded max retries ({self.max_retries}), "
200
+ "marking as permanently failed"
201
+ )
202
+ await self.storage.mark_outbox_permanently_failed(
203
+ event_id, f"Exceeded max retries ({self.max_retries})"
204
+ )
205
+ return
206
+
207
+ try:
208
+ # Build CloudEvent
209
+ ce = CloudEvent(
210
+ {
211
+ "type": event["event_type"],
212
+ "source": event["event_source"],
213
+ "id": event_id,
214
+ },
215
+ event["event_data"],
216
+ )
217
+
218
+ # Convert to structured format
219
+ headers, body = to_structured(ce)
220
+
221
+ # Publish to Message Broker
222
+ if self._http_client is None:
223
+ raise RuntimeError("HTTP client not initialized")
224
+
225
+ response = await self._http_client.post(
226
+ self.broker_url,
227
+ headers=headers,
228
+ content=body,
229
+ )
230
+
231
+ # Check response
232
+ response.raise_for_status()
233
+
234
+ # Mark as published
235
+ await self.storage.mark_outbox_published(event_id)
236
+ logger.info(f"Successfully published event {event_id}")
237
+
238
+ except httpx.HTTPStatusError as e:
239
+ # HTTP error with status code - distinguish 4xx (client) vs 5xx (server)
240
+ status_code = e.response.status_code
241
+ error_msg = f"HTTP {status_code}: {str(e)}"
242
+
243
+ if 400 <= status_code < 500:
244
+ # Client error (4xx) - permanent failure, don't retry
245
+ logger.error(
246
+ f"Permanent error for event {event_id}: {error_msg}. "
247
+ "Marking as invalid (won't retry)"
248
+ )
249
+ await self.storage.mark_outbox_invalid(event_id, error_msg)
250
+ else:
251
+ # Server error (5xx) - temporary failure, retry
252
+ logger.warning(
253
+ f"Server error for event {event_id} "
254
+ f"(retry {retry_count + 1}/{self.max_retries}): {error_msg}"
255
+ )
256
+ await self.storage.mark_outbox_failed(event_id, error_msg)
257
+
258
+ except httpx.RequestError as e:
259
+ # Network error (connection timeout, DNS failure, etc.) - retry
260
+ error_msg = f"Network error: {str(e)}"
261
+ logger.warning(
262
+ f"Network error for event {event_id} "
263
+ f"(retry {retry_count + 1}/{self.max_retries}): {error_msg}"
264
+ )
265
+ await self.storage.mark_outbox_failed(event_id, error_msg)
266
+
267
+ except Exception as e:
268
+ # Unknown error - retry (safety net)
269
+ error_msg = f"{type(e).__name__}: {str(e)}"
270
+ logger.warning(
271
+ f"Unknown error for event {event_id} "
272
+ f"(retry {retry_count + 1}/{self.max_retries}): {error_msg}"
273
+ )
274
+ await self.storage.mark_outbox_failed(event_id, error_msg)
@@ -0,0 +1,112 @@
1
+ """
2
+ Transactional outbox helpers for reliable event publishing.
3
+
4
+ This module provides utilities for sending events using the transactional
5
+ outbox pattern. Events are written to the database within the same transaction
6
+ as other business logic, ensuring atomicity.
7
+ """
8
+
9
+ import logging
10
+ import uuid
11
+ from typing import TYPE_CHECKING, Any, cast
12
+
13
+ from pydantic import BaseModel
14
+
15
+ from edda.pydantic_utils import is_pydantic_instance, to_json_dict
16
+
17
+ if TYPE_CHECKING:
18
+ from edda.context import WorkflowContext
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ async def send_event_transactional(
24
+ ctx: "WorkflowContext",
25
+ event_type: str,
26
+ event_source: str,
27
+ event_data: dict[str, Any] | BaseModel,
28
+ content_type: str = "application/json",
29
+ ) -> str:
30
+ """
31
+ Send an event using the transactional outbox pattern.
32
+
33
+ This function writes an event to the outbox table instead of sending it
34
+ directly. The event will be asynchronously published by the Outbox Relayer.
35
+
36
+ This ensures that event publishing is atomic with the workflow execution:
37
+ - If the workflow fails, the event is not published
38
+ - If the workflow succeeds, the event is guaranteed to be published
39
+
40
+ Example:
41
+ >>> # With dict
42
+ >>> @activity
43
+ ... async def reserve_inventory(ctx: WorkflowContext, order_id: str) -> dict:
44
+ ... reservation_id = str(uuid.uuid4())
45
+ ... await send_event_transactional(
46
+ ... ctx,
47
+ ... event_type="inventory.reserved",
48
+ ... event_source="order-service",
49
+ ... event_data={
50
+ ... "order_id": order_id,
51
+ ... "reservation_id": reservation_id,
52
+ ... }
53
+ ... )
54
+ ... return {"reservation_id": reservation_id}
55
+ >>>
56
+ >>> # With Pydantic model (automatically converted to JSON)
57
+ >>> @activity
58
+ ... async def reserve_inventory_typed(ctx: WorkflowContext, order_id: str) -> dict:
59
+ ... reservation_id = str(uuid.uuid4())
60
+ ... event = InventoryReserved(
61
+ ... order_id=order_id,
62
+ ... reservation_id=reservation_id,
63
+ ... )
64
+ ... await send_event_transactional(
65
+ ... ctx,
66
+ ... event_type="inventory.reserved",
67
+ ... event_source="order-service",
68
+ ... event_data=event,
69
+ ... )
70
+ ... return {"reservation_id": reservation_id}
71
+
72
+ Args:
73
+ ctx: Workflow context
74
+ event_type: CloudEvent type (e.g., "order.created")
75
+ event_source: CloudEvent source (e.g., "order-service")
76
+ event_data: Event payload (JSON dict or Pydantic model)
77
+ content_type: Content type (defaults to "application/json")
78
+
79
+ Returns:
80
+ Event ID (UUID)
81
+
82
+ Raises:
83
+ Exception: If writing to outbox fails
84
+ """
85
+ # Check if in transaction
86
+ if not ctx.in_transaction():
87
+ logger.warning(
88
+ "send_event_transactional() called outside of a transaction. "
89
+ "Event will still be sent, but atomicity with other operations is not guaranteed. "
90
+ "Consider using @activity (with default transactional=True) or wrapping in ctx.transaction()."
91
+ )
92
+
93
+ # Convert Pydantic model to JSON dict
94
+ event_data_dict: dict[str, Any]
95
+ if is_pydantic_instance(event_data):
96
+ event_data_dict = to_json_dict(event_data)
97
+ else:
98
+ event_data_dict = cast(dict[str, Any], event_data)
99
+
100
+ # Generate event ID
101
+ event_id = str(uuid.uuid4())
102
+
103
+ # Write to outbox table
104
+ await ctx.storage.add_outbox_event(
105
+ event_id=event_id,
106
+ event_type=event_type,
107
+ event_source=event_source,
108
+ event_data=event_data_dict,
109
+ content_type=content_type,
110
+ )
111
+
112
+ return event_id
edda/pydantic_utils.py ADDED
@@ -0,0 +1,316 @@
1
+ """Pydantic integration utilities for Edda framework.
2
+
3
+ This module provides utilities for detecting Pydantic models and converting
4
+ between Pydantic models and JSON-compatible dictionaries.
5
+ """
6
+
7
+ import types
8
+ from enum import Enum
9
+ from typing import Any, TypeVar, Union, cast, get_args, get_origin
10
+
11
+ from pydantic import BaseModel
12
+
13
+ T = TypeVar("T", bound=BaseModel)
14
+
15
+
16
+ def is_pydantic_model(obj: Any) -> bool:
17
+ """Check if an object is a Pydantic model class.
18
+
19
+ Args:
20
+ obj: Object to check
21
+
22
+ Returns:
23
+ True if obj is a Pydantic model class, False otherwise
24
+
25
+ Examples:
26
+ >>> from pydantic import BaseModel
27
+ >>> class User(BaseModel):
28
+ ... name: str
29
+ >>> is_pydantic_model(User)
30
+ True
31
+ >>> is_pydantic_model(User(name="Alice"))
32
+ False
33
+ >>> is_pydantic_model(str)
34
+ False
35
+ """
36
+ try:
37
+ return isinstance(obj, type) and issubclass(obj, BaseModel)
38
+ except TypeError:
39
+ # issubclass raises TypeError for non-class objects
40
+ return False
41
+
42
+
43
+ def is_pydantic_instance(obj: Any) -> bool:
44
+ """Check if an object is a Pydantic model instance.
45
+
46
+ Args:
47
+ obj: Object to check
48
+
49
+ Returns:
50
+ True if obj is a Pydantic model instance, False otherwise
51
+
52
+ Examples:
53
+ >>> from pydantic import BaseModel
54
+ >>> class User(BaseModel):
55
+ ... name: str
56
+ >>> is_pydantic_instance(User(name="Alice"))
57
+ True
58
+ >>> is_pydantic_instance(User)
59
+ False
60
+ >>> is_pydantic_instance("Alice")
61
+ False
62
+ """
63
+ return isinstance(obj, BaseModel)
64
+
65
+
66
+ def to_json_dict(obj: Any) -> Any:
67
+ """Convert a Pydantic model or Enum to a JSON-compatible value.
68
+
69
+ Recursively handles lists and dicts containing Pydantic models or Enums.
70
+
71
+ Args:
72
+ obj: Object to convert (Pydantic model, Enum, list, dict, or primitive)
73
+
74
+ Returns:
75
+ JSON-compatible value (dict for Pydantic, primitive for Enum, or original value)
76
+
77
+ Examples:
78
+ >>> from pydantic import BaseModel
79
+ >>> from enum import Enum
80
+ >>> class User(BaseModel):
81
+ ... name: str
82
+ ... age: int
83
+ >>> class Status(Enum):
84
+ ... ACTIVE = "active"
85
+ >>> user = User(name="Alice", age=30)
86
+ >>> to_json_dict(user)
87
+ {'name': 'Alice', 'age': 30}
88
+ >>> to_json_dict(Status.ACTIVE)
89
+ 'active'
90
+ >>> to_json_dict([user])
91
+ [{'name': 'Alice', 'age': 30}]
92
+ >>> to_json_dict({'name': 'Bob'})
93
+ {'name': 'Bob'}
94
+ """
95
+ if is_pydantic_instance(obj):
96
+ return obj.model_dump(mode="json")
97
+ if isinstance(obj, Enum):
98
+ return obj.value
99
+ if isinstance(obj, list):
100
+ return [to_json_dict(item) for item in obj]
101
+ if isinstance(obj, dict):
102
+ return {key: to_json_dict(value) for key, value in obj.items()}
103
+ return obj
104
+
105
+
106
+ def from_json_dict(data: dict[str, Any], model: type[T]) -> T:
107
+ """Convert a JSON dictionary to a Pydantic model instance.
108
+
109
+ Args:
110
+ data: JSON-compatible dictionary
111
+ model: Pydantic model class
112
+
113
+ Returns:
114
+ Pydantic model instance
115
+
116
+ Raises:
117
+ ValidationError: If data does not match model schema
118
+
119
+ Examples:
120
+ >>> from pydantic import BaseModel
121
+ >>> class User(BaseModel):
122
+ ... name: str
123
+ ... age: int
124
+ >>> data = {'name': 'Alice', 'age': 30}
125
+ >>> user = from_json_dict(data, User)
126
+ >>> user.name
127
+ 'Alice'
128
+ >>> user.age
129
+ 30
130
+ """
131
+ return model.model_validate(data)
132
+
133
+
134
+ def extract_pydantic_model_from_annotation(annotation: Any) -> type[BaseModel] | None:
135
+ """Extract Pydantic model class from a type annotation.
136
+
137
+ Handles various annotation patterns:
138
+ - Direct: User
139
+ - Optional: User | None, Optional[User]
140
+ - Generic: list[User], dict[str, User]
141
+
142
+ Args:
143
+ annotation: Type annotation to analyze
144
+
145
+ Returns:
146
+ Pydantic model class if found, None otherwise
147
+
148
+ Examples:
149
+ >>> from pydantic import BaseModel
150
+ >>> from typing import Optional
151
+ >>> class User(BaseModel):
152
+ ... name: str
153
+ >>> extract_pydantic_model_from_annotation(User)
154
+ <class 'User'>
155
+ >>> extract_pydantic_model_from_annotation(User | None)
156
+ <class 'User'>
157
+ >>> extract_pydantic_model_from_annotation(Optional[User])
158
+ <class 'User'>
159
+ >>> extract_pydantic_model_from_annotation(str)
160
+ None
161
+ """
162
+ # Direct Pydantic model
163
+ if is_pydantic_model(annotation):
164
+ return cast(type[BaseModel], annotation)
165
+
166
+ # Handle Union types (e.g., User | None, Optional[User])
167
+ # Only check for Union types, not other generics (list, dict, etc.)
168
+ origin = get_origin(annotation)
169
+ # Check for typing.Union (Optional[X]) and types.UnionType (X | Y in Python 3.10+)
170
+ if origin is Union or origin is types.UnionType:
171
+ args = get_args(annotation)
172
+ for arg in args:
173
+ if is_pydantic_model(arg):
174
+ return cast(type[BaseModel], arg)
175
+
176
+ return None
177
+
178
+
179
+ # ============================================================================
180
+ # Enum utilities
181
+ # ============================================================================
182
+
183
+
184
+ def is_enum_class(obj: Any) -> bool:
185
+ """Check if an object is an Enum class.
186
+
187
+ Args:
188
+ obj: Object to check
189
+
190
+ Returns:
191
+ True if obj is an Enum class, False otherwise
192
+
193
+ Examples:
194
+ >>> from enum import Enum
195
+ >>> class Status(Enum):
196
+ ... ACTIVE = "active"
197
+ >>> is_enum_class(Status)
198
+ True
199
+ >>> is_enum_class(Status.ACTIVE)
200
+ False
201
+ >>> is_enum_class(str)
202
+ False
203
+ """
204
+ try:
205
+ return isinstance(obj, type) and issubclass(obj, Enum)
206
+ except TypeError:
207
+ # issubclass raises TypeError for non-class objects
208
+ return False
209
+
210
+
211
+ def is_enum_instance(obj: Any) -> bool:
212
+ """Check if an object is an Enum instance.
213
+
214
+ Args:
215
+ obj: Object to check
216
+
217
+ Returns:
218
+ True if obj is an Enum instance, False otherwise
219
+
220
+ Examples:
221
+ >>> from enum import Enum
222
+ >>> class Status(Enum):
223
+ ... ACTIVE = "active"
224
+ >>> is_enum_instance(Status.ACTIVE)
225
+ True
226
+ >>> is_enum_instance(Status)
227
+ False
228
+ >>> is_enum_instance("active")
229
+ False
230
+ """
231
+ return isinstance(obj, Enum)
232
+
233
+
234
+ def extract_enum_from_annotation(annotation: Any) -> type[Enum] | None:
235
+ """Extract Enum class from a type annotation.
236
+
237
+ Handles various annotation patterns:
238
+ - Direct: Status
239
+ - Optional: Status | None, Optional[Status]
240
+
241
+ Args:
242
+ annotation: Type annotation to analyze
243
+
244
+ Returns:
245
+ Enum class if found, None otherwise
246
+
247
+ Examples:
248
+ >>> from enum import Enum
249
+ >>> from typing import Optional
250
+ >>> class Status(Enum):
251
+ ... ACTIVE = "active"
252
+ >>> extract_enum_from_annotation(Status)
253
+ <class 'Status'>
254
+ >>> extract_enum_from_annotation(Status | None)
255
+ <class 'Status'>
256
+ >>> extract_enum_from_annotation(Optional[Status])
257
+ <class 'Status'>
258
+ >>> extract_enum_from_annotation(str)
259
+ None
260
+ """
261
+ # Direct Enum class
262
+ if is_enum_class(annotation):
263
+ return cast(type[Enum], annotation)
264
+
265
+ # Handle Union types (e.g., Status | None, Optional[Status])
266
+ origin = get_origin(annotation)
267
+ # Check for typing.Union (Optional[X]) and types.UnionType (X | Y in Python 3.10+)
268
+ if origin is Union or origin is types.UnionType:
269
+ args = get_args(annotation)
270
+ for arg in args:
271
+ if is_enum_class(arg):
272
+ return cast(type[Enum], arg)
273
+
274
+ return None
275
+
276
+
277
+ def enum_value_to_enum(value: Any, enum_class: type[Enum]) -> Enum:
278
+ """Convert a value (str or int) to Enum instance.
279
+
280
+ Args:
281
+ value: Value to convert (must match an Enum member's value)
282
+ enum_class: Enum class to convert to
283
+
284
+ Returns:
285
+ Enum instance
286
+
287
+ Raises:
288
+ ValueError: If value does not match any Enum member
289
+
290
+ Examples:
291
+ >>> from enum import Enum
292
+ >>> class Status(Enum):
293
+ ... ACTIVE = "active"
294
+ ... INACTIVE = "inactive"
295
+ >>> enum_value_to_enum("active", Status)
296
+ <Status.ACTIVE: 'active'>
297
+ >>> enum_value_to_enum("invalid", Status)
298
+ Traceback (most recent call last):
299
+ ...
300
+ ValueError: Cannot convert 'invalid' to Status
301
+ """
302
+ # Try by value first (most common case)
303
+ for member in enum_class:
304
+ if member.value == value:
305
+ return member
306
+
307
+ # Try by name (fallback for string values matching member names)
308
+ if isinstance(value, str):
309
+ # Try exact match first
310
+ if hasattr(enum_class, value):
311
+ return cast(Enum, getattr(enum_class, value))
312
+ # Try uppercase
313
+ if hasattr(enum_class, value.upper()):
314
+ return cast(Enum, getattr(enum_class, value.upper()))
315
+
316
+ raise ValueError(f"Cannot convert {value!r} to {enum_class.__name__}")