KurrentDB snapshots
Ranvier.Persistence.KurrentDB stores a small scope of persisted inputs as a snapshot stream. Each mutation appends the full scope atomically. Reads fetch the latest snapshot.
Setup
Install the .NET 10 adapter from NuGet:
dotnet add package Ranvier.Persistence.KurrentDB --version 0.1.1-preview.1
open KurrentDB.Client
open Ranvier.Persistence
open Ranvier.Persistence.KurrentDB
use client = new KurrentDBClient(KurrentDBClientSettings.Create connectionString)
let store = KurrentDBStateStore(client, "customer-42") :> IStateStore
Keep the caller-owned client for the application's lifetime. Use a stable scope to reload the same state. Scopes are case-sensitive Unicode strings, limited to 512 UTF-8 bytes; each has an independent stream. The adapter creates its stream on the first mutation.
For local tests, start an insecure server using the KurrentDB installation guide and connect with kurrentdb://localhost:2113?tls=false. Production connections should use the server's TLS and authentication settings.
Save, reload, derive
open Ranvier
let codec: JournalCodec<int> =
{ Name = "int"
Version = 1
Encode = string
Decode = fun text ->
match System.Int32.TryParse text with
| true, value -> Ok value
| _ -> Error "Expected an integer" }
let countKey = Persist.key "count" (PersistCodec.ofJournal codec)
let writeCount session value =
use graph = new Graph()
use active = graph.Activate()
let count = Persist.signal session countKey 0
count.Value <- value
session.FlushAsync()
let readCount session =
use graph = new Graph()
use active = graph.Activate()
let count = Persist.signal session countKey 0
let doubled = createMemo (fun _ -> count.Value * 2)
count.Value, doubled.Value
let roundTrip () =
task {
let! session = Persist.loadAsync store [ countKey ]
do! writeCount session 21
let! restored = Persist.loadAsync store [ countKey ]
return readCount restored
}
// roundTrip() completes with (21, 42).
The graph work runs synchronously. Await the returned flush task before reloading; queued storage continues after graph disposal. See Persisted inputs for codecs, migrations and busters.
Conditional writes
Loaded records carry an opaque ETag. Preserve it to reject a competing update:
let incrementStored () =
task {
let! rows = store.LoadMany [ "count" ]
let current = rows.Head
do! store.SaveMany [ { current with Payload = string (int current.Payload + 1) } ]
return! store.LoadMany [ "count" ]
}
Seed "count" with the round-trip example first. A changed or deleted record raises KurrentDBStateConflictException, whose Key identifies the conflict. Task-to-async bridges may wrap it in AggregateException. Reload and resolve the conflict in the application.
A successful save changes the saved records' ETags; unrelated records keep theirs. StoredRecord.Version remains the payload codec version. Reload for the new ETag before the next conditional save.
Ordinary PersistSession writes currently carry ETag = None and use last-writer-wins. Automatic optimistic session writes remain separate work.
Capacity and lifecycle
- Every save, delete or clear appends a complete snapshot. The encoded scope is limited to 1 MiB; oversized mutations fail before appending. The server may impose a lower limit.
- A
SaveManybatch commits completely or fails. Duplicate keys, malformed ETags and invalid Unicode are rejected. - A mutation attempts up to 32 appends against fresh snapshots on stream conflicts. A stale record ETag fails immediately. Exhausted stream conflicts and connection errors propagate to the caller.
- A missing ETag permits an insert or replacement. Deletes and clear are unconditional within the scope.
ClearandDeleteManyremove records from the current snapshot. Older events remain in stream history; these operations are logical deletion.- Reserve streams beginning
ranvier-state-v1-for the adapter. Its scope suffix is base64url of the UTF-8 scope. External events, physical deletion, truncation or expiry can invalidate stored state. - History grows with each mutation. Manage retention explicitly; a maximum-count policy retaining at least the latest event can preserve current state. Age-based expiry can remove an idle scope's current snapshot.
For larger state or frequent writes, evaluate the Marten adapter. A connection failure can leave the commit acknowledgement uncertain. Reload before retrying a conditional write. The KurrentDB adapter implements IStateStore; journal transport remains part of #185.
Integration tests
Set RANVIER_KURRENTDB_CONNECTION to a disposable server, then run:
dotnet test tests/Ranvier.Persistence.KurrentDB.Tests -c Release
The suite covers the shared storage and ETag contracts, atomic rollback, concurrent clients, scope isolation, capacity failures, corrupt snapshots and a fresh graph restart. A missing or unavailable server fails the run. CI starts its own pinned KurrentDB container and tests both package variants.