Skip to content

What's new in 0.17

Eventuous 0.17 adds Azure Blob Storage projections, paged streaming reads, and an AOT-compatible serialization interface. It also reworks subscription recovery and fixes checkpointing, concurrent appends, and diagnostics. The supported targets remain .NET 8, .NET 9, and .NET 10.

These notes cover changes since 0.16.4. For earlier changes, see the 0.16 release notes.

Event serialization must be configured explicitly

Section titled “Event serialization must be configured explicitly”

The reflection-based DefaultEventSerializer has moved from Eventuous.Serialization to the new Eventuous.Serialization.Json.Dynamic package. Its namespace remains Eventuous.

Before (0.16)After (0.17)
DefaultEventSerializer in Eventuous.SerializationAdd a reference to Eventuous.Serialization.Json.Dynamic
DefaultEventSerializer.InstanceEventSerializer.Default
DefaultEventSerializer.SetDefaultSerializer(serializer)EventSerializer.SetDefault(serializer)
Automatically available reflection-based defaultConfigure a serializer before constructing components that use the default

For an application that keeps reflection-based JSON serialization:

Terminal window
dotnet add package Eventuous.Serialization.Json.Dynamic --version 0.17.0
Program.cs
using System.Text.Json;
using Eventuous;
var serializer = new DefaultEventSerializer(
new JsonSerializerOptions(JsonSerializerDefaults.Web)
);
EventSerializer.SetDefault(serializer);
builder.Services.AddSingleton<IEventSerializer>(serializer);

Keep any custom JSON options and converters from your existing configuration. The DI registration supplies the serializer to components resolved through the container; SetDefault also covers components constructed directly without an explicit serializer argument. Accessing EventSerializer.Default before configuration throws InvalidOperationException.

For trimmed or Native AOT applications, use DefaultStaticEventSerializer with a generated JsonSerializerContext. It remains in Eventuous.Serialization. Callers of IEventSerializer no longer inherit reflection warnings; the dynamic serializer reports them at construction instead. See serialization for both configurations. (#524)

Custom subscriptions use a run-based lifecycle

Section titled “Custom subscriptions use a run-based lifecycle”

Subscriptions now have one supervisor that owns connection attempts, recovery, and teardown. The built-in providers handle these changes. If you derive from EventSubscription<TOptions> or EventSubscriptionWithCheckpoint<TOptions>, update your implementation:

Previous extension pointReplacement
Subscribe(CancellationToken) overrideConnect(SubscriptionRun run) override
Unsubscribe(CancellationToken) and Finalize(CancellationToken) overridesRegister resource cleanup with run.OnDisconnect(...) when acquiring each resource
Stopping.Tokenrun.Token
Dropped(reason, exception)run.Fail(reason, exception)
Sequence++run.NextSequence()
GetCheckpoint(cancellationToken)GetCheckpoint(run)
HandleInternal(context)HandleInternal(run, context)

The checkpoint methods apply to EventSubscriptionWithCheckpoint<TOptions>. Capture the run in transport callbacks so late messages and failures refer to the connection attempt that produced them. Pass its token to transport I/O and report message-loop failures with run.Fail(...).

Resubscribe, IsDropped, and DropReason.Stopped are removed. IsRunning is now read-only. Normal shutdown is cancellation; use ServerError or SubscriptionError when reporting failures. The public IMessageSubscription.Subscribe and Unsubscribe entry points remain available.

Cleanup runs in reverse registration order and completes before the replacement connection starts. RetryDelay defaults to two seconds. TeardownTimeout defaults to five seconds and signals cleanup to finish promptly; it is not a hard deadline that abandons cleanup. See subscription lifecycle. (#571)

Custom relational providers must also replace the GetEndOfStream and GetEndOfAll SQL properties with PrepareEndOfStreamCommand(TConnection connection). Return a command that measures the subscribed stream for a stream subscription, or the global log for an all-stream subscription. (#551, #587)

RabbitMQ client 7 and asynchronous failure handlers

Section titled “RabbitMQ client 7 and asynchronous failure handlers”

Eventuous.RabbitMq upgrades RabbitMQ.Client from 6.8.1 to 7.2.1. Custom RabbitMqSubscription.HandleEventProcessingFailure delegates now receive IChannel instead of IModel and return ValueTask instead of void. Use the asynchronous channel operations:

// Before
options.FailureHandler = (channel, message, exception) =>
channel.BasicReject(message.DeliveryTag, requeue: true);
// After
options.FailureHandler = (channel, message, exception) =>
channel.BasicRejectAsync(message.DeliveryTag, requeue: true);

The configured failure handler runs even when ThrowOnError is true; the subscription reports the failure and replaces the run afterwards. Choose the requeue or rejection policy explicitly in a custom handler. See RabbitMQ error handling. (#561, #571)

Registering the same handler type twice in one subscription using AddEventHandler<THandler>() now throws ArgumentException during registration. The same rule applies to composition handlers that resolve their inner handler by type. Previously, these registrations could dispatch each event to the same instance twice. Use separate factory or instance registrations when you need multiple handlers of the same type.

Factories now retain one handler per registration, fixing cases where several factories all resolved to the first handler. This changes ownership and container visibility:

  • Factory-created handlers are owned by the subscription and disposed after its consume pipe drains. IAsyncDisposable takes precedence over IDisposable.
  • A factory registration no longer makes its handler available through GetRequiredKeyedService<THandler>(subscriptionId).
  • Type registrations remain container-owned. Instance registrations remain caller-owned. Composition wrappers are not disposed by the subscription.

If a factory returns a shared or container-owned handler, opt out of subscription ownership:

builder.AddEventHandler(
sp => sp.GetRequiredService<SharedHandler>(),
ownsHandler: false
);
builder.AddCompositionEventHandler<SharedHandler, PollyEventHandler>(
sp => sp.GetRequiredService<SharedHandler>(),
handler => new PollyEventHandler(handler, retryPolicy),
ownsInnerHandler: false
);

See handler registration for the overloads and ownership rules. (#577)

Relational gap skipping is disabled by default

Section titled “Relational gap skipping is disabled by default”

SqlSubscriptionOptionsBase.GapSkipTimeoutMs now defaults to null, previously 5000. Global subscriptions using the relational base no longer abandon a missing position simply because five seconds have elapsed. This avoids skipping events from transactions that take longer to commit.

  • GapHandlingTimeoutMs still defaults to null. Where supported, enable provider-specific remediation, such as PostgreSQL tombstones. Remediation takes precedence over timeout-based skipping.
  • GapAgeThresholdMs still defaults to one hour. Gaps become eligible for age-based release while the subscription is running, without requiring a restart.
  • Set GapSkipTimeoutMs = 5000 explicitly only if you require the previous five-second skip policy and accept that a late-committing event can be missed.

See PostgreSQL gap handling. (#588)

The experimental Redis store now treats the start position as inclusive, matching the other event stores. New appends use explicit entry IDs that round-trip through Eventuous stream positions, fixing skipped events at page boundaries.

Existing streams can contain automatically generated Redis IDs with a sequence component greater than nine. These IDs cannot be represented by the position encoding. Reads now fail with NotSupportedException instead of silently skipping affected events; reading from the beginning also fails when it reaches an unrepresentable entry.

Inspect and migrate affected streams before resuming from saved positions. Stop older or external writers that use automatically generated IDs before using resumed reads with the new store. Resumed reads validate preceding stream entries, adding round trips proportional to the prefix length. (#568)

CheckpointCommitHandler.Commit(...) now returns ValueTask<bool> rather than ValueTask. A result of false means the handler stopped without accepting the position; it must not be treated as a commit. A result of true means the position was queued, not that the checkpoint has already been persisted.

The public Eventuous.Tools.TaskRunner and Eventuous.Subscriptions.Channels.ChannelFullException types are removed. Custom transport loops should use the run lifecycle described above. Checkpoint queue saturation now applies backpressure instead of throwing ChannelFullException and losing a position. (#554, #571)

The new Eventuous.Azure.Storage.Blobs package provides BlobStorageProjector<T>. Register event handlers to update JSON state documents in Azure Blob Storage, with one document per stream by default and support for custom blob IDs and naming. The blob container must exist before processing begins.

See Azure Blob Storage for registration, configuration, and projection examples. (#550)

Use ReadStreamToEnd to read a whole stream without buffering it in memory:

await foreach (var evt in eventReader.ReadStreamToEnd(
stream,
StreamReadPosition.Start,
pageSize: 500,
cancellationToken: cancellationToken
)) {
// Process one event at a time.
}

The page size defaults to 500 and must be positive. Set failIfNotFound: false to return an empty sequence for a missing stream. ReadStream uses the same paging but collects all events into an array.

KurrentDBEventStore now yields events as they arrive from the server. Other stores can buffer in proportion to the requested count, so use bounded reads or ReadStreamToEnd rather than int.MaxValue. The reader contract requires the requested count unless the stream ends, and an empty sequence for reads past the end of an existing stream. Tiered and Redis readers now follow that contract.

IEventReader already returned IAsyncEnumerable<StreamEvent> in 0.16.4; this release adds the paged helper and corrects the implementations. See reading events. (#568)

The KurrentDB Bookings sample now uses Aspire to host its applications and dependencies, including Azure Blob Storage projections. See the sample README. (#574)

Check direct package references in applications that also use these dependencies. In addition to RabbitMQ, this release changes these major dependency versions:

Dependency0.16.40.17
Microsoft.Extensions.* / System.Diagnostics.DiagnosticSource on .NET 8 and 99.0.1010.0.10
Microsoft.Data.SqlClient6.0.27.0.2
Microsoft.Data.Sqlite9.0.610.0.10
StackExchange.Redis2.10.13.1.0
System.Reactive6.0.17.0.0

The library continues to target .NET 8, 9, and 10; these are package dependency changes. (#561, #591)

  • PostgreSQL concurrent appends: racing writes now fail explicitly instead of silently dropping events. The fix changes the append_events and check_stream database functions. Apply the 0.17 versions through your schema deployment process, or run the Eventuous schema initializer with initializeDatabase: true. Updating the package alone does not replace functions in a database whose schema is managed separately. (#582)
  • Subscription recovery and shutdown: drops, handler failures, and transport-loop failures reach the supervisor; stale callbacks cannot disrupt a replacement run. Teardown completes the final checkpoint flush, and concurrent disposal no longer crashes host shutdown. (#562, #563, #571)
  • Filtered KurrentDB checkpoints: server checkpoint markers and the caught-up transition advance checkpoints across unmatched events without advancing past pending matched events. Resolved links use the original stream position. (#554, #560)
  • Checkpoint diagnostics: commit diagnostics run on the worker that owns the position set, removing a race that could stall subscriptions. (#552)
  • Relational subscription metrics: stream subscriptions measure their own stream’s end position rather than the global log. (#551, #587)
  • Tracing: subscription processing links to the restored event context rather than making it the parent. Trace views can therefore show separate processing traces connected by links. Missing or invalid contexts are ignored, and configured sampling is respected regardless of initialization order. (#570, #591)
  • Analyzer diagnostics: EVTC001 no longer reports false positives for object payloads, and type lookup is more robust. (#581)
  • KurrentDB client: the dependency is updated to 1.4.1. (#573)

The full change comparison includes the supporting regression tests, dependency updates, and CI changes.