From 960ac17803910bacf45b1e9bf1787be59106f92f Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Wed, 19 Aug 2026 20:27:02 +0200 Subject: [PATCH 1/2] feat(samples): host the KurrentDB Bookings sample in Aspire with blob projections Supersedes #556: instead of adding a third Bookings clone under samples/azure, the existing KurrentDB sample gains an Aspire AppHost and the Azure pieces worth keeping. - Add Bookings.AppHost orchestrating KurrentDB, MongoDB 7.0, the Azurite blob emulator, both services, and a Scalar API reference; service telemetry flows to the Aspire dashboard via OTLP - Add BookingStateBlobProjection: booking state projected to Azure Blob Storage from the same all-stream subscription as the Mongo projections, with ByGlobalPosition idempotency and race retries, exposed via GET /bookings/{id}/view and readable next to the event-store fold - Fix latent Payments sample breakage: set the default event serializer (required since #524), reference the AspNetCore command mapping generator, and bind RecordPayment with HttpCommand so MapDiscoveredCommands actually maps the route - Add health endpoints and Scalar-compatible OpenAPI document routes to both services; keep fixed ports for standalone runs while letting Aspire assign URLs - Add kurrentdb and azurite services to docker-compose for standalone runs; add a sample README covering both run modes - Pin the sample (and the Spyglass tests that use its apps as fixtures) to net10.0, since Aspire cannot run multi-targeted projects; Spyglass tests move to a net10-only CI step - Verified end to end under Aspire: book, pay via the Payments service, integration events through the gateway, and all three read paths agree Co-authored-by: Quezlatch Co-Authored-By: Claude Fable 5 --- .github/workflows/pull-request.yml | 6 +- CLAUDE.md | 2 +- Directory.Packages.props | 6 ++ Eventuous.slnx | 1 + samples/kurrentdb/Bookings.AppHost/AppHost.cs | 63 +++++++++++++++++++ .../Bookings.AppHost/Bookings.AppHost.csproj | 19 ++++++ .../Application/CommandApi.cs | 2 +- .../Application/CommandService.cs | 3 +- .../Bookings.Payments.csproj | 2 + .../kurrentdb/Bookings.Payments/Program.cs | 7 ++- .../Bookings.Payments/Registrations.cs | 40 ++++++++---- .../Application/BookingsQueryService.cs | 23 ++++++- .../Queries/BookingStateBlobProjection.cs | 33 ++++++++++ .../Application/Queries/BookingView.cs | 19 ++++++ samples/kurrentdb/Bookings/Bookings.csproj | 1 + samples/kurrentdb/Bookings/Program.cs | 29 ++++++++- samples/kurrentdb/Bookings/Registrations.cs | 17 ++++- samples/kurrentdb/Bookings/appsettings.json | 3 + samples/kurrentdb/Directory.Build.props | 8 +++ samples/kurrentdb/README.md | 49 +++++++++++++++ samples/kurrentdb/aspire.config.json | 5 ++ samples/kurrentdb/docker-compose.yml | 30 +++++---- .../Eventuous.Tests.Spyglass.csproj | 2 + 23 files changed, 329 insertions(+), 41 deletions(-) create mode 100644 samples/kurrentdb/Bookings.AppHost/AppHost.cs create mode 100644 samples/kurrentdb/Bookings.AppHost/Bookings.AppHost.csproj create mode 100644 samples/kurrentdb/Bookings/Application/Queries/BookingStateBlobProjection.cs create mode 100644 samples/kurrentdb/Bookings/Application/Queries/BookingView.cs create mode 100644 samples/kurrentdb/Directory.Build.props create mode 100644 samples/kurrentdb/README.md create mode 100644 samples/kurrentdb/aspire.config.json diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 3aa18281a..86eac69f3 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -55,7 +55,6 @@ jobs: src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/Eventuous.Tests.Extensions.AspNetCore.csproj src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore.Analyzers/Eventuous.Tests.Extensions.AspNetCore.Analyzers.csproj src/Gateway/test/Eventuous.Tests.Gateway/Eventuous.Tests.Gateway.csproj - src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj src/Sqlite/test/Eventuous.Tests.Sqlite/Eventuous.Tests.Sqlite.csproj src/SignalR/test/Eventuous.Tests.SignalR/Eventuous.Tests.SignalR.csproj @@ -65,6 +64,11 @@ jobs: dotnet test "$proj" -c "Debug CI" -f net${{ matrix.dotnet-version }} echo "::endgroup::" done + - + # Uses the sample apps as fixtures, which are pinned to net10.0 for the Aspire AppHost + name: Run Spyglass tests + if: matrix.dotnet-version == '10.0' + run: dotnet test src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj -c "Debug CI" -f net10.0 - name: Upload Test Results if: always() diff --git a/CLAUDE.md b/CLAUDE.md index 0ba35e6d7..621f0260c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,7 +92,7 @@ src/Diagnostics/ OpenTelemetry, Logging src/Gateway/ Event gateway src/Testing/ Test utilities test/ Shared test helpers (Eventuous.Sut.App, Eventuous.Sut.Domain, Eventuous.TestHelpers, Eventuous.TestHelpers.TUnit) -samples/ Sample apps (esdb, postgres, kurrentdb, banking) +samples/ Sample apps (kurrentdb with Aspire AppHost, postgres) ``` ## External Repos to Update diff --git a/Directory.Packages.props b/Directory.Packages.props index e6e4e6d06..c941a2843 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,12 +17,18 @@ 4.13.0 + + 13.4.6 + 10.0.3 1.63.0 + + + diff --git a/Eventuous.slnx b/Eventuous.slnx index 546b2b5e8..f028ec763 100644 --- a/Eventuous.slnx +++ b/Eventuous.slnx @@ -156,6 +156,7 @@ + diff --git a/samples/kurrentdb/Bookings.AppHost/AppHost.cs b/samples/kurrentdb/Bookings.AppHost/AppHost.cs new file mode 100644 index 000000000..1c61f1b9a --- /dev/null +++ b/samples/kurrentdb/Bookings.AppHost/AppHost.cs @@ -0,0 +1,63 @@ +using System.Runtime.InteropServices; +using Scalar.Aspire; + +var builder = DistributedApplication.CreateBuilder(args); + +// Same image as the repository's KurrentDB test fixtures +var kurrentImage = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 + ? "kurrentplatform/kurrentdb:26.1.1-experimental-arm64-10.0-noble" + : "kurrentplatform/kurrentdb:26.1.1"; +var imageParts = kurrentImage.Split(':'); + +var kurrentdb = builder.AddContainer("kurrentdb", imageParts[0], imageParts[1]) + .WithArgs("--insecure", "--run-projections=All", "--enable-atom-pub-over-http") + .WithHttpEndpoint(port: 2113, targetPort: 2113, name: "http"); + +var kurrentdbEndpoint = kurrentdb.GetEndpoint("http"); + +var mongoUser = builder.AddParameter("mongo-user", "mongoadmin"); +var mongoPassword = builder.AddParameter("mongo-password", "secret", secret: true); + +var mongo = builder.AddMongoDB("mongo", userName: mongoUser, password: mongoPassword) + // MongoDB 8.3 refuses to start on Linux kernel 6.19+ (SERVER-121912) + .WithImageTag("7.0"); + +var storage = builder.AddAzureStorage("storage").RunAsEmulator(); +var blobs = storage.AddBlobs("blobs"); +storage.AddBlobContainer("bookings-container", blobContainerName: "bookings"); + +var bookings = builder.AddProject("bookings") + .WithHttpEndpoint() + .WithHttpHealthCheck("/health") + .WithReference(blobs) + .WithEnvironment(ctx => { + ctx.EnvironmentVariables["KurrentDB__ConnectionString"] = ReferenceExpression.Create( + $"kurrentdb://{kurrentdbEndpoint.Property(EndpointProperty.Host)}:{kurrentdbEndpoint.Property(EndpointProperty.Port)}?tls=false" + ); + ctx.EnvironmentVariables["Mongo__ConnectionString"] = mongo.Resource.ConnectionStringExpression; + } + ) + .WaitFor(kurrentdb) + .WaitFor(mongo) + .WaitFor(blobs); + +var payments = builder.AddProject("payments") + .WithHttpEndpoint() + .WithHttpHealthCheck("/health") + .WithEnvironment(ctx => { + ctx.EnvironmentVariables["KurrentDB__ConnectionString"] = ReferenceExpression.Create( + $"kurrentdb://{kurrentdbEndpoint.Property(EndpointProperty.Host)}:{kurrentdbEndpoint.Property(EndpointProperty.Port)}?tls=false" + ); + ctx.EnvironmentVariables["Mongo__ConnectionString"] = mongo.Resource.ConnectionStringExpression; + } + ) + .WaitFor(kurrentdb) + .WaitFor(mongo); + +builder.AddScalarApiReference() + .WithApiReference(bookings) + .WithApiReference(payments) + .WaitFor(bookings) + .WaitFor(payments); + +builder.Build().Run(); diff --git a/samples/kurrentdb/Bookings.AppHost/Bookings.AppHost.csproj b/samples/kurrentdb/Bookings.AppHost/Bookings.AppHost.csproj new file mode 100644 index 000000000..f8c3433e9 --- /dev/null +++ b/samples/kurrentdb/Bookings.AppHost/Bookings.AppHost.csproj @@ -0,0 +1,19 @@ + + + + Exe + 830ae8e6-48ba-4d84-b460-a2922dc3ec63 + + + + + + + + + + + + + + diff --git a/samples/kurrentdb/Bookings.Payments/Application/CommandApi.cs b/samples/kurrentdb/Bookings.Payments/Application/CommandApi.cs index ac66176c0..44b231fc2 100644 --- a/samples/kurrentdb/Bookings.Payments/Application/CommandApi.cs +++ b/samples/kurrentdb/Bookings.Payments/Application/CommandApi.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc; using static Bookings.Payments.Application.PaymentCommands; -namespace Bookings.Payments.Application; +namespace Bookings.Payments.Application; [Route("payment")] public class CommandApi(ICommandService service) : CommandHttpApiBase(service) { diff --git a/samples/kurrentdb/Bookings.Payments/Application/CommandService.cs b/samples/kurrentdb/Bookings.Payments/Application/CommandService.cs index 4e1c94066..a92dadbc6 100644 --- a/samples/kurrentdb/Bookings.Payments/Application/CommandService.cs +++ b/samples/kurrentdb/Bookings.Payments/Application/CommandService.cs @@ -14,9 +14,8 @@ public CommandService(IEventStore store) : base(store) { } } -// [AggregateCommands(typeof(Payment))] public static class PaymentCommands { - [HttpCommand] + [HttpCommand] public record RecordPayment( string PaymentId, string BookingId, diff --git a/samples/kurrentdb/Bookings.Payments/Bookings.Payments.csproj b/samples/kurrentdb/Bookings.Payments/Bookings.Payments.csproj index ff30478df..131bc5f68 100644 --- a/samples/kurrentdb/Bookings.Payments/Bookings.Payments.csproj +++ b/samples/kurrentdb/Bookings.Payments/Bookings.Payments.csproj @@ -24,12 +24,14 @@ + + diff --git a/samples/kurrentdb/Bookings.Payments/Program.cs b/samples/kurrentdb/Bookings.Payments/Program.cs index fd9fe2272..8ca0222ee 100644 --- a/samples/kurrentdb/Bookings.Payments/Program.cs +++ b/samples/kurrentdb/Bookings.Payments/Program.cs @@ -10,6 +10,7 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddHealthChecks(); // OpenTelemetry instrumentation must be added before adding Eventuous services builder.Services.AddTelemetry(); @@ -20,14 +21,16 @@ var app = builder.Build(); app.Services.AddEventuousLogs(); -app.UseSwagger(); +// Serve the OpenAPI document where the Scalar API reference in the Aspire AppHost expects it +app.UseSwagger(c => c.RouteTemplate = "openapi/{documentName}.json"); app.UseOpenTelemetryPrometheusScrapingEndpoint(); // Here we discover commands by their annotations app.MapDiscoveredCommands(); -app.UseSwaggerUI(); +app.UseSwaggerUI(c => c.SwaggerEndpoint("/openapi/v1.json", "Payments v1")); app.MapEventuousSpyglass(); +app.MapHealthChecks("/health"); app.Run(); \ No newline at end of file diff --git a/samples/kurrentdb/Bookings.Payments/Registrations.cs b/samples/kurrentdb/Bookings.Payments/Registrations.cs index d1d599bdd..85f1db76f 100644 --- a/samples/kurrentdb/Bookings.Payments/Registrations.cs +++ b/samples/kurrentdb/Bookings.Payments/Registrations.cs @@ -1,7 +1,9 @@ +using System.Text.Json; using Bookings.Payments.Application; using Bookings.Payments.Domain; using Bookings.Payments.Infrastructure; using Bookings.Payments.Integration; +using Eventuous; using Eventuous.Diagnostics.OpenTelemetry; using Eventuous.KurrentDB; using Eventuous.KurrentDB.Producers; @@ -16,6 +18,8 @@ namespace Bookings.Payments; public static class Registrations { extension(IServiceCollection services) { public void AddServices(IConfiguration configuration) { + EventSerializer.SetDefault(new DefaultEventSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web))); + services.AddKurrentDBClient(configuration["KurrentDB:ConnectionString"]!); services.AddEventStore(); services.AddCommandService(); @@ -31,24 +35,36 @@ public void AddServices(IConfiguration configuration) { } public void AddTelemetry() { + var otelEnabled = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") != null; + services.AddOpenTelemetry() .WithMetrics( - builder => builder - .AddAspNetCoreInstrumentation() - .AddEventuous() - .AddEventuousSubscriptions() - .AddPrometheusExporter() + builder => { + builder + .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("payments")) + .AddAspNetCoreInstrumentation() + .AddEventuous() + .AddEventuousSubscriptions() + .AddPrometheusExporter(); + if (otelEnabled) builder.AddOtlpExporter(); + } ); services.AddOpenTelemetry() .WithTracing( - builder => builder - .AddAspNetCoreInstrumentation() - .AddGrpcClientInstrumentation() - .AddEventuousTracing() - .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("payments")) - .SetSampler(new AlwaysOnSampler()) - .AddZipkinExporter() + builder => { + builder + .AddAspNetCoreInstrumentation() + .AddGrpcClientInstrumentation() + .AddEventuousTracing() + .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("payments")) + .SetSampler(new AlwaysOnSampler()); + + if (otelEnabled) + builder.AddOtlpExporter(); + else + builder.AddZipkinExporter(); + } ); } } diff --git a/samples/kurrentdb/Bookings/Application/BookingsQueryService.cs b/samples/kurrentdb/Bookings/Application/BookingsQueryService.cs index f3c86be71..d35fe874b 100644 --- a/samples/kurrentdb/Bookings/Application/BookingsQueryService.cs +++ b/samples/kurrentdb/Bookings/Application/BookingsQueryService.cs @@ -1,9 +1,30 @@ +using Azure; +using Azure.Storage.Blobs; using Bookings.Application.Queries; +using Eventuous.Azure.Storage.Blobs; using Eventuous.Projections.MongoDB.Tools; using MongoDB.Driver; namespace Bookings.Application; -public class BookingsQueryService(IMongoDatabase database) { +public class BookingsQueryService(IMongoDatabase database, BlobServiceClient blobClient, BlobStorageProjectorOptions blobOptions) { public async Task GetUserBookings(string userId) => await database.LoadDocument(userId); + + /// + /// Reads the booking state projected to Azure Blob Storage. The blob name follows the + /// projector's default naming convention: {id}/{state type name}.json. + /// + public async Task GetBooking(string bookingId, CancellationToken cancellationToken) { + var blob = blobClient + .GetBlobContainerClient(BookingStateBlobProjection.ContainerName) + .GetBlobClient($"{bookingId}/{nameof(BookingView)}.json"); + + try { + var content = await blob.DownloadContentAsync(cancellationToken); + + return content.Value.Content.ToObjectFromJson(blobOptions.JsonOptions); + } catch (RequestFailedException e) when (e.Status == 404) { + return null; + } + } } diff --git a/samples/kurrentdb/Bookings/Application/Queries/BookingStateBlobProjection.cs b/samples/kurrentdb/Bookings/Application/Queries/BookingStateBlobProjection.cs new file mode 100644 index 000000000..b9e95f20b --- /dev/null +++ b/samples/kurrentdb/Bookings/Application/Queries/BookingStateBlobProjection.cs @@ -0,0 +1,33 @@ +using Azure.Storage.Blobs; +using Eventuous.Azure.Storage.Blobs; +using static Bookings.Domain.Bookings.BookingEvents; + +namespace Bookings.Application.Queries; + +/// +/// Projects the booking state to Azure Blob Storage, in parallel with the MongoDB projections +/// registered on the same subscription. Each booking stream becomes one JSON blob. +/// The all-stream subscription provides real global positions, so the projector can use +/// ByGlobalPosition idempotency to skip replayed events. +/// +public class BookingStateBlobProjection : BlobStorageProjector { + public const string ContainerName = "bookings"; + + public BookingStateBlobProjection(BlobServiceClient client, BlobStorageProjectorOptions options) + : base(client, ContainerName, options) { + On((ctx, view) => view with { + Id = ctx.Stream.GetId(), + GuestId = ctx.Message.GuestId, + RoomId = ctx.Message.RoomId, + CheckInDate = ctx.Message.CheckInDate, + CheckOutDate = ctx.Message.CheckOutDate, + BookingPrice = ctx.Message.BookingPrice, + Outstanding = ctx.Message.OutstandingAmount + } + ); + + On((view, evt) => view with { Outstanding = evt.Outstanding }); + + On((view, _) => view with { Paid = true }); + } +} diff --git a/samples/kurrentdb/Bookings/Application/Queries/BookingView.cs b/samples/kurrentdb/Bookings/Application/Queries/BookingView.cs new file mode 100644 index 000000000..68f9ad591 --- /dev/null +++ b/samples/kurrentdb/Bookings/Application/Queries/BookingView.cs @@ -0,0 +1,19 @@ +using NodaTime; + +// ReSharper disable UnusedAutoPropertyAccessor.Global +namespace Bookings.Application.Queries; + +/// +/// Booking state projected to Azure Blob Storage, one blob per booking stream. +/// Requires a parameterless constructor, as the blob projector creates a new instance for new blobs. +/// +public record BookingView { + public string Id { get; init; } = ""; + public string GuestId { get; init; } = ""; + public string RoomId { get; init; } = ""; + public LocalDate CheckInDate { get; init; } + public LocalDate CheckOutDate { get; init; } + public float BookingPrice { get; init; } + public float Outstanding { get; init; } + public bool Paid { get; init; } +} diff --git a/samples/kurrentdb/Bookings/Bookings.csproj b/samples/kurrentdb/Bookings/Bookings.csproj index 2d46cb2c6..f26277599 100644 --- a/samples/kurrentdb/Bookings/Bookings.csproj +++ b/samples/kurrentdb/Bookings/Bookings.csproj @@ -24,6 +24,7 @@ + diff --git a/samples/kurrentdb/Bookings/Program.cs b/samples/kurrentdb/Bookings/Program.cs index 0060b1549..d3d28cf1a 100644 --- a/samples/kurrentdb/Bookings/Program.cs +++ b/samples/kurrentdb/Bookings/Program.cs @@ -1,6 +1,8 @@ using System.Text.Json.Serialization; +using Azure.Storage.Blobs; using Bookings; using Bookings.Application; +using Bookings.Application.Queries; using Bookings.Domain.Bookings; using Eventuous; using Eventuous.Diagnostics.Logging; @@ -29,6 +31,7 @@ builder.Services.AddControllers().AddJsonOptions(cfg => cfg.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb)); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddHealthChecks(); builder.Services.AddTelemetry(); builder.Services.AddEventuous(builder.Configuration); builder.Services.Configure(options => options.SerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb)); @@ -36,10 +39,13 @@ var app = builder.Build(); app.UseSerilogRequestLogging(); -app.UseSwagger().UseSwaggerUI(); +// Serve the OpenAPI document where the Scalar API reference in the Aspire AppHost expects it +app.UseSwagger(c => c.RouteTemplate = "openapi/{documentName}.json"); +app.UseSwaggerUI(c => c.SwaggerEndpoint("/openapi/v1.json", "Bookings v1")); app.MapControllers(); app.UseOpenTelemetryPrometheusScrapingEndpoint(); app.MapEventuousSpyglass(); +app.MapHealthChecks("/health"); app.MapGet( "/bookings/my/{userId}", @@ -50,11 +56,30 @@ } ); +// Unlike GET /bookings/{id}, which folds the state from the event stream, this endpoint +// serves the read model projected to Azure Blob Storage +app.MapGet( + "/bookings/{bookingId}/view", + async (string bookingId, BookingsQueryService queryService, CancellationToken cancellationToken) => { + var booking = await queryService.GetBooking(bookingId, cancellationToken); + + return booking == null ? Results.NotFound() : Results.Ok(booking); + } +); + +// The blob projector doesn't create the container, and outside Aspire nothing else does +app.Services.GetRequiredService() + .GetBlobContainerClient(BookingStateBlobProjection.ContainerName) + .CreateIfNotExists(); + var factory = app.Services.GetRequiredService(); var listener = new LoggingEventListener(factory, "OpenTelemetry"); +// The Aspire AppHost assigns URLs via ASPNETCORE_URLS; keep the fixed port for standalone runs +if (Environment.GetEnvironmentVariable("ASPNETCORE_URLS") == null) app.Urls.Add("http://*:5051"); + try { - app.Run("http://*:5051"); + app.Run(); return 0; } catch (Exception e) { diff --git a/samples/kurrentdb/Bookings/Registrations.cs b/samples/kurrentdb/Bookings/Registrations.cs index 66e58b300..121f3b2b4 100644 --- a/samples/kurrentdb/Bookings/Registrations.cs +++ b/samples/kurrentdb/Bookings/Registrations.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Azure.Storage.Blobs; using Bookings.Application; using Bookings.Application.Queries; using Bookings.Domain; @@ -6,6 +7,7 @@ using Bookings.Infrastructure; using Bookings.Integration; using Eventuous; +using Eventuous.Azure.Storage.Blobs; using Eventuous.Diagnostics.OpenTelemetry; using Eventuous.KurrentDB; using Eventuous.KurrentDB.Subscriptions; @@ -23,9 +25,8 @@ namespace Bookings; public static class Registrations { extension(IServiceCollection services) { public void AddEventuous(IConfiguration configuration) { - EventSerializer.SetDefault( - new DefaultEventSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web).ConfigureForNodaTime(DateTimeZoneProviders.Tzdb)) - ); + var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web).ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); + EventSerializer.SetDefault(new DefaultEventSerializer(jsonOptions)); services.AddKurrentDBClient(configuration["KurrentDB:ConnectionString"]!); services.AddEventStore(); @@ -36,12 +37,22 @@ public void AddEventuous(IConfiguration configuration) { services.AddSingleton(Mongo.ConfigureMongo(configuration)); + services.AddSingleton(new BlobServiceClient(configuration.GetConnectionString("blobs"))); + services.AddSingleton( + new BlobStorageProjectorOptions { + JsonOptions = jsonOptions, + RaceRetries = 3, + IdempotencyMode = IdempotencyMode.ByGlobalPosition + } + ); + services.AddSubscription( "BookingsProjections", builder => builder .UseCheckpointStore() .AddEventHandler() .AddEventHandler() + .AddEventHandler() .WithPartitioningByStream(2) ); services.AddSingleton(); diff --git a/samples/kurrentdb/Bookings/appsettings.json b/samples/kurrentdb/Bookings/appsettings.json index 70f8e4352..9d8634280 100644 --- a/samples/kurrentdb/Bookings/appsettings.json +++ b/samples/kurrentdb/Bookings/appsettings.json @@ -1,4 +1,7 @@ { + "ConnectionStrings": { + "blobs": "UseDevelopmentStorage=true" + }, "Mongo": { "ConnectionString": "mongodb://localhost:27017", "User": "mongoadmin", diff --git a/samples/kurrentdb/Directory.Build.props b/samples/kurrentdb/Directory.Build.props new file mode 100644 index 000000000..b128b3648 --- /dev/null +++ b/samples/kurrentdb/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + net10.0 + net10.0 + + diff --git a/samples/kurrentdb/README.md b/samples/kurrentdb/README.md new file mode 100644 index 000000000..3a125fa3b --- /dev/null +++ b/samples/kurrentdb/README.md @@ -0,0 +1,49 @@ +# Bookings sample (KurrentDB) + +A two-service hotel booking application demonstrating Eventuous with KurrentDB as the event store: + +- **Bookings** — commands and queries for room bookings. Projects booking state to **MongoDB** + (`BookingStateProjection`, `MyBookingsProjection`) and to **Azure Blob Storage** + (`BookingStateBlobProjection`) from the same all-stream subscription, showing multiple projection + targets side by side. The blob projection uses `ByGlobalPosition` idempotency and race retries. +- **Bookings.Payments** — records payments and publishes integration events back to KurrentDB + through the Eventuous gateway; the Bookings service consumes them with a persistent subscription. + +## Run with .NET Aspire + +The `Bookings.AppHost` project orchestrates everything: KurrentDB, MongoDB, the Azurite blob +storage emulator, both services, and a Scalar API reference for browsing the APIs. Telemetry from +both services flows to the Aspire dashboard via OTLP. + +```bash +aspire run +# or +dotnet run --project Bookings.AppHost +``` + +Requires the [Aspire CLI](https://learn.microsoft.com/dotnet/aspire/cli/install) (for `aspire run`) +and a container runtime such as Docker Desktop. + +## Run standalone + +Start the infrastructure, then run the services: + +```bash +docker compose up -d +dotnet run --project Bookings # listens on :5051 +dotnet run --project Bookings.Payments +``` + +On Apple Silicon, edit `docker-compose.yml` to use the arm64 KurrentDB image (see the comment there). +The compose file also starts Zipkin, Prometheus, Grafana, and Seq for the observability tooling the +services use when no OTLP endpoint is configured. + +## Try it + +1. Book a room: `POST /booking/book` on the Bookings service. +2. Record a payment on the Payments service — the command endpoints are discovered from + annotations; find them in the service's API reference (Scalar in Aspire, Swagger UI standalone). +3. Read the Mongo projection: `GET /bookings/my/{userId}`. +4. Read the blob projection: `GET /bookings/{bookingId}/view` — served from the `bookings` blob + container, one JSON blob per booking (while `GET /bookings/{bookingId}` folds the state from + the event stream). diff --git a/samples/kurrentdb/aspire.config.json b/samples/kurrentdb/aspire.config.json new file mode 100644 index 000000000..cbce11337 --- /dev/null +++ b/samples/kurrentdb/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "Bookings.AppHost/Bookings.AppHost.csproj" + } +} diff --git a/samples/kurrentdb/docker-compose.yml b/samples/kurrentdb/docker-compose.yml index 4154d40c9..bf2285314 100644 --- a/samples/kurrentdb/docker-compose.yml +++ b/samples/kurrentdb/docker-compose.yml @@ -1,21 +1,19 @@ services: -# esdb: -# container_name: esdemo-esdb -# image: eventstore/eventstore:23.10.2-alpha-arm64v8 -# # image: eventstore/eventstore:latest #23.10.2-buster-slim -# ports: -# - '2113:2113' -# - '1113:1113' -# environment: -# EVENTSTORE_INSECURE: 'true' -# EVENTSTORE_CLUSTER_SIZE: 1 -# EVENTSTORE_EXT_TCP_PORT: 1113 -# EVENTSTORE_HTTP_PORT: 2113 -# EVENTSTORE_ENABLE_EXTERNAL_TCP: 'true' -# EVENTSTORE_RUN_PROJECTIONS: all -# EVENTSTORE_START_STANDARD_PROJECTIONS: "true" -# EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP: "true" + kurrentdb: + container_name: esdemo-kurrentdb + # On Apple Silicon, use kurrentplatform/kurrentdb:26.1.1-experimental-arm64-10.0-noble + image: kurrentplatform/kurrentdb:26.1.1 + command: --insecure --run-projections=All --enable-atom-pub-over-http + ports: + - '2113:2113' + + azurite: + container_name: esdemo-azurite + image: mcr.microsoft.com/azure-storage/azurite + command: azurite-blob --blobHost 0.0.0.0 --skipApiVersionCheck + ports: + - '10000:10000' mongo: container_name: esdemo-mongo diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj b/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj index 4801b4d5e..7fe734f15 100644 --- a/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj +++ b/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj @@ -2,6 +2,8 @@ Exe true + + net10.0 From acd5a8b4a6119fa6d9b1ff2787eef75d5171e555 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 20 Aug 2026 09:34:23 +0200 Subject: [PATCH 2/2] fix(samples): review feedback on the blob projection wiring - Run BookingStateBlobProjection on its own subscription with its own checkpoint, so adding it to a system with existing data replays the stream from the beginning and backfills the blobs instead of resuming from the Mongo projections' position (which would also let a later payment event build an incomplete view from a fresh instance) - Create the blob container with the awaited async call at startup Verified live under Aspire: book + pay end to end, blob view and Mongo view agree. Co-Authored-By: Claude Fable 5 --- .../Application/Queries/BookingStateBlobProjection.cs | 9 +++++---- samples/kurrentdb/Bookings/Program.cs | 4 ++-- samples/kurrentdb/Bookings/Registrations.cs | 11 ++++++++++- samples/kurrentdb/README.md | 6 ++++-- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/samples/kurrentdb/Bookings/Application/Queries/BookingStateBlobProjection.cs b/samples/kurrentdb/Bookings/Application/Queries/BookingStateBlobProjection.cs index b9e95f20b..5b431d97d 100644 --- a/samples/kurrentdb/Bookings/Application/Queries/BookingStateBlobProjection.cs +++ b/samples/kurrentdb/Bookings/Application/Queries/BookingStateBlobProjection.cs @@ -5,10 +5,11 @@ namespace Bookings.Application.Queries; /// -/// Projects the booking state to Azure Blob Storage, in parallel with the MongoDB projections -/// registered on the same subscription. Each booking stream becomes one JSON blob. -/// The all-stream subscription provides real global positions, so the projector can use -/// ByGlobalPosition idempotency to skip replayed events. +/// Projects the booking state to Azure Blob Storage, in parallel with the MongoDB projections. +/// Each booking stream becomes one JSON blob. It runs on its own all-stream subscription with +/// its own checkpoint, so it can replay from the beginning of the stream and backfill the blobs +/// when added to an existing system. The all-stream subscription provides real global positions, +/// so the projector can use ByGlobalPosition idempotency to skip replayed events. /// public class BookingStateBlobProjection : BlobStorageProjector { public const string ContainerName = "bookings"; diff --git a/samples/kurrentdb/Bookings/Program.cs b/samples/kurrentdb/Bookings/Program.cs index d3d28cf1a..e78f6706f 100644 --- a/samples/kurrentdb/Bookings/Program.cs +++ b/samples/kurrentdb/Bookings/Program.cs @@ -68,9 +68,9 @@ ); // The blob projector doesn't create the container, and outside Aspire nothing else does -app.Services.GetRequiredService() +await app.Services.GetRequiredService() .GetBlobContainerClient(BookingStateBlobProjection.ContainerName) - .CreateIfNotExists(); + .CreateIfNotExistsAsync(); var factory = app.Services.GetRequiredService(); var listener = new LoggingEventListener(factory, "OpenTelemetry"); diff --git a/samples/kurrentdb/Bookings/Registrations.cs b/samples/kurrentdb/Bookings/Registrations.cs index 121f3b2b4..50e960d2d 100644 --- a/samples/kurrentdb/Bookings/Registrations.cs +++ b/samples/kurrentdb/Bookings/Registrations.cs @@ -52,9 +52,18 @@ public void AddEventuous(IConfiguration configuration) { .UseCheckpointStore() .AddEventHandler() .AddEventHandler() - .AddEventHandler() .WithPartitioningByStream(2) ); + + // The blob projection runs on its own subscription with its own checkpoint, so when + // it's added to a system with existing data, it replays all events from the beginning + // and backfills the blobs instead of starting from the other projections' position + services.AddSubscription( + "BookingsBlobProjection", + builder => builder + .UseCheckpointStore() + .AddEventHandler() + ); services.AddSingleton(); services.AddSubscription( diff --git a/samples/kurrentdb/README.md b/samples/kurrentdb/README.md index 3a125fa3b..b0d9562f5 100644 --- a/samples/kurrentdb/README.md +++ b/samples/kurrentdb/README.md @@ -4,8 +4,10 @@ A two-service hotel booking application demonstrating Eventuous with KurrentDB a - **Bookings** — commands and queries for room bookings. Projects booking state to **MongoDB** (`BookingStateProjection`, `MyBookingsProjection`) and to **Azure Blob Storage** - (`BookingStateBlobProjection`) from the same all-stream subscription, showing multiple projection - targets side by side. The blob projection uses `ByGlobalPosition` idempotency and race retries. + (`BookingStateBlobProjection`), showing multiple projection targets side by side. The blob + projection uses `ByGlobalPosition` idempotency and race retries, and runs on its own + subscription with its own checkpoint — so when it's added to a system with existing data, + it replays the stream from the beginning and backfills the blobs. - **Bookings.Payments** — records payments and publishes integration events back to KurrentDB through the Eventuous gateway; the Bookings service consumes them with a persistent subscription.