Vi går igenom hur optimistic concurrency uppstår "gratis" i SqlStreamStore, hur vi retryar med Polly, varför outbox-mönstret behövs först vid extern publicering, och hur idempotens skyddar mot dubbla anrop.
Vid läsning noterar du vilken version aggregatet hade. Vid skrivning säger du "jag förväntar mig att versionen fortfarande är 7". Om någon annan hann skriva däremellan så kastar databasen ett fel.
dbo.Messages har en unique constraint på (StreamIdInternal, StreamVersion). När två skribenter försöker skriva version 8 till samma ström får den andra ett SqlException 2627 — som SqlStreamStore översätter till WrongExpectedVersionException. Du behöver alltså inte själv hålla reda på låsning eller versioner i app-koden — databasen sköter det.
I många fall är en konflikt bara olycklig timing — om vi laddar om aggregatet, applicerar commandot igen och försöker spara så kommer det att lyckas. Det här är ett perfekt jobb för Polly.
var retry = Policy
.Handle<ConcurrencyConflictException>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt => TimeSpan.FromMilliseconds(50 * Math.Pow(2, attempt)));
public async Task<DepositMoneyResult> Handle(DepositMoneyCommand cmd, CancellationToken ct)
{
return await retry.ExecuteAsync(async () =>
{
var account = await _repo.LoadAsync(cmd.Account, ct)
?? throw new NotFoundException(cmd.Account);
account.Deposit(new Money(cmd.Amount, cmd.Currency), cmd.Reference);
await _repo.SaveAsync(account, ct);
return new DepositMoneyResult(account.Version, account.Balance.Amount);
});
}
Deposit är det inte sant utan idempotency-key (annars skulle vi sätta in 100 kr två gånger). Lösningen är pipeline-behaviorn IdempotencyBehavior från lektion 4 i kombination med retryn — då deduplicerar idempotency-storen och retryn skyddar bara mot tekniska konflikter.
Eftersom event store (dbo.Messages) och alla read model-tabeller ligger i samma MSSQL kan vi använda en transaktion när vi uppdaterar read modellen från projektionen. Då försvinner hela dual-write-problemet.
using var scope = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await using var conn = new SqlConnection(_cs);
await conn.OpenAsync(ct);
await UpdateReadModelAsync(conn, evt, ct); // INSERT/UPDATE dbo.AccountSummary
await WriteCheckpointAsync(conn, evt.Position, ct); // MERGE dbo.ProjectionCheckpoints
scope.Complete();
Utan transaktion: read model uppdateras, processen kraschar, checkpoint skrivs aldrig → samma event processas igen → dubblerad data. Med transaktion: båda eller ingen → exactly-once.
Outbox-mönstret behövs när du måste skriva till två olika systems i samma transaktion — typexempel: spara event i databasen och publicera till en message bus (RabbitMQ, Kafka, Azure Service Bus). Det går inte att göra atomärt över olika resurser, så vi använder en mellantabell.
CREATE TABLE dbo.Outbox (
Id BIGINT IDENTITY PRIMARY KEY,
EventType NVARCHAR(400) NOT NULL,
PayloadJson NVARCHAR(MAX) NOT NULL,
OccurredAt DATETIMEOFFSET NOT NULL,
PublishedAt DATETIMEOFFSET NULL
);
Skrivning sker i samma transaktion som event-append. En separat OutboxRelay : BackgroundService pollar tabellen efter rader där PublishedAt IS NULL, publicerar till bussen och uppdaterar PublishedAt.
Från lektion 4: klienten skickar en MessageId. IdempotencyBehavior kollar tabellen dbo.IdempotencyKeys:
CREATE TABLE dbo.IdempotencyKeys (
MessageId UNIQUEIDENTIFIER PRIMARY KEY,
Result NVARCHAR(MAX) NOT NULL,
CreatedAt DATETIMEOFFSET NOT NULL
);
CREATE INDEX IX_IdempotencyKeys_CreatedAt ON dbo.IdempotencyKeys(CreatedAt);
-- Periodisk städning av rader äldre än t.ex. 30 dagar
Detta skyddar mot: klienten retryar pga timeout, nätverket blinkar mellan API och DB, dubbelklick i UI:t. Tillsammans med retry-policyn på handlern blir hela kedjan robust.
// Två användare gör DepositMoney på samma konto samtidigt.
//
// T1: Load(account) → Version = 7
// T2: Load(account) → Version = 7
// T1: account.Deposit(100) → ny event, Version blir 8
// T1: Save() → AppendToStream(streamId, expectedVersion: 7, [event]) → OK
//
// T2: account.Deposit(50) → ny event, Version blir 8
// T2: Save() → AppendToStream(streamId, expectedVersion: 7, [event])
// → ConcurrencyConflictException
//
// Polly:
// T2 (försök 2): Load(account) → Version = 8 (T1:s ändring syns)
// account.Deposit(50) → ny event, Version blir 9
// Save() → AppendToStream(streamId, expectedVersion: 8, ...) → OK
| Nivå | När |
|---|---|
ReadCommitted | Standardvalet för projektioner och queries. Snabbt, undviker dirty reads. |
Snapshot | Bra för långkörande läs-rapporter. Kräver READ_COMMITTED_SNAPSHOT ON på databasen. |
Serializable | Använd inte i high-throughput-kod. Risk för deadlocks och blockering. |
SELECT * FROM dbo.Messages i en lång transaktion
Det låser hela event-tabellen och blockerar skrivare. Läs alltid streams genom SqlStreamStores API som använder pagineringar och korta connections.
Even with optimistic concurrency så får du bättre prestanda om du aldrig har två trådar som skriver till samma aggregat samtidigt. Detta uppnås med en in-memory kö per StreamId — vilket är exakt vad vi bygger i nästa lektion (08 · Command-kö & Undo).
(StreamId, Version) + expectedVersion vid append.TransactionScope ger exactly-once-projektion utan outbox.TransactionScope och async-flöden.Lös övningarna självständigt. Det finns inget facit — lärandet sker i processen.
ConcurrencyConflictException. Lägg därefter på Polly-retry runt handlern och visa att båda commands lyckas.AccountSummaryProjection så att WriteCheckpointAsync kastar exception efter att read modellen uppdaterats — men utanför TransactionScope. Verifiera att eventet processas igen och saldot blir fel. Återställ koden så att båda anropen ligger i samma scope och visa att problemet försvinner.SqlIdempotencyStore : IIdempotencyStore mot tabellen i exemplet. Skriv ett integrationstest som skickar samma DepositMoneyCommand två gånger med samma MessageId och verifiera att kontosaldot bara ökade en gång.dbo.Outbox och en handler som skriver en rad i samma TransactionScope som event-appenden. Bygg en OutboxRelay : BackgroundService som pollar och loggar i konsolen (ingen riktig bus krävs).OutboxRelay som publicerar event-payloads till en RabbitMQ-kö. Verifiera att meddelanden alltid levereras minst en gång även om relay-processen krashar mitt i. Bonus: implementera "at-least-once → effectively-once" med en mottagar-side dedupliceringsstore.
← Föregående: SqlStreamStore & projektioner Nästa: Command-kö & Undo →