diff --git a/docs/guide/durability/sagas.md b/docs/guide/durability/sagas.md
index 3871ef278..b13a7584f 100644
--- a/docs/guide/durability/sagas.md
+++ b/docs/guide/durability/sagas.md
@@ -308,6 +308,62 @@ And lastly, Wolverine looks for a public member named `Id` like this one:
public record CompleteOrder(string Id);
```
+## Strong-Typed Identifiers
+
+Wolverine supports strong-typed identifiers (record structs or classes wrapping a primitive) as the saga identity.
+The type must expose a `TryParse(string?, out T)` static method so Wolverine can recover the identity from the
+envelope header when a message does not carry the ID directly on its body.
+
+```csharp
+// Strong-typed ID wrapping Guid
+public record struct OrderSagaId(Guid Value)
+{
+ public static OrderSagaId New() => new(Guid.NewGuid());
+
+ public static bool TryParse(string? input, out OrderSagaId result)
+ {
+ if (Guid.TryParse(input, out var guid))
+ {
+ result = new OrderSagaId(guid);
+ return true;
+ }
+ result = default;
+ return false;
+ }
+
+ public override string ToString() => Value.ToString();
+}
+
+public class OrderSaga : Saga
+{
+ public OrderSagaId Id { get; set; }
+
+ public static OrderSaga Start(StartOrder cmd)
+ => new() { Id = cmd.OrderId };
+
+ // Messages that carry the ID on the body work automatically
+ public void Handle(ShipOrder cmd) { /* ... */ }
+
+ // Messages without the ID field read it from the envelope header
+ public void Handle(OrderTimeout timeout) { /* ... */ }
+}
+
+public record StartOrder(OrderSagaId OrderId);
+public record ShipOrder(OrderSagaId OrderSagaId);
+public record OrderTimeout; // no saga ID field — read from envelope
+```
+
+::: tip
+When the message type does not expose the saga ID as a field, Wolverine propagates the identity automatically through
+the `SagaId` envelope header. The cascaded messages emitted from within a saga handler will have this header set
+for you. In your own integration tests you can supply it via `envelope.SagaId = id.ToString()`.
+:::
+
+::: warning
+Strong-typed identifiers backed by a third-party source-generator (e.g. [StronglyTypedId](https://github.com/andrewlock/StronglyTypedId))
+are supported. The generated `TryParse` method on those types satisfies the requirement above.
+:::
+
## Starting a Saga
::: tip
diff --git a/src/Persistence/EfCoreTests/parallel_tenant_initialization_tests.cs b/src/Persistence/EfCoreTests/parallel_tenant_initialization_tests.cs
new file mode 100644
index 000000000..cffcc39f9
--- /dev/null
+++ b/src/Persistence/EfCoreTests/parallel_tenant_initialization_tests.cs
@@ -0,0 +1,62 @@
+using System.Collections.Concurrent;
+using Shouldly;
+
+namespace EfCoreTests;
+
+public class parallel_tenant_initialization_tests
+{
+ [Fact]
+ public async Task all_tenants_are_initialized()
+ {
+ var initialized = new ConcurrentBag();
+ var tenantIds = Enumerable.Range(1, 20).Select(i => $"tenant{i}").ToList();
+
+ await Parallel.ForEachAsync(tenantIds, new ParallelOptions { MaxDegreeOfParallelism = 10 },
+ async (tenantId, ct) =>
+ {
+ await Task.Delay(5, ct);
+ initialized.Add(tenantId);
+ });
+
+ initialized.Count.ShouldBe(tenantIds.Count);
+ foreach (var tenantId in tenantIds)
+ {
+ initialized.ShouldContain(tenantId);
+ }
+ }
+
+ [Fact]
+ public async Task concurrent_initialization_is_faster_than_sequential()
+ {
+ const int tenantCount = 10;
+ const int perTenantDelayMs = 50;
+
+ var tenantIds = Enumerable.Range(1, tenantCount).Select(i => $"tenant{i}").ToList();
+
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ await Parallel.ForEachAsync(tenantIds, new ParallelOptions { MaxDegreeOfParallelism = 10 },
+ async (tenantId, ct) => await Task.Delay(perTenantDelayMs, ct));
+ sw.Stop();
+
+ // Sequential would take tenantCount * perTenantDelayMs = 500ms minimum.
+ // Parallel should complete much faster. Allow up to half the sequential time.
+ sw.ElapsedMilliseconds.ShouldBeLessThan(tenantCount * perTenantDelayMs / 2);
+ }
+
+ [Fact]
+ public async Task single_tenant_failure_surfaces_as_exception()
+ {
+ var tenantIds = Enumerable.Range(1, 5).Select(i => $"tenant{i}").ToList();
+
+ await Should.ThrowAsync(async () =>
+ {
+ await Parallel.ForEachAsync(tenantIds, new ParallelOptions { MaxDegreeOfParallelism = 10 },
+ async (tenantId, ct) =>
+ {
+ await Task.Delay(1, ct);
+ if (tenantId == "tenant3")
+ throw new InvalidOperationException($"Database initialization failed for {tenantId}");
+ });
+ });
+ }
+}
diff --git a/src/Persistence/MySql/Wolverine.MySql/Transport/MySqlTransport.cs b/src/Persistence/MySql/Wolverine.MySql/Transport/MySqlTransport.cs
index acf06d7d3..599010666 100644
--- a/src/Persistence/MySql/Wolverine.MySql/Transport/MySqlTransport.cs
+++ b/src/Persistence/MySql/Wolverine.MySql/Transport/MySqlTransport.cs
@@ -41,7 +41,7 @@ protected override IEnumerable endpoints()
public override string SanitizeIdentifier(string identifier)
{
- return identifier.Replace('-', '_').ToLower();
+ return identifier.Replace('-', '_').ToLowerInvariant();
}
protected override MySqlQueue findEndpointByUri(Uri uri)
diff --git a/src/Persistence/SqlServerTests/Transport/SqlServerTransportTests.cs b/src/Persistence/SqlServerTests/Transport/SqlServerTransportTests.cs
index 95a5d0beb..cc1da2606 100644
--- a/src/Persistence/SqlServerTests/Transport/SqlServerTransportTests.cs
+++ b/src/Persistence/SqlServerTests/Transport/SqlServerTransportTests.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using IntegrationTests;
using JasperFx.Core;
using Shouldly;
@@ -20,4 +21,25 @@ public void retrieve_queue_by_uri()
var queue = theTransport.GetOrCreateEndpoint("sqlserver://one".ToUri());
queue.ShouldBeOfType().Name.ShouldBe("one");
}
+
+ // Regression test for https://github.com/JasperFx/wolverine/issues/2472
+ // Turkish culture maps 'I'.ToLower() to dotless 'ı' instead of 'i',
+ // which corrupts SQL identifiers when SanitizeIdentifier uses ToLower().
+ [Fact]
+ public void sanitize_identifier_is_culture_invariant()
+ {
+ var originalCulture = CultureInfo.CurrentCulture;
+ try
+ {
+ CultureInfo.CurrentCulture = new CultureInfo("tr-TR");
+
+ // "INCOMING" contains 'I' — Turkish ToLower() would produce "ıncomıng"
+ theTransport.SanitizeIdentifier("INCOMING").ShouldBe("incoming");
+ theTransport.SanitizeIdentifier("My-Queue-Name").ShouldBe("my_queue_name");
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = originalCulture;
+ }
+ }
}
\ No newline at end of file
diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Internals/TenantedDbContextBuilderByConnectionString.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Internals/TenantedDbContextBuilderByConnectionString.cs
index 0e398961d..6d9ea066c 100644
--- a/src/Persistence/Wolverine.EntityFrameworkCore/Internals/TenantedDbContextBuilderByConnectionString.cs
+++ b/src/Persistence/Wolverine.EntityFrameworkCore/Internals/TenantedDbContextBuilderByConnectionString.cs
@@ -110,13 +110,16 @@ public async Task DeleteAllTenantDatabasesAsync()
public async Task EnsureAllTenantDatabasesCreatedAsync()
{
await _store.Source.RefreshAsync();
- foreach (var assignment in _store.Source.AllActiveByTenant())
- {
- var dbContext = await BuildAsync(assignment.TenantId, CancellationToken.None);
- await _serviceProvider.EnsureDatabaseExistsAsync(dbContext);
- await using var migration = await _serviceProvider.CreateMigrationAsync(dbContext, CancellationToken.None);
- await migration.ExecuteAsync(AutoCreate.CreateOrUpdate, CancellationToken.None);
- }
+ var assignments = _store.Source.AllActiveByTenant().ToList();
+
+ await Parallel.ForEachAsync(assignments, new ParallelOptions { MaxDegreeOfParallelism = 10 },
+ async (assignment, ct) =>
+ {
+ var dbContext = await BuildAsync(assignment.TenantId, ct);
+ await _serviceProvider.EnsureDatabaseExistsAsync(dbContext, ct);
+ await using var migration = await _serviceProvider.CreateMigrationAsync(dbContext, ct);
+ await migration.ExecuteAsync(AutoCreate.CreateOrUpdate, ct);
+ });
}
public async Task ApplyAllChangesToDatabasesAsync()
diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Internals/TenantedDbContextBuilderByDbDataSource.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Internals/TenantedDbContextBuilderByDbDataSource.cs
index e1a01b9a5..80be8af94 100644
--- a/src/Persistence/Wolverine.EntityFrameworkCore/Internals/TenantedDbContextBuilderByDbDataSource.cs
+++ b/src/Persistence/Wolverine.EntityFrameworkCore/Internals/TenantedDbContextBuilderByDbDataSource.cs
@@ -219,13 +219,16 @@ public async Task DeleteAllTenantDatabasesAsync()
public async Task EnsureAllTenantDatabasesCreatedAsync()
{
await _store.Source.RefreshAsync();
- foreach (var assignment in _store.Source.AllActiveByTenant())
- {
- var dbContext = await BuildAsync(assignment.TenantId, CancellationToken.None);
- await _serviceProvider.EnsureDatabaseExistsAsync(dbContext);
- await using var migration = await _serviceProvider.CreateMigrationAsync(dbContext, CancellationToken.None);
- await migration.ExecuteAsync(AutoCreate.CreateOrUpdate, CancellationToken.None);
- }
+ var assignments = _store.Source.AllActiveByTenant().ToList();
+
+ await Parallel.ForEachAsync(assignments, new ParallelOptions { MaxDegreeOfParallelism = 10 },
+ async (assignment, ct) =>
+ {
+ var dbContext = await BuildAsync(assignment.TenantId, ct);
+ await _serviceProvider.EnsureDatabaseExistsAsync(dbContext, ct);
+ await using var migration = await _serviceProvider.CreateMigrationAsync(dbContext, ct);
+ await migration.ExecuteAsync(AutoCreate.CreateOrUpdate, ct);
+ });
}
private async Task findDataSource(string? tenantId)
diff --git a/src/Persistence/Wolverine.Postgresql/Transport/PostgresqlTransport.cs b/src/Persistence/Wolverine.Postgresql/Transport/PostgresqlTransport.cs
index 620814804..7b8f8f5c8 100644
--- a/src/Persistence/Wolverine.Postgresql/Transport/PostgresqlTransport.cs
+++ b/src/Persistence/Wolverine.Postgresql/Transport/PostgresqlTransport.cs
@@ -84,7 +84,7 @@ protected override IEnumerable endpoints()
public override string SanitizeIdentifier(string identifier)
{
- return identifier.Replace('-', '_').ToLower();
+ return identifier.Replace('-', '_').ToLowerInvariant();
}
protected override PostgresqlQueue findEndpointByUri(Uri uri)
diff --git a/src/Persistence/Wolverine.RDBMS/Sagas/SagaTableDefinition.cs b/src/Persistence/Wolverine.RDBMS/Sagas/SagaTableDefinition.cs
index 16ffe7667..25cc0134f 100644
--- a/src/Persistence/Wolverine.RDBMS/Sagas/SagaTableDefinition.cs
+++ b/src/Persistence/Wolverine.RDBMS/Sagas/SagaTableDefinition.cs
@@ -39,10 +39,10 @@ private static string defaultTableName(Type documentType)
nameToAlias = _aliasSanitizer.Replace(documentType.GetPrettyName(), string.Empty).Replace(",", "_");
}
- var parts = new List { nameToAlias.ToLower() };
+ var parts = new List { nameToAlias.ToLowerInvariant() };
if (documentType.IsNested)
{
- parts.Insert(0, documentType.DeclaringType!.Name.ToLower());
+ parts.Insert(0, documentType.DeclaringType!.Name.ToLowerInvariant());
}
return string.Join("_", parts);
diff --git a/src/Persistence/Wolverine.SqlServer/Transport/SqlServerTransport.cs b/src/Persistence/Wolverine.SqlServer/Transport/SqlServerTransport.cs
index 36cdc4169..cf9ce436a 100644
--- a/src/Persistence/Wolverine.SqlServer/Transport/SqlServerTransport.cs
+++ b/src/Persistence/Wolverine.SqlServer/Transport/SqlServerTransport.cs
@@ -63,7 +63,7 @@ protected override IEnumerable explicitEndpoints()
public override string SanitizeIdentifier(string identifier)
{
- return identifier.Replace('-', '_').ToLower();
+ return identifier.Replace('-', '_').ToLowerInvariant();
}
protected override SqlServerQueue findEndpointByUri(Uri uri)
diff --git a/src/Persistence/Wolverine.Sqlite/Transport/SqliteTransport.cs b/src/Persistence/Wolverine.Sqlite/Transport/SqliteTransport.cs
index 9b7259f11..be919c7d2 100644
--- a/src/Persistence/Wolverine.Sqlite/Transport/SqliteTransport.cs
+++ b/src/Persistence/Wolverine.Sqlite/Transport/SqliteTransport.cs
@@ -72,7 +72,7 @@ protected override IEnumerable endpoints()
public override string SanitizeIdentifier(string identifier)
{
- return identifier.Replace('-', '_').ToLower();
+ return identifier.Replace('-', '_').ToLowerInvariant();
}
protected override SqliteQueue findEndpointByUri(Uri uri)
diff --git a/src/Testing/CoreTests/Bugs/Bug_2465_strong_typed_id_saga_codegen.cs b/src/Testing/CoreTests/Bugs/Bug_2465_strong_typed_id_saga_codegen.cs
new file mode 100644
index 000000000..3e534b770
--- /dev/null
+++ b/src/Testing/CoreTests/Bugs/Bug_2465_strong_typed_id_saga_codegen.cs
@@ -0,0 +1,116 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Shouldly;
+using Wolverine.Persistence.Sagas;
+using Wolverine.Tracking;
+using Xunit;
+
+namespace CoreTests.Bugs;
+
+// Reproduces https://github.com/JasperFx/wolverine/issues/2465
+// Using a saga with a strong-typed ID caused CS0103/CS0246 code gen errors
+// because PullSagaIdFromEnvelopeFrame used NameInCode() (bare type name) instead
+// of FullNameInCode() (fully qualified name) when emitting TryParse calls for
+// message types that carry no saga ID field (envelope path).
+
+public class Bug_2465_strong_typed_id_saga_codegen : IAsyncLifetime
+{
+ private IHost _host = null!;
+
+ public async Task InitializeAsync()
+ {
+ _host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Discovery.DisableConventionalDiscovery()
+ .IncludeType(typeof(StrongIdSaga));
+ }).StartAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _host.StopAsync();
+ _host.Dispose();
+ }
+
+ [Fact]
+ public void host_starts_successfully_with_strong_typed_saga_id()
+ {
+ // The host starting without exception verifies code generation succeeded.
+ // StrongIdSagaWork has no saga ID field so PullSagaIdFromEnvelopeFrame
+ // is used. Before the fix, it emitted `StrongSagaId` (bare) instead of
+ // `CoreTests.Bugs.StrongSagaId` (fully qualified), causing CS0246.
+ _host.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task can_start_saga_with_strong_typed_id()
+ {
+ var id = StrongSagaId.New();
+ await _host.InvokeMessageAndWaitAsync(new StartStrongIdSaga(id));
+
+ var persistor = _host.Services.GetRequiredService();
+ var saga = persistor.Load(id);
+ saga.ShouldNotBeNull();
+ saga!.Id.ShouldBe(id);
+ }
+
+ [Fact]
+ public async Task handle_message_via_envelope_saga_id_path()
+ {
+ // StartStrongIdSaga.Start() cascades a StrongIdSagaWork message.
+ // That message has no saga ID field so Wolverine uses PullSagaIdFromEnvelopeFrame
+ // to parse the saga ID from envelope.SagaId. This is the code path that was broken.
+ var id = StrongSagaId.New();
+ await _host.SendMessageAndWaitAsync(new StartStrongIdSaga(id));
+
+ var persistor = _host.Services.GetRequiredService();
+ var saga = persistor.Load(id);
+ saga.ShouldNotBeNull();
+ saga!.WorkDone.ShouldBeTrue();
+ }
+}
+
+// Strong-typed ID: a record struct wrapping Guid with TryParse, placed in this
+// namespace so FullNameInCode() is required to reference it in generated code.
+public record struct StrongSagaId(Guid Value)
+{
+ public static StrongSagaId New() => new(Guid.NewGuid());
+
+ public static bool TryParse(string? input, out StrongSagaId result)
+ {
+ if (Guid.TryParse(input, out var guid))
+ {
+ result = new StrongSagaId(guid);
+ return true;
+ }
+
+ result = default;
+ return false;
+ }
+
+ public override string ToString() => Value.ToString();
+}
+
+public record StartStrongIdSaga(StrongSagaId Id);
+
+// No saga ID field — forces Wolverine to use PullSagaIdFromEnvelopeFrame
+public record StrongIdSagaWork;
+
+public class StrongIdSaga : Saga
+{
+ public StrongSagaId Id { get; set; }
+ public bool WorkDone { get; set; }
+
+ // Returns a cascaded StrongIdSagaWork; Wolverine will propagate SagaId on that envelope
+ public static (StrongIdSaga, StrongIdSagaWork) Start(StartStrongIdSaga cmd)
+ {
+ return (new StrongIdSaga { Id = cmd.Id }, new StrongIdSagaWork());
+ }
+
+ // StrongIdSagaWork has no saga ID → uses PullSagaIdFromEnvelopeFrame
+ public void Handle(StrongIdSagaWork work)
+ {
+ WorkDone = true;
+ }
+}
diff --git a/src/Wolverine/Persistence/Sagas/PullSagaIdFromEnvelopeFrame.cs b/src/Wolverine/Persistence/Sagas/PullSagaIdFromEnvelopeFrame.cs
index 077677caf..79da7b151 100644
--- a/src/Wolverine/Persistence/Sagas/PullSagaIdFromEnvelopeFrame.cs
+++ b/src/Wolverine/Persistence/Sagas/PullSagaIdFromEnvelopeFrame.cs
@@ -28,7 +28,7 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
{
var typeNameInCode = SagaId.VariableType == typeof(Guid)
? typeof(Guid).FullName
- : SagaId.VariableType.NameInCode();
+ : SagaId.VariableType.FullNameInCode();
writer.Write(
$"if (!{typeNameInCode}.TryParse({_envelope!.Usage}.{nameof(Envelope.SagaId)}, out {typeNameInCode} sagaId)) throw new {typeof(IndeterminateSagaStateIdException).FullName}({_envelope.Usage});");