Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,18 @@
<PropertyGroup Label="Testcontainers version">
<TestcontainersVersion>4.13.0</TestcontainersVersion>
</PropertyGroup>
<PropertyGroup Label="Aspire version, keep in sync with the Aspire.AppHost.Sdk version in the sample AppHost">
<AspireVersion>13.4.6</AspireVersion>
</PropertyGroup>
<PropertyGroup>
<NpgsqlVersion>10.0.3</NpgsqlVersion>
<TUnitVersion>1.63.0</TUnitVersion>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.Azure.Storage" Version="$(AspireVersion)" />
<PackageVersion Include="Aspire.Hosting.MongoDB" Version="$(AspireVersion)" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="Scalar.Aspire" Version="0.11.0" />
<PackageVersion Include="FluentValidation" Version="12.0.0" />
<PackageVersion Include="IsExternalInit" Version="1.0.3" />
<PackageVersion Include="KurrentDB.Client" Version="1.4.1" />
Expand Down
1 change: 1 addition & 0 deletions Eventuous.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
</Folder>
<Folder Name="/Samples/" />
<Folder Name="/Samples/KurrentDB/">
<Project Path="samples/kurrentdb/Bookings.AppHost/Bookings.AppHost.csproj" />
<Project Path="samples/kurrentdb/Bookings.Domain/Bookings.Domain.csproj" />
<Project Path="samples/kurrentdb/Bookings.Payments/Bookings.Payments.csproj" />
<Project Path="samples/kurrentdb/Bookings/Bookings.csproj" />
Expand Down
63 changes: 63 additions & 0 deletions samples/kurrentdb/Bookings.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
@@ -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<Projects.Bookings>("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<Projects.Bookings_Payments>("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();
19 changes: 19 additions & 0 deletions samples/kurrentdb/Bookings.AppHost/Bookings.AppHost.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Aspire.AppHost.Sdk/13.4.6">

<PropertyGroup>
<OutputType>Exe</OutputType>
<UserSecretsId>830ae8e6-48ba-4d84-b460-a2922dc3ec63</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Bookings\Bookings.csproj"/>
<ProjectReference Include="..\Bookings.Payments\Bookings.Payments.csproj"/>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.Azure.Storage"/>
<PackageReference Include="Aspire.Hosting.MongoDB"/>
<PackageReference Include="Scalar.Aspire"/>
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -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<PaymentState> service) : CommandHttpApiBase<PaymentState>(service) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ public CommandService(IEventStore store) : base(store) {
}
}

// [AggregateCommands(typeof(Payment))]
public static class PaymentCommands {
[HttpCommand]
[HttpCommand<PaymentState>]
public record RecordPayment(
string PaymentId,
string BookingId,
Expand Down
2 changes: 2 additions & 0 deletions samples/kurrentdb/Bookings.Payments/Bookings.Payments.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="$(SrcRoot)\Core\src\Eventuous.Serialization.Json.Dynamic\Eventuous.Serialization.Json.Dynamic.csproj"/>
<ProjectReference Include="$(SrcRoot)\Extensions\src\Eventuous.Extensions.AspNetCore\Eventuous.Extensions.AspNetCore.csproj"/>
<ProjectReference Include="$(SrcRoot)\Extensions\src\Eventuous.Extensions.DependencyInjection\Eventuous.Extensions.DependencyInjection.csproj"/>
<ProjectReference Include="$(SrcRoot)\KurrentDB\src\Eventuous.KurrentDB\Eventuous.KurrentDB.csproj" />
<ProjectReference Include="$(SrcRoot)\Mongo\src\Eventuous.Projections.MongoDB\Eventuous.Projections.MongoDB.csproj"/>
<ProjectReference Include="$(SrcRoot)\Diagnostics\src\Eventuous.Diagnostics.OpenTelemetry\Eventuous.Diagnostics.OpenTelemetry.csproj"/>
<ProjectReference Include="$(SrcRoot)\Gateway\src\Eventuous.Gateway\Eventuous.Gateway.csproj"/>
<ProjectReference Include="$(SrcRoot)\Extensions\gen\Eventuous.Extensions.AspNetCore.Generators\Eventuous.Extensions.AspNetCore.Generators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<ProjectReference Include="$(SrcRoot)\Core\gen\Eventuous.Subscriptions.Generators\Eventuous.Subscriptions.Generators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<ProjectReference Include="$(SrcRoot)\Core\gen\Eventuous.Shared.Generators\Eventuous.Shared.Generators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<ProjectReference Include="$(SrcRoot)\Experimental\src\Eventuous.Spyglass\Eventuous.Spyglass.csproj"/>
Expand Down
7 changes: 5 additions & 2 deletions samples/kurrentdb/Bookings.Payments/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<PaymentState>();

app.UseSwaggerUI();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/openapi/v1.json", "Payments v1"));

app.MapEventuousSpyglass();
app.MapHealthChecks("/health");

app.Run();
40 changes: 28 additions & 12 deletions samples/kurrentdb/Bookings.Payments/Registrations.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,6 +18,8 @@
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<KurrentDBEventStore>();
services.AddCommandService<CommandService, PaymentState>();
Expand All @@ -31,24 +35,36 @@
}

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();

Check warning on line 66 in samples/kurrentdb/Bookings.Payments/Registrations.cs

View workflow job for this annotation

GitHub Actions / Build and test core (10.0)

'ZipkinExporterHelperExtensions.AddZipkinExporter(TracerProviderBuilder)' is obsolete: 'The Zipkin exporter is obsolete and will be removed in a future release. Consider using the OpenTelemetry.Exporter.OpenTelemetryProtocol NuGet package instead. See https://opentelemetry.io/blog/2025/deprecating-zipkin-exporters/ for more information.'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Known and deferred: the Zipkin exporter is the sample's pre-existing standalone tracing fallback (the Bookings service has the identical call, and the compose stack ships Zipkin). Under Aspire both services already export via OTLP. Replacing the standalone observability stack is out of scope for this PR.

}
);
}
}
Expand Down
23 changes: 22 additions & 1 deletion samples/kurrentdb/Bookings/Application/BookingsQueryService.cs
Original file line number Diff line number Diff line change
@@ -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<MyBookings?> GetUserBookings(string userId) => await database.LoadDocument<MyBookings>(userId);

/// <summary>
/// Reads the booking state projected to Azure Blob Storage. The blob name follows the
/// projector's default naming convention: {id}/{state type name}.json.
/// </summary>
public async Task<BookingView?> 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<BookingView>(blobOptions.JsonOptions);
} catch (RequestFailedException e) when (e.Status == 404) {
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Azure.Storage.Blobs;
using Eventuous.Azure.Storage.Blobs;
using static Bookings.Domain.Bookings.BookingEvents;

namespace Bookings.Application.Queries;

/// <summary>
/// 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.
/// </summary>
public class BookingStateBlobProjection : BlobStorageProjector<BookingView> {
public const string ContainerName = "bookings";

public BookingStateBlobProjection(BlobServiceClient client, BlobStorageProjectorOptions options)
: base(client, ContainerName, options) {
On<V1.RoomBooked>((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<V1.PaymentRecorded>((view, evt) => view with { Outstanding = evt.Outstanding });

On<V1.BookingFullyPaid>((view, _) => view with { Paid = true });
}
}
19 changes: 19 additions & 0 deletions samples/kurrentdb/Bookings/Application/Queries/BookingView.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using NodaTime;

// ReSharper disable UnusedAutoPropertyAccessor.Global
namespace Bookings.Application.Queries;

/// <summary>
/// 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.
/// </summary>
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; }
}
1 change: 1 addition & 0 deletions samples/kurrentdb/Bookings/Bookings.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<PackageReference Include="Swashbuckle.AspNetCore"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="$(SrcRoot)\Azure\src\Eventuous.Azure.Storage.Blobs\Eventuous.Azure.Storage.Blobs.csproj"/>
<ProjectReference Include="$(SrcRoot)\Diagnostics\src\Eventuous.Diagnostics.Logging\Eventuous.Diagnostics.Logging.csproj"/>
<ProjectReference Include="$(SrcRoot)\KurrentDB\src\Eventuous.KurrentDB\Eventuous.KurrentDB.csproj" />
<ProjectReference Include="$(SrcRoot)\Experimental\src\Eventuous.Spyglass\Eventuous.Spyglass.csproj"/>
Expand Down
Loading
Loading