Skip to content

Cephalon.EventSourcing.ClickHouse

Maturity: M1 · Ownership: provider-managed · Family: event-sourcing · See audit, matrix.

Cephalon.EventSourcing.ClickHouse is the ClickHouse event-store provider for Cephalon, following the same provider pattern as Cephalon.EventSourcing.MongoDB, Cephalon.EventSourcing.Redis, Cephalon.EventSourcing.Neo4j, and Cephalon.EventSourcing.Cassandra. It delivers the IEventStore contract against a ClickHouse MergeTree table ordered by (stream_id, stream_version), with application-layer optimistic concurrency via a pre-read version check.

  • a ClickHouse-backed implementation of IEventStore registered through AddCephalonClickHouseEventSourcing()
  • the ClickHouseEventEntry record model for persisted domain event rows
  • ClickHouseEventSourcingConfiguration that holds the DDL template for the event-streams table using MergeTree() ordered by (stream_id, stream_version)
  • optimistic-version append semantics: reads the current stream version before every AppendAsync, compares against expectedVersion, and throws EventStreamConcurrencyException before writing if they differ
  • stream replay through ReadStreamAsync returning events ordered by stream_version ascending as an IAsyncEnumerable<IDomainEvent>
  • GetVersionAsync returning -1 for a stream that does not exist yet
  • System.Text.Json serialization for event payloads using the concrete event CLR type
  • event type round-tripping through AssemblyQualifiedName — stored as written by the CLR and resolved back via Type.GetType() on read
  • lazy table bootstrap — CREATE TABLE IF NOT EXISTS is executed once per instance using a SemaphoreSlim(1,1) guard
  • connection-per-operation pattern — a new ClickHouseConnection is created for each database operation; no long-lived connection object is held
  • ClickHouseEventEntry.cs
  • ClickHouseEventStore.cs
  • ClickHouseEventSourcingConfiguration.cs
  • Hosting/ClickHouseEventSourcingServiceCollectionExtensions.cs

This pack sits on top of Cephalon.EventSourcing, not in place of it. Cephalon.EventSourcing owns the IEventStore contract, the IDomainEvent marker, and EventStreamConcurrencyException. Cephalon.EventSourcing.ClickHouse supplies the ClickHouse columnar implementation of that contract so event-sourced aggregates can keep the same IEventStore injection point while using ClickHouse as the append-only analytics backing store.

builder.Services.AddCephalonClickHouseEventSourcing(
host: "localhost",
database: "myapp");

The tableName parameter defaults to "cephalon_event_streams" and can be overridden:

builder.Services.AddCephalonClickHouseEventSourcing(
host: "localhost",
database: "myapp",
tableName: "domain_events",
username: "cephalon",
password: "secret");

The method registers IEventStore using TryAdd semantics — a host that already registered a shared IEventStore keeps its own instance.

ColumnClickHouse typeNotes
stream_idStringLogical aggregate / stream identifier
stream_versionInt64Per-stream monotonic version
event_typeStringAssemblyQualifiedName of the concrete event CLR type
payloadStringSystem.Text.Json-serialized event body using the concrete type
occurred_at_utcDateTime64(3, 'UTC')IDomainEvent.OccurredAtUtc as stored by the domain event
appended_at_utcDateTime64(3, 'UTC')UTC wall-clock time of the append operation

Engine: MergeTree() ORDER BY (stream_id, stream_version)

The MergeTree engine is used because domain events are immutable — no deduplication or replacement is needed. Unlike ReplacingMergeTree, MergeTree preserves every appended row permanently.

The ORDER BY (stream_id, stream_version) clause makes per-stream range queries efficient and stores events in version order within each logical stream. ClickHouse physically stores rows sorted by this key on disk.

Important: ClickHouse MergeTree does not enforce uniqueness on the ORDER BY key. Two concurrent writers can both INSERT with the same (stream_id, stream_version) without database-level rejection. See the Concurrency semantics section below.

ScenarioBehaviour
GetVersionAsync on empty streamReturns -1
AppendAsync(..., expectedVersion: -1) on empty streamSucceeds — assigns versions starting at 1
AppendAsync(..., expectedVersion: N) when stream is at NSucceeds — appends events at versions N+1, N+2, ...
AppendAsync with wrong expectedVersionEventStreamConcurrencyException thrown before any insert
Concurrent writer commits same version (race after version read)Both INSERTs succeed at the database level — MergeTree does not reject duplicates. The pre-read check catches the conflict only if observed before the race window closes.
Event’s StreamVersion does not match expected sequential assignmentInvalidOperationException thrown — events must declare the version the provider will assign
Event’s StreamId does not match the streamId argumentInvalidOperationException thrown

Known limitation — optimistic concurrency

Section titled “Known limitation — optimistic concurrency”

The version-read (max(stream_version)) followed by INSERT is not atomic. Two concurrent writers that both observe the same expectedVersion will both pass the pre-insert check and both INSERT successfully — ClickHouse MergeTree does NOT enforce uniqueness on ORDER BY key columns.

This means:

  • EventStreamConcurrencyException is thrown only when the pre-read version check fails (catches most single-process conflicts)
  • A narrow race window exists between the version read and the INSERT where a concurrent writer can slip through
  • For strict concurrency control, wrap AppendAsync operations in application-layer distributed locking (e.g. Redis SETNX, database advisory locks, or a distributed lock service)

ClickHouse is optimized for high-throughput append-heavy analytics workloads. This provider is well-suited to scenarios where occasional duplicate detection at the application layer is acceptable, and to single-writer event stream scenarios where strict concurrency is not required.

ReadStreamAsync(streamId, fromVersion) executes:

SELECT stream_id, stream_version, event_type, payload, occurred_at_utc, appended_at_utc
FROM {tableName}
WHERE stream_id = '{streamId}' AND stream_version >= {fromVersion}
ORDER BY stream_version ASC

It returns an IAsyncEnumerable<IDomainEvent>, yielding events one by one as the reader is iterated. The CLR type is resolved from event_type via Type.GetType(throwOnError: false) — a missing type throws InvalidOperationException with a message that names the unresolvable type and the stream.

ClickHouseEventStore uses a bool _tableCreated flag checked before every operation. On first access it executes the CREATE TABLE IF NOT EXISTS DDL from ClickHouseEventSourcingConfiguration.CreateTableSql using a SemaphoreSlim(1,1) guard. Subsequent calls skip the DDL via the flag. This avoids startup cost if the store is never accessed in a given process lifetime, and avoids repeated DDL round-trips in steady state.

ClickHouseEventStore creates a new ClickHouseConnection per operation. No socket is opened at construction time. The TCP connection to the ClickHouse HTTP endpoint is deferred until connection.OpenAsync() is called for each operation. This means IEventStore can be composed in tests and at startup without requiring a live ClickHouse node.

This provider intentionally does not claim:

  • snapshot persistence (ISnapshotStore is not implemented)
  • projection rebuild orchestration
  • archival or retention management
  • background stream replay workers
  • database-level uniqueness constraint on (stream_id, stream_version) — use application-layer locking for strict concurrency
  • Materialized Views for streaming projections
  • per-event CorrelationId or TenantId column population (fields absent from the table schema in this slice)
  • transport or event-bus integration