514 lines
17 KiB
Markdown
514 lines
17 KiB
Markdown
# SAGA Pattern - Reference Examples
|
|
|
|
## Complete Implementation Examples
|
|
|
|
### 1. Complete MassTransit Configuration
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Configure MassTransit with saga support.
|
|
/// VI: Cấu hình MassTransit với hỗ trợ saga.
|
|
/// </summary>
|
|
public static class SagaServiceExtensions
|
|
{
|
|
public static IServiceCollection AddSagaSupport(
|
|
this IServiceCollection services,
|
|
IConfiguration configuration)
|
|
{
|
|
services.AddMassTransit(x =>
|
|
{
|
|
// EN: Add saga state machine
|
|
// VI: Thêm saga state machine
|
|
x.AddSagaStateMachine<OrderSaga, OrderSagaState>()
|
|
.EntityFrameworkRepository(r =>
|
|
{
|
|
r.ConcurrencyMode = ConcurrencyMode.Pessimistic;
|
|
r.AddDbContext<DbContext, SagaDbContext>((provider, builder) =>
|
|
{
|
|
builder.UseNpgsql(configuration.GetConnectionString("SagaDb"));
|
|
});
|
|
});
|
|
|
|
// EN: Add consumers
|
|
x.AddConsumer<ReserveInventoryConsumer>();
|
|
x.AddConsumer<ReleaseInventoryConsumer>();
|
|
x.AddConsumer<ProcessPaymentConsumer>();
|
|
x.AddConsumer<RefundPaymentConsumer>();
|
|
x.AddConsumer<CompleteOrderConsumer>();
|
|
x.AddConsumer<CancelOrderConsumer>();
|
|
|
|
x.UsingRabbitMq((context, cfg) =>
|
|
{
|
|
cfg.Host(configuration["RabbitMQ:Host"], "/", h =>
|
|
{
|
|
h.Username(configuration["RabbitMQ:Username"]!);
|
|
h.Password(configuration["RabbitMQ:Password"]!);
|
|
});
|
|
|
|
// EN: Configure retry policy
|
|
cfg.UseMessageRetry(r => r.Intervals(
|
|
TimeSpan.FromSeconds(1),
|
|
TimeSpan.FromSeconds(5),
|
|
TimeSpan.FromSeconds(15)));
|
|
|
|
cfg.ConfigureEndpoints(context);
|
|
});
|
|
});
|
|
|
|
return services;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. Saga DbContext
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: DbContext for saga state persistence.
|
|
/// VI: DbContext để lưu trữ saga state.
|
|
/// </summary>
|
|
public class SagaDbContext : DbContext
|
|
{
|
|
public DbSet<OrderSagaState> OrderSagas => Set<OrderSagaState>();
|
|
|
|
public SagaDbContext(DbContextOptions<SagaDbContext> options)
|
|
: base(options) { }
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
modelBuilder.Entity<OrderSagaState>(entity =>
|
|
{
|
|
entity.ToTable("OrderSagas");
|
|
|
|
entity.HasKey(x => x.CorrelationId);
|
|
|
|
entity.Property(x => x.CurrentState)
|
|
.HasMaxLength(100)
|
|
.IsRequired();
|
|
|
|
entity.Property(x => x.UserId)
|
|
.HasMaxLength(100);
|
|
|
|
entity.Property(x => x.PaymentId)
|
|
.HasMaxLength(100);
|
|
|
|
entity.Property(x => x.FailureReason)
|
|
.HasMaxLength(500);
|
|
|
|
// EN: Optimistic concurrency
|
|
entity.Property(x => x.RowVersion)
|
|
.IsRowVersion();
|
|
|
|
entity.HasIndex(x => x.OrderId);
|
|
entity.HasIndex(x => x.CurrentState);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Extended saga state with row version.
|
|
/// VI: Saga state mở rộng với row version.
|
|
/// </summary>
|
|
public class OrderSagaState : SagaStateMachineInstance
|
|
{
|
|
public Guid CorrelationId { get; set; }
|
|
public string CurrentState { get; set; } = default!;
|
|
|
|
public Guid OrderId { get; set; }
|
|
public string UserId { get; set; } = default!;
|
|
public decimal TotalAmount { get; set; }
|
|
public string? PaymentId { get; set; }
|
|
public string? FailureReason { get; set; }
|
|
|
|
// EN: For timeout scheduling
|
|
public Guid? ReservationTimeoutToken { get; set; }
|
|
public Guid? PaymentTimeoutToken { get; set; }
|
|
|
|
// EN: Optimistic concurrency
|
|
public byte[] RowVersion { get; set; } = null!;
|
|
}
|
|
```
|
|
|
|
### 3. Complete Saga with Timeouts
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Complete order saga with timeout handling.
|
|
/// VI: Order saga hoàn chỉnh với xử lý timeout.
|
|
/// </summary>
|
|
public class OrderSagaWithTimeouts : MassTransitStateMachine<OrderSagaState>
|
|
{
|
|
public State OrderCreated { get; private set; } = null!;
|
|
public State InventoryReserved { get; private set; } = null!;
|
|
public State PaymentProcessing { get; private set; } = null!;
|
|
public State Completed { get; private set; } = null!;
|
|
public State Failed { get; private set; } = null!;
|
|
public State Compensating { get; private set; } = null!;
|
|
|
|
public Event<OrderSubmitted> OrderSubmittedEvent { get; private set; } = null!;
|
|
public Event<InventoryReserved> InventoryReservedEvent { get; private set; } = null!;
|
|
public Event<InventoryReservationFailed> InventoryFailedEvent { get; private set; } = null!;
|
|
public Event<PaymentProcessed> PaymentProcessedEvent { get; private set; } = null!;
|
|
public Event<PaymentFailed> PaymentFailedEvent { get; private set; } = null!;
|
|
public Event<InventoryReleased> InventoryReleasedEvent { get; private set; } = null!;
|
|
|
|
// EN: Timeout schedules / VI: Lịch timeout
|
|
public Schedule<OrderSagaState, ReservationTimeout> ReservationTimeoutSchedule { get; private set; } = null!;
|
|
public Schedule<OrderSagaState, PaymentTimeout> PaymentTimeoutSchedule { get; private set; } = null!;
|
|
|
|
public OrderSagaWithTimeouts()
|
|
{
|
|
InstanceState(x => x.CurrentState);
|
|
|
|
// EN: Configure events
|
|
ConfigureEvents();
|
|
|
|
// EN: Configure timeouts
|
|
ConfigureTimeouts();
|
|
|
|
// EN: Configure state machine
|
|
ConfigureStateMachine();
|
|
|
|
SetCompletedWhenFinalized();
|
|
}
|
|
|
|
private void ConfigureEvents()
|
|
{
|
|
Event(() => OrderSubmittedEvent, x => x.CorrelateById(m => m.Message.OrderId));
|
|
Event(() => InventoryReservedEvent, x => x.CorrelateById(m => m.Message.OrderId));
|
|
Event(() => InventoryFailedEvent, x => x.CorrelateById(m => m.Message.OrderId));
|
|
Event(() => PaymentProcessedEvent, x => x.CorrelateById(m => m.Message.OrderId));
|
|
Event(() => PaymentFailedEvent, x => x.CorrelateById(m => m.Message.OrderId));
|
|
Event(() => InventoryReleasedEvent, x => x.CorrelateById(m => m.Message.OrderId));
|
|
}
|
|
|
|
private void ConfigureTimeouts()
|
|
{
|
|
Schedule(() => ReservationTimeoutSchedule,
|
|
x => x.ReservationTimeoutToken,
|
|
s => s.Delay = TimeSpan.FromMinutes(5));
|
|
|
|
Schedule(() => PaymentTimeoutSchedule,
|
|
x => x.PaymentTimeoutToken,
|
|
s => s.Delay = TimeSpan.FromMinutes(10));
|
|
}
|
|
|
|
private void ConfigureStateMachine()
|
|
{
|
|
Initially(
|
|
When(OrderSubmittedEvent)
|
|
.Then(InitializeSagaData)
|
|
.Schedule(ReservationTimeoutSchedule, ctx => new ReservationTimeout
|
|
{
|
|
OrderId = ctx.Saga.OrderId
|
|
})
|
|
.Publish(ctx => new ReserveInventory
|
|
{
|
|
OrderId = ctx.Saga.OrderId,
|
|
Items = ctx.Message.Items
|
|
})
|
|
.TransitionTo(OrderCreated)
|
|
);
|
|
|
|
During(OrderCreated,
|
|
When(InventoryReservedEvent)
|
|
.Unschedule(ReservationTimeoutSchedule)
|
|
.Schedule(PaymentTimeoutSchedule, ctx => new PaymentTimeout
|
|
{
|
|
OrderId = ctx.Saga.OrderId
|
|
})
|
|
.Publish(ctx => new ProcessPayment
|
|
{
|
|
OrderId = ctx.Saga.OrderId,
|
|
UserId = ctx.Saga.UserId,
|
|
Amount = ctx.Saga.TotalAmount
|
|
})
|
|
.TransitionTo(InventoryReserved),
|
|
|
|
When(InventoryFailedEvent)
|
|
.Unschedule(ReservationTimeoutSchedule)
|
|
.Then(ctx => ctx.Saga.FailureReason = ctx.Message.Reason)
|
|
.Publish(ctx => new CancelOrder
|
|
{
|
|
OrderId = ctx.Saga.OrderId,
|
|
Reason = ctx.Message.Reason
|
|
})
|
|
.TransitionTo(Failed),
|
|
|
|
When(ReservationTimeoutSchedule.Received)
|
|
.Then(ctx => ctx.Saga.FailureReason = "Inventory reservation timeout")
|
|
.Publish(ctx => new CancelOrder
|
|
{
|
|
OrderId = ctx.Saga.OrderId,
|
|
Reason = "Timeout waiting for inventory"
|
|
})
|
|
.TransitionTo(Failed)
|
|
);
|
|
|
|
During(InventoryReserved,
|
|
When(PaymentProcessedEvent)
|
|
.Unschedule(PaymentTimeoutSchedule)
|
|
.Then(ctx => ctx.Saga.PaymentId = ctx.Message.PaymentId)
|
|
.Publish(ctx => new CompleteOrder
|
|
{
|
|
OrderId = ctx.Saga.OrderId,
|
|
PaymentId = ctx.Message.PaymentId
|
|
})
|
|
.TransitionTo(Completed)
|
|
.Finalize(),
|
|
|
|
When(PaymentFailedEvent)
|
|
.Unschedule(PaymentTimeoutSchedule)
|
|
.Then(ctx => ctx.Saga.FailureReason = ctx.Message.Reason)
|
|
.Publish(ctx => new ReleaseInventory
|
|
{
|
|
OrderId = ctx.Saga.OrderId
|
|
})
|
|
.TransitionTo(Compensating),
|
|
|
|
When(PaymentTimeoutSchedule.Received)
|
|
.Then(ctx => ctx.Saga.FailureReason = "Payment processing timeout")
|
|
.Publish(ctx => new ReleaseInventory
|
|
{
|
|
OrderId = ctx.Saga.OrderId
|
|
})
|
|
.TransitionTo(Compensating)
|
|
);
|
|
|
|
During(Compensating,
|
|
When(InventoryReleasedEvent)
|
|
.Publish(ctx => new CancelOrder
|
|
{
|
|
OrderId = ctx.Saga.OrderId,
|
|
Reason = ctx.Saga.FailureReason ?? "Unknown error"
|
|
})
|
|
.TransitionTo(Failed)
|
|
);
|
|
}
|
|
|
|
private static void InitializeSagaData(BehaviorContext<OrderSagaState, OrderSubmitted> ctx)
|
|
{
|
|
ctx.Saga.OrderId = ctx.Message.OrderId;
|
|
ctx.Saga.UserId = ctx.Message.UserId;
|
|
ctx.Saga.TotalAmount = ctx.Message.TotalAmount;
|
|
}
|
|
}
|
|
|
|
public record ReservationTimeout { public Guid OrderId { get; init; } }
|
|
public record PaymentTimeout { public Guid OrderId { get; init; } }
|
|
```
|
|
|
|
### 4. Choreography-Style Saga
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Choreography-style saga using domain events.
|
|
/// VI: Saga kiểu choreography dùng domain events.
|
|
/// </summary>
|
|
|
|
// EN: Order Service publishes event after creation
|
|
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, OrderResult>
|
|
{
|
|
private readonly IOrderRepository _repository;
|
|
private readonly IPublishEndpoint _publishEndpoint;
|
|
|
|
public async Task<OrderResult> Handle(CreateOrderCommand request, CancellationToken ct)
|
|
{
|
|
var order = new Order(request.UserId, request.Address);
|
|
foreach (var item in request.Items)
|
|
order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
|
|
|
|
await _repository.AddAsync(order, ct);
|
|
await _repository.UnitOfWork.SaveChangesAsync(ct);
|
|
|
|
// EN: Publish event for choreography
|
|
// VI: Publish event cho choreography
|
|
await _publishEndpoint.Publish(new OrderCreatedIntegrationEvent
|
|
{
|
|
OrderId = order.Id,
|
|
UserId = order.UserId,
|
|
TotalAmount = order.TotalAmount,
|
|
Items = order.Items.Select(i => new OrderItemInfo(
|
|
i.ProductId, i.Quantity, i.UnitPrice)).ToList()
|
|
}, ct);
|
|
|
|
return new OrderResult(order.Id);
|
|
}
|
|
}
|
|
|
|
// EN: Inventory Service reacts to OrderCreated
|
|
public class OrderCreatedInventoryConsumer : IConsumer<OrderCreatedIntegrationEvent>
|
|
{
|
|
private readonly IInventoryService _inventory;
|
|
private readonly IPublishEndpoint _publishEndpoint;
|
|
|
|
public async Task Consume(ConsumeContext<OrderCreatedIntegrationEvent> context)
|
|
{
|
|
try
|
|
{
|
|
foreach (var item in context.Message.Items)
|
|
{
|
|
await _inventory.ReserveAsync(
|
|
item.ProductId,
|
|
item.Quantity,
|
|
context.Message.OrderId);
|
|
}
|
|
|
|
// EN: Notify success
|
|
await context.Publish(new InventoryReservedIntegrationEvent
|
|
{
|
|
OrderId = context.Message.OrderId
|
|
});
|
|
}
|
|
catch (InsufficientStockException ex)
|
|
{
|
|
// EN: Notify failure
|
|
await context.Publish(new InventoryReservationFailedIntegrationEvent
|
|
{
|
|
OrderId = context.Message.OrderId,
|
|
Reason = ex.Message
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// EN: Payment Service reacts to InventoryReserved
|
|
public class InventoryReservedPaymentConsumer : IConsumer<InventoryReservedIntegrationEvent>
|
|
{
|
|
private readonly IPaymentService _payment;
|
|
private readonly IOrderRepository _orders;
|
|
private readonly IPublishEndpoint _publishEndpoint;
|
|
|
|
public async Task Consume(ConsumeContext<InventoryReservedIntegrationEvent> context)
|
|
{
|
|
var order = await _orders.GetByIdAsync(context.Message.OrderId);
|
|
|
|
try
|
|
{
|
|
var paymentId = await _payment.ChargeAsync(
|
|
order!.UserId,
|
|
order.TotalAmount);
|
|
|
|
await context.Publish(new PaymentProcessedIntegrationEvent
|
|
{
|
|
OrderId = context.Message.OrderId,
|
|
PaymentId = paymentId
|
|
});
|
|
}
|
|
catch (PaymentFailedException ex)
|
|
{
|
|
await context.Publish(new PaymentFailedIntegrationEvent
|
|
{
|
|
OrderId = context.Message.OrderId,
|
|
Reason = ex.Message
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// EN: Inventory Service compensates on payment failure
|
|
public class PaymentFailedInventoryConsumer : IConsumer<PaymentFailedIntegrationEvent>
|
|
{
|
|
private readonly IInventoryService _inventory;
|
|
|
|
public async Task Consume(ConsumeContext<PaymentFailedIntegrationEvent> context)
|
|
{
|
|
// EN: Compensating action
|
|
await _inventory.ReleaseReservationAsync(context.Message.OrderId);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5. Saga Monitoring and Querying
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Query service for saga status.
|
|
/// VI: Service truy vấn trạng thái saga.
|
|
/// </summary>
|
|
public class SagaQueryService
|
|
{
|
|
private readonly SagaDbContext _context;
|
|
|
|
public SagaQueryService(SagaDbContext context)
|
|
{
|
|
_context = context;
|
|
}
|
|
|
|
public async Task<OrderSagaStatusDto?> GetOrderSagaStatusAsync(
|
|
Guid orderId,
|
|
CancellationToken ct = default)
|
|
{
|
|
var saga = await _context.OrderSagas
|
|
.FirstOrDefaultAsync(s => s.OrderId == orderId, ct);
|
|
|
|
if (saga == null)
|
|
return null;
|
|
|
|
return new OrderSagaStatusDto
|
|
{
|
|
OrderId = saga.OrderId,
|
|
CurrentState = saga.CurrentState,
|
|
PaymentId = saga.PaymentId,
|
|
FailureReason = saga.FailureReason,
|
|
IsCompleted = saga.CurrentState == "Completed",
|
|
IsFailed = saga.CurrentState == "Failed"
|
|
};
|
|
}
|
|
|
|
public async Task<List<OrderSagaStatusDto>> GetStuckSagasAsync(
|
|
TimeSpan maxAge,
|
|
CancellationToken ct = default)
|
|
{
|
|
var cutoff = DateTime.UtcNow - maxAge;
|
|
|
|
return await _context.OrderSagas
|
|
.Where(s => s.CurrentState != "Completed"
|
|
&& s.CurrentState != "Failed"
|
|
&& s.CreatedAt < cutoff)
|
|
.Select(s => new OrderSagaStatusDto
|
|
{
|
|
OrderId = s.OrderId,
|
|
CurrentState = s.CurrentState,
|
|
FailureReason = "Potentially stuck"
|
|
})
|
|
.ToListAsync(ct);
|
|
}
|
|
}
|
|
|
|
public record OrderSagaStatusDto
|
|
{
|
|
public Guid OrderId { get; init; }
|
|
public string CurrentState { get; init; } = default!;
|
|
public string? PaymentId { get; init; }
|
|
public string? FailureReason { get; init; }
|
|
public bool IsCompleted { get; init; }
|
|
public bool IsFailed { get; init; }
|
|
}
|
|
```
|
|
|
|
## Database Migrations
|
|
|
|
```sql
|
|
-- EN: Create OrderSagas table / VI: Tạo bảng OrderSagas
|
|
CREATE TABLE "OrderSagas" (
|
|
"CorrelationId" uuid PRIMARY KEY,
|
|
"CurrentState" varchar(100) NOT NULL,
|
|
"OrderId" uuid NOT NULL,
|
|
"UserId" varchar(100),
|
|
"TotalAmount" decimal(18,2) NOT NULL DEFAULT 0,
|
|
"PaymentId" varchar(100),
|
|
"FailureReason" varchar(500),
|
|
"ReservationTimeoutToken" uuid,
|
|
"PaymentTimeoutToken" uuid,
|
|
"RowVersion" bytea NOT NULL,
|
|
"CreatedAt" timestamp with time zone NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX "IX_OrderSagas_OrderId" ON "OrderSagas" ("OrderId");
|
|
CREATE INDEX "IX_OrderSagas_CurrentState" ON "OrderSagas" ("CurrentState");
|
|
CREATE INDEX "IX_OrderSagas_StuckSagas"
|
|
ON "OrderSagas" ("CurrentState", "CreatedAt")
|
|
WHERE "CurrentState" NOT IN ('Completed', 'Failed');
|
|
```
|