""" Saga Pattern Implementation Provides distributed transaction coordination with compensation logic for microservices architecture. """ import asyncio import uuid from typing import Callable, List, Dict, Any, Optional, Tuple from dataclasses import dataclass, field from datetime import datetime from enum import Enum import logging logger = logging.getLogger(__name__) class SagaStepStatus(str, Enum): """Status of a saga step""" PENDING = "pending" IN_PROGRESS = "in_progress" COMPLETED = "completed" FAILED = "failed" COMPENSATING = "compensating" COMPENSATED = "compensated" class SagaStatus(str, Enum): """Overall saga status""" PENDING = "pending" IN_PROGRESS = "in_progress" COMPLETED = "completed" FAILED = "failed" COMPENSATING = "compensating" COMPENSATED = "compensated" @dataclass class SagaStep: """ A single step in a saga with compensation logic. Args: name: Human-readable step name action: Async function to execute compensation: Async function to undo the action action_args: Arguments for the action function action_kwargs: Keyword arguments for the action function """ name: str action: Callable compensation: Optional[Callable] = None action_args: tuple = field(default_factory=tuple) action_kwargs: dict = field(default_factory=dict) # Runtime state status: SagaStepStatus = SagaStepStatus.PENDING result: Any = None error: Optional[Exception] = None started_at: Optional[datetime] = None completed_at: Optional[datetime] = None @dataclass class SagaExecution: """Tracks execution state of a saga""" saga_id: str status: SagaStatus = SagaStatus.PENDING steps: List[SagaStep] = field(default_factory=list) current_step: int = 0 started_at: Optional[datetime] = None completed_at: Optional[datetime] = None error: Optional[Exception] = None class SagaCoordinator: """ Coordinates saga execution with automatic compensation on failure. Example: ```python saga = SagaCoordinator() saga.add_step( "create_order", action=create_order, compensation=delete_order, action_args=(order_data,) ) saga.add_step( "reserve_inventory", action=reserve_inventory, compensation=release_inventory, action_args=(order_id, items) ) result = await saga.execute() ``` """ def __init__(self, saga_id: Optional[str] = None): self.execution = SagaExecution( saga_id=saga_id or str(uuid.uuid4()) ) self._completed_steps: List[SagaStep] = [] def add_step( self, name: str, action: Callable, compensation: Optional[Callable] = None, action_args: tuple = (), action_kwargs: dict = None ): """ Add a step to the saga. Args: name: Human-readable step name action: Async function to execute compensation: Async function to undo the action (optional) action_args: Arguments for the action function action_kwargs: Keyword arguments for the action function """ step = SagaStep( name=name, action=action, compensation=compensation, action_args=action_args, action_kwargs=action_kwargs or {} ) self.execution.steps.append(step) logger.debug(f"Added step '{name}' to saga {self.execution.saga_id}") async def execute(self) -> Tuple[bool, Optional[Any], Optional[Exception]]: """ Execute all saga steps in sequence. Returns: Tuple of (success: bool, final_result: Any, error: Optional[Exception]) """ self.execution.status = SagaStatus.IN_PROGRESS self.execution.started_at = datetime.now() logger.info( f"Starting saga {self.execution.saga_id} with {len(self.execution.steps)} steps" ) try: # Execute each step for idx, step in enumerate(self.execution.steps): self.execution.current_step = idx success = await self._execute_step(step) if not success: # Step failed, trigger compensation logger.error( f"Saga {self.execution.saga_id} failed at step '{step.name}': {step.error}" ) await self._compensate() self.execution.status = SagaStatus.COMPENSATED self.execution.completed_at = datetime.now() self.execution.error = step.error return False, None, step.error # Step succeeded self._completed_steps.append(step) # All steps completed successfully self.execution.status = SagaStatus.COMPLETED self.execution.completed_at = datetime.now() # Return the result of the last step final_result = self.execution.steps[-1].result if self.execution.steps else None logger.info(f"Saga {self.execution.saga_id} completed successfully") return True, final_result, None except Exception as e: logger.exception(f"Unexpected error in saga {self.execution.saga_id}: {e}") await self._compensate() self.execution.status = SagaStatus.FAILED self.execution.completed_at = datetime.now() self.execution.error = e return False, None, e async def _execute_step(self, step: SagaStep) -> bool: """ Execute a single saga step. Returns: True if step succeeded, False otherwise """ step.status = SagaStepStatus.IN_PROGRESS step.started_at = datetime.now() logger.info(f"Executing saga step '{step.name}'") try: # Execute the action if asyncio.iscoroutinefunction(step.action): result = await step.action(*step.action_args, **step.action_kwargs) else: result = step.action(*step.action_args, **step.action_kwargs) step.result = result step.status = SagaStepStatus.COMPLETED step.completed_at = datetime.now() logger.info(f"Saga step '{step.name}' completed successfully") return True except Exception as e: step.error = e step.status = SagaStepStatus.FAILED step.completed_at = datetime.now() logger.error(f"Saga step '{step.name}' failed: {e}") return False async def _compensate(self): """ Execute compensation logic for all completed steps in reverse order. """ if not self._completed_steps: logger.info(f"No steps to compensate for saga {self.execution.saga_id}") return self.execution.status = SagaStatus.COMPENSATING logger.info( f"Starting compensation for saga {self.execution.saga_id} " f"({len(self._completed_steps)} steps to compensate)" ) # Compensate in reverse order for step in reversed(self._completed_steps): if step.compensation is None: logger.warning( f"Step '{step.name}' has no compensation function, skipping" ) continue step.status = SagaStepStatus.COMPENSATING try: logger.info(f"Compensating step '{step.name}'") # Execute compensation with the result from the original action compensation_args = (step.result,) if step.result is not None else () if asyncio.iscoroutinefunction(step.compensation): await step.compensation(*compensation_args) else: step.compensation(*compensation_args) step.status = SagaStepStatus.COMPENSATED logger.info(f"Step '{step.name}' compensated successfully") except Exception as e: logger.error(f"Failed to compensate step '{step.name}': {e}") # Continue compensating other steps even if one fails logger.info(f"Compensation completed for saga {self.execution.saga_id}") def get_execution_summary(self) -> Dict[str, Any]: """Get summary of saga execution""" return { "saga_id": self.execution.saga_id, "status": self.execution.status.value, "total_steps": len(self.execution.steps), "current_step": self.execution.current_step, "completed_steps": len(self._completed_steps), "started_at": self.execution.started_at.isoformat() if self.execution.started_at else None, "completed_at": self.execution.completed_at.isoformat() if self.execution.completed_at else None, "error": str(self.execution.error) if self.execution.error else None, "steps": [ { "name": step.name, "status": step.status.value, "has_compensation": step.compensation is not None, "error": str(step.error) if step.error else None } for step in self.execution.steps ] }