Del 7 av 10

Transaktioner & concurrency — när två skribenter krockar

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.

Lektion 7 — V4 mån. Fokus på vad som händer när två skribenter når samma aggregat samtidigt, och hur vi gör skrivningen säker över processgränser.

Optimistic concurrency — den enkla idén

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.

Var optimistic concurrency bor i SqlStreamStore Tabellen 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.

Hantera konflikten — retry med Polly

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);
    });
}
Retry är inte alltid säkert Retry fungerar när commandot är idempotent ur affärssynvinkel — d.v.s. att utföra det två gånger ger samma sluttillstånd. För 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.

Stor fördel med single-database-setup

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.

När behöver vi outbox då?

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.

I denna kurs Vi har ingen extern bus och behöver därför ingen outbox för read-modellen. Outbox-tabellen visas här för att vara förberedd när systemet växer.

Idempotens på handler-nivå — repris

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.

Concurrency-flöde — exempel

// 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

Isolationsnivåer

NivåNär
ReadCommittedStandardvalet för projektioner och queries. Snabbt, undviker dirty reads.
SnapshotBra för långkörande läs-rapporter. Kräver READ_COMMITTED_SNAPSHOT ON på databasen.
SerializableAnvänd inte i high-throughput-kod. Risk för deadlocks och blockering.
Lägg aldrig 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.

Single-writer per aggregat

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).

Sammanfattning

Referenser

Elektroniska resurser

Böcker

Övningar

Lös övningarna självständigt. Det finns inget facit — lärandet sker i processen.

  1. Forcera en konflikt Skriv ett test som laddar samma aggregat i två tasks, muterar båda och sparar. Verifiera att den andra sparningen kastar ConcurrencyConflictException. Lägg därefter på Polly-retry runt handlern och visa att båda commands lyckas.
  2. Bryt projektionen avsiktligt Modifiera 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.
  3. Implementera idempotency-store Skapa 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.
  4. Outbox-skiss Skapa tabellen 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).

Soloprojektor

Projekt 1 — Robust skrivkedja Utöka bankkontoexemplet med: idempotency-store i MSSQL, Polly-retry på alla handlers, och en stress-test (NBomber eller k6) som skickar 1 000 samtidiga deposits till samma konto. Mät hur många konflikter som inträffar, hur många som lyckas efter retry, och hur slutsaldot blir exakt rätt.
Projekt 2 — Outbox med RabbitMQ (fördjupning) Lägg till outbox-tabell och en 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 →