KurrentDB / EventStoreDB (.NET)
The purpose-built event store: an append-only, chunked transaction log where every write is an event in a named stream, guarded by an expected-version check and an event-id idempotence check, exposed through one global $all order, catch-up and server-checkpointed subscriptions, and an in-server JavaScript projections engine.
| Field | Value |
|---|---|
| Language | C# / .NET |
| License | Kurrent License v1 (source-available; forbids offering it as a hosted service) for code after 24.6.x, with older parts still under the Event Store License (LICENSE.md) |
| Repository | kurrent-io/KurrentDB |
| Documentation | docs.kurrent.io |
| Category | event store |
| Persistence model | replay (the log is the truth; consumers fold it; snapshots are an application pattern) |
| Journal store | Its own chunked transaction file (chunk-NNNNNN.NNNNNN, 256 MiB chunks) plus a hash index (PTables) over it |
| Latest release | v26.1.2, August 11, 2026 (releases); the clone's src/Directory.Build.props carries VersionPrefix 26.2.0 prerelease |
| Local clone | $REPOS/kurrentdb at 38fc23c5e971ee186871c03ffe94526be07c556c |
Last reviewed: September 12, 2026.
Overview
What it solves
KurrentDB (renamed from EventStoreDB in 2025; the code still says ESDB in places such as the mutex name in ExclusiveDbLock.cs) is not a workflow engine. It is the storage half of the durable-execution problem, offered on its own: an immutable log of facts, addressed as streams, that applications fold into state. The concepts page states the contract plainly:
"The event log is an append-only sequence of events stored within the database. It is the ultimate source of truth, capturing every event appended to KurrentDB" and "Once an event is appended, its type, body, or any part of it cannot be modified. The event remains unchanged forever." (concepts)
In this catalog it is the event-store kind: no code is replayed by the database, so the replay-of-code dimensions of the spine translate to their event-store analogues and are analysed as such below. Step identity becomes stream identity plus event number plus event id; the journal-versus-world question becomes the expected-version check; determinism becomes the fold discipline of a projection; compensation becomes reversing events; versioning becomes upcasting; concurrency becomes the $all order and the multi-stream append; replay-or-snapshot becomes checkpoints and snapshot events; testing becomes an in-memory node or a container.
Design philosophy
Three commitments shape everything:
- The log is a transaction file, not a table. Writes go through a single
StorageWriterServiceintoTFChunkfiles; reads go through an index that maps(stream hash, event number)to a log position. There is one writer per database, enforced at process level by a named mutex (ExclusiveDbLock.cs) and at cluster level by leader election. - Concurrency is optimistic, never locked. The concepts page: the checks "prevent accidental overwrites or lost updates due to race conditions" without "resource locks, meaning these protections come without the performance hit of managing locks" (concepts).
- Everything derived is itself a stream. Projection checkpoints, projection results, persistent-subscription checkpoints and parked messages are all ordinary events in
$-prefixed system streams. The database has one storage primitive and uses it for its own bookkeeping.
How it works
The log: prepares, commits and positions
A write is stored as one PrepareLogRecord per event. The record carries the fields a consumer needs to re-identify it later (PrepareLogRecord.cs):
// src/KurrentDB.Core/TransactionLog/LogRecords/PrepareLogRecord.cs (fields, abridged)
public long TransactionPosition { get; }
public int TransactionOffset { get; }
public long ExpectedVersion { get; }
public string EventStreamId { get; }
public Guid EventId { get; }
public Guid CorrelationId { get; }
public DateTime TimeStamp { get; }
public string EventType { get; }
public ReadOnlyMemory<byte> Data => ...;
public ReadOnlyMemory<byte> Metadata { get; }PrepareFlags says what kind of record it is. Modern writes are SingleWrite | IsCommitted: the prepare is "considered committed immediately, no commit will follow in TF" (PrepareLogRecord.cs). The separate CommitLogRecord (TransactionPosition, FirstEventNumber, SortKey, CorrelationId, TimeStamp in CommitLogRecord.cs) survives for the legacy explicit-transaction path (TransactionStart / TransactionWrite / TransactionCommit under Services/RequestManager/Managers/).
A position in the log is a TFPos pair, (CommitPosition, PreparePosition), rendered as 32 hex digits (TFPos.cs). This is the coordinate of the $all stream and what a catch-up subscription checkpoints.
The file layout is fixed in TFConsts.cs: ChunkSize = 256 * 1024 * 1024, a 128-byte header and footer, and MaxLogRecordSize = 16 * 1024 * 1024. Each chunk header records a ChunkId, a format Version and MinCompatibleVersion, and IsScavenged (ChunkHeader.cs).
The write path and ExpectedVersion
The sentinel expected versions are in ExpectedVersion.cs:
// src/KurrentDB.Core/Data/ExpectedVersion.cs
public static class ExpectedVersion {
public const long Any = -2;
public const long NoStream = -1;
public const long Invalid = -3;
public const long StreamExists = -4;
public const long SoftDeleted = -5;
public const long MinValue = SoftDeleted;
}Any non-negative value means "the last event number of the stream must be exactly this". The client SDK spells the same thing as StreamState.NoStream, StreamState.Any, StreamState.StreamExists or a StreamRevision (.NET appending events):
// docs.kurrent.io, .NET client v1.4, appending-events
var eventData = new EventData(
Uuid.NewUuid(), "OrderPlaced", "{\"orderId\": \"123\"}"u8.ToArray()
);
await client.AppendToStreamAsync(
"order-123",
StreamState.NoStream,
new List<EventData> { eventData }
);Every append is decided by IndexWriter<TStreamId>.CheckCommit (IndexWriter.cs), which the writer calls once per stream in the request (StorageWriterService.cs). It returns a CommitCheckResult whose Decision is one of (CommitDecision.cs):
// src/KurrentDB.Core/Services/Storage/ReaderIndex/CommitDecision.cs
public enum CommitDecision {
Ok,
ConsistencyCheckFailure,
Idempotent,
/// <summary>Some of the events in the stream are idempotent and others are not</summary>
CorruptedIdempotency,
InvalidTransaction,
/// <summary>Idempotent write, but not yet indexed</summary>
IdempotentNotReady,
}The decision procedure, read from IndexWriter.cs:
- A hard-deleted stream (
EventNumber.DeletedStream) rejects any append with events "regardless of the specified expected version". StreamExistsfails on a soft-deleted stream and on a stream with neither events nor a metadata stream.- Otherwise:
expectedVersion > curVersionis aConsistencyCheckFailure("writing after the end");expectedVersion == curVersionisOk;expectedVersion < curVersionenters the idempotence check below.
The idempotence rule, precisely
The rule the docs give is short: "If two events with the same Uuid are appended to the same stream in quick succession, KurrentDB will only append one of the events." (.NET appending events). The code is more specific, and the distinction matters for anyone copying the rule.
There are two tiers, named in comments in IndexWriter.cs:
- Strong idempotency applies when the request names an exact expected version and that version is behind the stream head. The writer walks the batch: for event
iit expects event numberexpectedVersion + 1 + i, and the event is "already written" only if a record with that event id exists at that event number in that stream (checked first in the recently-committed cache_committedEvents, then by_indexReader.ReadPrepare). If every event in the batch is already there, the decision isIdempotent. If the first event is missing, it is an ordinaryConsistencyCheckFailure. If some but not all match, it isCorruptedIdempotency, which the writer reports as a consistency failure "since caller will need to sync with that stream" (StorageWriterService.cs). - Weak idempotency applies when the expected version is
Any,StreamExistsorSoftDeleted. The comment is explicit about the limit: "if the request doesn't specify what the event numbers must be, so we don't know where in the stream to look for them. check recently written events only." Only the in-memory_committedEventscache of recent event ids is consulted. A duplicate old enough to have left that cache is appended again.
So the exact rule is: same stream + same event id + (with an explicit expected version) same event number ⇒ no-op; with Any, same stream + same event id within the recent-write window ⇒ no-op. The batch must be all-or-nothing; a partially duplicated batch is refused.
An idempotent write is not reported as a failure. The writer replies StorageMessage.AlreadyCommitted carrying the original LogPosition and first/last event numbers (StorageMessage.cs), and the request manager completes the client's request as a success at that position, logging IDEMPOTENT WRITE TO STREAM (RequestManagerBase.cs). The client cannot tell a replayed append from a first one. One caveat: IdempotentNotReady, an idempotent match against a write not yet replicated to the cluster, is simply dropped with "just drop the write and wait for the client to retry" (StorageWriterService.cs).
Atomicity across events and streams
A single AppendToStreamAsync with several EventData is one transaction: all prepares are written together and share a TransactionPosition. Since 25.1 MultiStreamAppend and since 26.1 AppendRecords extend this to several streams: "either all writes succeed or the entire operation fails", and with AppendRecords, "Records from different streams can be mixed, and their exact order is preserved in the global log." (.NET appending events). Server-side, the WritePrepares handler runs CheckCommit for every stream in the request before writing any prepare, and a stream may be included as a check-only stream with no events (HasEventsToWrite == false in CommitCheckResult.cs), which is how a consistency check on stream B can guard an append to stream A (StorageWriterService.cs).
Streams, $all, metadata and scavenging
Every event has two coordinates: (stream, eventNumber) and its TFPos in $all, "a dedicated paged stream containing all events" (streams). $all is read straight off the transaction file (StorageReaderWorker.All.cs); stream reads go through the TableIndex / PTable hash index (PTable.cs).
Retention is stream metadata, itself an event in $$stream. The keys are $maxCount, $maxAge, $tb (truncate before), $cacheControl and $acl (streams; parsed in StreamMetadata.cs). Setting $tb is a soft delete; a hard delete appends a $streamDeleted tombstone. None of this frees bytes: "events are...still present in the database and will be visible in reads and subscriptions to the $all stream. To remove these events from the database...you need to run a 'scavenge.'" (scavenge). Scavenging "removes events and reclaims disk space by creating a copy of the relevant chunk, minus those events, and then deleting the old chunk", then merges small chunks. It is driven by a ScavengePoint (SP-{EventNumber}, the position to scavenge up to, exclusive) and per-stream DiscardPoints (FirstEventNumberToKeep) (ScavengePoint.cs, DiscardPoint.cs).
Subscriptions and checkpoints
Two families, distinguished by who holds the position:
Catch-up subscriptions are client-driven. "A checkpoint is the position of an event in the
$allstream to which your application has processed." The client stores it and resubscribes from it; positions are exclusive (.NET subscriptions):csharp// docs.kurrent.io, .NET client v1.4, subscriptions (abridged) var checkpoint = FromStream.Start; // or read from persistent store await using var subscription = client.SubscribeToStream("order-123", checkpoint); await foreach (var message in subscription.Messages) { switch (message) { case StreamMessage.Event(var evnt): checkpoint = FromStream.After(evnt.OriginalEventNumber); break; } }For filtered
$allsubscriptions the server also emitsAllStreamCheckpointReachedmessages so a consumer that filters out everything still advances.Persistent subscriptions are server-driven competing consumers. "The subscription checkpoint is maintained by the server." (.NET persistent subscriptions). The checkpoint is a
$SubscriptionCheckpointevent in$persistentsubscription-{stream}::{group}-checkpoint(PersistentSubscriptionCheckpointWriter.cs), written byTryMarkCheckpointat the lowest position that has no outstanding or retrying message ahead of it (PersistentSubscription.cs). Delivery is at-least-once: consumers ack or nack (Park,Retry,Skip), and messages exceeding the retry count go to a-parkedstream (server persistent subscriptions).
The projections engine
Projections run inside the server on the leader node, in a Jint JavaScript interpreter (JintProjectionStateHandler.cs). The global functions emit and linkTo are installed on the engine, and the definition DSL (fromStream, fromAll, fromCategory, fromStreams, when, partitionBy, outputState, transformBy, filterBy) is a state machine of which call may follow which (JintProjectionStateHandler.cs). A projection is a fold: $init returns the seed, each handler receives (state, event) and mutates or returns the state (.NET projections):
// docs.kurrent.io, .NET client v1.4, projections
fromAll()
.when({
$init: function () {
return { count: 0 };
},
$any: function (s, e) {
s.count += 1;
},
})
.outputState();The engine's own bookkeeping is streams: $projections-{name}-checkpoint, -result, -state, -emittedstreams, -order, -partitions (ProjectionNamesBuilder.cs). A checkpoint is a $ProjectionCheckpoint event (CoreProjectionCheckpointWriter.cs) whose body is a CheckpointTag: a Phase, a TFPos Position and, for stream-mode projections, a Streams dictionary of per-stream event numbers (CheckpointTag.cs). The docs: "Checkpoints store how far along a projection is in the streams it is processing from. There is a performance overhead with writing a checkpoint, as it does more than append an event, and writing them too often can slow projections down." (custom projections). Five system projections ($by_category → $ce-, $by_event_type → $et-, $stream_by_category, $by_correlation_id → $bc-, $streams) write link events rather than copies (system projections).
Two rules matter for anyone modelling a UI on this: "Streams where projections emit events cannot be used to append events from applications", and resetting a projection deletes its checkpoint and soft-deletes its output streams so it reprocesses from the beginning (projections, .NET projections).
Analysis
1. Step identity and replay matching
There is no step: the unit is an event, and it has three identities. The application's EventId (a Uuid the client must generate) is the idempotence key. The (EventStreamId, eventNumber) pair is the position identity that an expected-version append asserts. The TFPos is the global identity that subscriptions checkpoint. Replay matching is the strong-idempotency walk in IndexWriter.cs: "is there already a record with this event id at this event number in this stream". Matching is by identity only; the event's Data is never compared, so a re-append with the same id and a different body is silently a no-op. That is a documented consequence of "same Uuid", not a bug, and it means the idempotence key must encode everything that distinguishes an attempt.
2. Journal versus world
The journal is the world, so the question becomes: what happens when a writer's belief about the stream is stale? The answer is the expected-version check, and the store always wins. A writer that read version n, computed, and appends with expectedVersion = n succeeds only if nobody else appended in between; otherwise WrongExpectedVersion (RequestManagerBase.cs). There is no reconciliation rule table; the writer's job is to re-read and re-decide. Disagreement is detected by one integer comparison at commit time, which is exactly why it is cheap and why the docs can promise it without locks (concepts). The $all position of a catch-up subscription plays the same role on the read side: a consumer that checkpoints p and crashes resumes from p, and the store never advances a client-held checkpoint on its own.
3. Determinism enforcement
Not enforced, by design: the database stores facts and does not run application code, so it cannot police how the facts were produced. The one place it does run code, the projections engine, makes the fold deterministic by construction: a handler only sees (state, event), the runtime restricts which DSL calls are legal after which (JintProjectionStateHandler.cs), and the engine owns the checkpoint so that a restart re-folds from the last $ProjectionCheckpoint rather than from a handler's memory. Nothing stops a JavaScript handler from consulting Date, but there is no clock or I/O capability exposed to it beyond emit/linkTo. Outside projections, determinism between events is the application's discipline.
4. Compensation and failure handling
There is no compensation primitive. Failure handling is layered as (a) reject the write (ConsistencyCheckFailure, or refuse the whole batch on CorruptedIdempotency), (b) atomicity for a multi-stream append so no partial fact set is ever visible (.NET appending events), and (c) for consumers, the persistent-subscription nack ladder: Retry, Park to a poison stream, Skip (.NET persistent subscriptions). Undoing a fact is itself a new fact, appended by the application. Scavenging is the only thing that removes data, and only what metadata already declared dead (scavenge).
5. Versioning against old histories
The store is neutral: an event type is a string and a body is bytes. Evolution is an application pattern documented on the Kurrent blog: "as you load an old version of the event, you upconvert (or upcast) the old event to the new version through a small piece of code, before passing it to the projection logic.", with the alternative that "a parser may be a better option than upcasting. The parser would receive the raw serialised format of any version of an event and directly parse it to an in-memory structure before replaying the event.", and, for the drastic case, "A copy-replace is a process where we copy events from one event store to a new event store, at the same time making all the necessary changes." (event immutability). The chunk format has its own compatibility scheme (Version and MinCompatibleVersion in ChunkHeader.cs) so old chunks stay readable by newer servers. Note the asymmetry with a replay engine: an upcaster runs on read, so old histories are never rewritten and the code that produced them is irrelevant.
6. Concurrency under replay
Concurrency is resolved at append time, not at replay time. Within one stream the expected version serializes writers; across streams there is no ordering promise except what $all records: with MultiStreamAppend, "ordering across streams is not guaranteed", whereas AppendRecords preserves the exact interleaving in the global log (.NET appending events). Replay of a consumer is therefore always sequential over one total order ($all, or one stream), and the concurrency of the original writers is invisible except through their positions. Persistent subscriptions reintroduce concurrency on the consumer side and are explicit that ordering is then a best effort: the Pinned strategy "is not a guarantee, and you should handle the usual ordering and concurrency issues." (.NET persistent subscriptions).
7. Replay or snapshot
Replay is the default and the only built-in mechanism for state; position is what gets snapshotted. Every consumer kind records a position (client checkpoint, $SubscriptionCheckpoint, $ProjectionCheckpoint) and replays events after it. Projection checkpoints are the exception that stores state too: a $ProjectionCheckpoint carries the folded state, which is why writing one "does more than append an event" (custom projections). Application-level snapshots of aggregates are a documented pattern, not a feature: "Snapshots are a way of storing the current state of an aggregate at a particular point in time, and can be used to skip over the previous events when loading the aggregate.", kept in a separate stream with $maxCount so "only one snapshot event" survives, with the warning that "The need to use snapshots may hint to the model's design flaw." (snapshots). The cost of pure replay is the length of the stream; the cost of a snapshot is a second schema to version.
8. Testing
Two supported paths. In-process: MiniNode in MiniNode.cs boots a full ClusterVNode with inMemDb = true by default, and the server's own suites under src/KurrentDB.Core.Tests are written against it. The --mem-db option that backs it is deprecated: "--mem-db has been deprecated as of version 25.1.0 and will be removed in a future version to allow us to simplify and unify some core code paths." (db config; [Deprecated(...)] on MemDb in ClusterVNodeOptions.cs). Out-of-process: the official clients test against a container: "Integration tests run against a server using Docker. Tests are written using TestContainers and require Docker to be installed." with the image chosen by KURRENTDB_IMAGE (Java client). There is no simulation of the log itself; the substrate is tested by running it.
9. Journal integrity and the single writer
KurrentDB is the purest statement of this dimension in the survey, because integrity of an append-only log is the whole product rather than a supporting concern.
Every append can be conditional, and the condition is the stream's length.ExpectedVersion is compared against the stream's current version at commit time, and the decision procedure returns a typed outcome rather than a boolean — including CorruptedIdempotency when a re-submission matches an existing event id but not the position it should occupy (IndexWriter.cs). A library that appends to a file has the same option available: assert the expected length.
Idempotency comes in two strengths, and the weaker one is bounded. With an explicit expected version, a re-append of the same event id at the same position is recognised and becomes a no-op. Without one, the check falls back to a cache of recent writes, which the source states plainly as a "weak idempotency" check that looks only at recently written events (IndexWriter.cs). So unasserted appends buy convenience at the cost of a deduplication window, and the window is not documented as a guarantee.
The writer is fenced by a process-level mutex, and that is a different guard.ExclusiveDbLock holds a named mutex over the database path (ExclusiveDbLock.cs), which stops a second server instance opening the same files. It is orthogonal to ExpectedVersion: the lock keeps two processes apart, the expected version keeps two logical writers apart. Conflating them is a mistake a library can make and this design does not.
Records are committed in two parts, on purpose. The transaction file holds prepare records followed by a commit record, so a partially written transaction is identifiable as one that never committed — the log format itself distinguishes "written" from "committed", which is the write-ahead discipline expressed in the storage layout rather than in a protocol.
Chunks carry a version and a minimum compatible version, so an older server refuses a newer chunk format instead of misreading it (§5). That is the same idea as a per-record format key, applied at file granularity.
Nothing records which client wrote an event. Identity in the record is the event id, the stream position and the global position; the writer is not part of it.
10. Operator recovery and intervention
The operator surface is broad, and its most interesting feature is that a consumer that cannot process a record has somewhere for it to go.
Parked messages are a real dead-letter queue. A persistent subscription's consumer can negatively acknowledge with retry, park or skip; a parked message goes to its own stream, and ReplayParkedMessages and ReplayParkedMessage exist as server operations to put them back (PersistentSubscriptionService.cs, ParkedMessageOperationResult.cs). One record failing therefore does not stop the subscription or get silently dropped, and an operator can drain the parked stream after a fix. Very few systems in this survey have anywhere to put a poison record.
Projections can be reset, which discards the projection's checkpoint and folded state and re-reads from the beginning — the read-model equivalent of a rebuild, and the reason a projection's output must be treated as derived rather than as truth.
Deletion comes in two strengths. A soft delete makes a stream appear empty while its events remain until a scavenge, and a hard delete tombstones the stream so the name cannot be reused. Stream metadata adds $maxCount and a truncate-before marker for bounded retention.
Scavenging is the space-reclamation operation, and it is deliberately separate from deletion: deleting marks, scavenging removes. That separation is what makes deletion cheap and reversible up to the point a scavenge runs.
Inspection is the product's strong suit. Any stream can be read forwards or backwards, $all gives the global order, and system streams expose the store's own bookkeeping. Reading a record back needs no cooperation from whatever wrote it.
What is absent is anything about a computation. There is no fork, no rewind of a program, no cancellation — because there is no program. The operator surface is over data, and that is the honest boundary of an event store.
11. Suspension and external input
This dimension does not apply, and the reason is worth stating precisely. An event store has no notion of a program that waits: it accepts appends and serves reads. Nothing suspends, nothing resumes, and there is no state meaning "blocked".
The nearest analogue is a subscription's checkpoint. A catch-up subscription is a reader that remembers its position, so a consumer that dies resumes from its last recorded checkpoint rather than from the beginning. That is resumption of a reader rather than of a computation, and it is the mechanism a durable program's projection would use.
Persistent subscriptions move the checkpoint server-side and add per-message acknowledgement, which converts the consumer's position from its own responsibility into shared state. The trade is that ordering is no longer guaranteed across consumers.
External input, from the store's point of view, is just an append. Whether it came from a program, a person, or an integration is not modelled, and no record distinguishes them.
What a durable-execution layer would have to supply on top: timers, a way to address a waiting computation, and a state distinguishing waiting from working. The store provides the record and the ordering, and nothing about the control flow.
Strengths
- Idempotence with the right key. Event id plus stream plus position, batch-atomic, and the duplicate returns the original position as a success. This is the "re-appended attempt is a no-op" rule done properly.
- One integer for concurrency. Expected version is cheap, lock-free and composable across streams via check-only entries in a multi-stream append.
- Everything derived is inspectable. Checkpoints, projection state and parked messages are streams you can read with the same API, which makes debugging a stuck consumer a read, not a log dive.
- A total order exists.
$allgives every consumer one sequence to checkpoint against, and link events let system projections re-slice it without copying. - Explicit retention. Nothing is deleted except by metadata plus scavenge, and scavenge is a copy-then-swap of whole chunks.
Weaknesses
- Weak idempotency is a window, not a guarantee. With
StreamState.Anythe duplicate check consults only a recent-write cache; a late retry appends twice. Callers must use an explicit expected version to get the strong rule, which is not what the docs' one-line summary suggests. - No compensation, no workflow. Sagas, timeouts and retries of application steps are entirely the application's problem; the store only refuses or accepts facts.
- Projections are a JavaScript sandbox inside the database. Powerful for read models, but
emitcreates write amplification, projections run only on the leader, and a reset soft-deletes output streams (projections). - The in-memory test mode is on its way out.
--mem-dbis deprecated, pushing tests toward containers. - License. Kurrent License v1 is source-available, not open source, for versions after 24.6.x (
LICENSE.md).
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| Expected version instead of locks | Cheap, composable, no lock lifetime to manage; a stale writer just re-reads | Writers must be written as read-decide-append loops; no "wait for the lock" |
| Idempotence keyed by client-supplied event id | Lets the client retry blindly after a timeout; duplicate returns the original position | Body is not compared, so the id must encode the attempt; Any degrades to a cache window |
Partial-duplicate batch is refused (CorruptedIdempotency) | A batch is one fact set; half-applied is worse than rejected | A retry after a genuinely mixed batch fails and the caller must resync |
$all as a first-class stream | One total order for every consumer to checkpoint against | Filtered consumers need server checkpoint messages to make progress |
| Checkpoints are events in system streams | One storage primitive; checkpoints replicate and scavenge like anything else | Checkpoint writes cost a full append (plus state for projections), so frequency is a tuning knob |
| Projections in-server, JavaScript, DSL-restricted | Read models without an external process; handler surface is small enough to keep folds pure | Write amplification, leader-only, sandbox limits |
| Retention by metadata plus explicit scavenge | Deletion is declared, not performed; the log stays append-only | Deleted data is visible in $all until scavenge; scavenge is a copy of whole chunks |
| Upcasting on read, never rewriting history | Old chunks and old events stay byte-stable | Every reader carries every upcaster forever unless a copy-replace migration is run |
Implications for a durable-execution library
- Assert the expected length on every append.
ExpectedVersionis compared at commit and returns a typed decision rather than a boolean (§9), which is the same option a library appending to a file has and usually declines to take. - Keep the process lock and the append condition separate. A named mutex over the database stops a second process; the expected version stops a second logical writer. They solve different problems, and a library that ships only a lock file has solved the first.
- Unasserted appends should not exist in a durable path. Without an expected version, deduplication degrades to a recent-writes cache — a window rather than a guarantee (§9). If the caller cannot state what it expects, the write should not be idempotent by accident.
- A same-id-different-position re-submission is corruption, not a duplicate. Naming that case and refusing it is better than treating every repeat as benign.
- Distinguish written from committed in the format itself. Prepare records followed by a commit record make a partially written transaction identifiable without a checksum, which is the write-ahead rule expressed as layout.
- A poison record needs somewhere to go (§10). Parking a message that a consumer cannot process, on its own stream, with an operation to replay it later, is the dead-letter design most systems in this survey lack entirely.
- Separate marking from reclaiming. Deleting a stream and scavenging the space are different operations, which is what makes deletion cheap and recoverable up to the scavenge.
- A reader's checkpoint is the projection primitive (§11): the consumer records its position and resumes from it. Any library building a read model over its record needs exactly this, and nothing more.
- The boundary of an event store is instructive. It provides the record and the ordering and deliberately nothing about control flow — no timers, no addressing of a waiting computation, no waiting state. Those are the durable-execution layer's job, and seeing them absent here clarifies what that layer actually adds.
Sources
- Clone:
$REPOS/kurrentdbat38fc23c5e971ee186871c03ffe94526be07c556c(September 9, 2026). src/KurrentDB.Core/Data/ExpectedVersion.cssrc/KurrentDB.Core/Services/Storage/ReaderIndex/IndexWriter.cssrc/KurrentDB.Core/Services/Storage/ReaderIndex/CommitDecision.cssrc/KurrentDB.Core/Services/Storage/ReaderIndex/CommitCheckResult.cssrc/KurrentDB.Core/Services/Storage/StorageWriterService.cssrc/KurrentDB.Core/Services/RequestManager/Managers/RequestManagerBase.cssrc/KurrentDB.Core/Messages/StorageMessage.cssrc/KurrentDB.Core/TransactionLog/LogRecords/PrepareLogRecord.cssrc/KurrentDB.Core/TransactionLog/LogRecords/CommitLogRecord.cssrc/KurrentDB.Core/TransactionLog/Chunks/TFConsts.cssrc/KurrentDB.Core/TransactionLog/Chunks/ChunkHeader.cssrc/KurrentDB.Core/TransactionLog/Scavenging/Data/ScavengePoint.cssrc/KurrentDB.Core/TransactionLog/Scavenging/Data/DiscardPoint.cssrc/KurrentDB.Core/Services/PersistentSubscription/PersistentSubscription.cssrc/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cssrc/KurrentDB.Projections.Shared/Services/Processing/ProjectionNamesBuilder.cssrc/KurrentDB.Projections.Shared/Services/Processing/Checkpointing/CheckpointTag.cssrc/KurrentDB.Core.Testing/Helpers/MiniNode.cs- Kurrent docs: concepts, streams, scavenge, projections, custom projections, system projections, persistent subscriptions, database settings; .NET client: appending events, catch-up subscriptions, persistent subscriptions, projections.
- Kurrent blog: Event immutability and dealing with change (Savvas Kleanthous, January 20, 2021); Snapshots in Event Sourcing (Oskar Dudycz, May 20, 2021).
- KurrentDB Java client README (TestContainers guidance).
- Sibling pages: Temporal (replay engine), idempotence, write-ahead logging, catalog index.