diff --git a/CLAUDE.md b/CLAUDE.md index b86c10e..bd69415 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,6 +107,13 @@ Critical path for MVP: Stages 1–5, 7–8, 10–11 no longer referenced, along with `xunit.runner.visualstudio` and `coverlet.collector`. - **Pattern**: Mirror Marten's IntegrationContext base class - **Database**: Dockerized SQL Server 2025 on localhost:11433 +- **Never run two test runs at once.** Tests share one SQL Server instance and isolate by + `DatabaseSchemaName` inside `master`, not by database, so concurrent runs — a second `dotnet test`, + a run started before an earlier one finished, or a run left alive after you killed its parent shell + — step on each other's schemas and produce large, scattered, misleading failure sets across + unrelated areas (query plans, flat tables, partitioning, subscriptions). Let a run finish, and + confirm with `pgrep -f Polecat.Tests` before starting another. A killed run in particular can leave + its test host alive and its schemas half-torn-down; drop the leftovers before re-running. - **Test naming**: snake_case file names (e.g., `start_stream_tests.cs`) - **Assertions**: Shouldly (or similar fluent assertions) - **Lifecycle**: `IAsyncLifetime` is ValueTask-based and inherits `IAsyncDisposable`. If a class diff --git a/Directory.Packages.props b/Directory.Packages.props index e90a1df..4a88bbd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -62,14 +62,14 @@ re-reads progression and succeeds when the replay had already reached the mark, plus a configurable DaemonSettings.SideEffectGateTimeout), jasperfx#595 (BatchingChannel could deliver its trailing batch twice on shutdown) and #597. Same lockstep rule as above. --> - - + + - + - + @@ -165,7 +165,7 @@ - + diff --git a/src/Polecat.Tests/Compliance/ComplianceQuerySessionAlias.cs b/src/Polecat.Tests/Compliance/ComplianceQuerySessionAlias.cs index ec12b3d..f730b27 100644 --- a/src/Polecat.Tests/Compliance/ComplianceQuerySessionAlias.cs +++ b/src/Polecat.Tests/Compliance/ComplianceQuerySessionAlias.cs @@ -3,3 +3,8 @@ // by type name, so a per-consumer global alias lets one shared source file bind to Polecat's // IQuerySession here and to Marten's in Marten. global using ComplianceQuerySession = Polecat.IQuerySession; + +// Same mechanism for the EventProjection suites. Those declare projection types at file scope, so +// they cannot reach the pair their suite class is generic over. +global using ComplianceOperations = Polecat.IDocumentSession; +global using ComplianceEventProjection = Polecat.Projections.EventProjection; diff --git a/src/Polecat.Tests/Compliance/PolecatComplianceFixture.cs b/src/Polecat.Tests/Compliance/PolecatComplianceFixture.cs index 3a85212..3a07392 100644 --- a/src/Polecat.Tests/Compliance/PolecatComplianceFixture.cs +++ b/src/Polecat.Tests/Compliance/PolecatComplianceFixture.cs @@ -26,12 +26,17 @@ protected override async Task BuildStoreAsync(ComplianceStoreConfig config) var options = new StoreOptions { - ConnectionString = ConnectionSource.ConnectionString, + ConnectionString = connectionStringFor(config), AutoCreateSchemaObjects = AutoCreate.All, DatabaseSchemaName = schemaName, UseNativeJsonType = ConnectionSource.SupportsNativeJson }; + if (config.MaxConcurrentRebuildsPerDatabase.HasValue) + { + options.DaemonSettings.MaxConcurrentRebuildsPerDatabase = config.MaxConcurrentRebuildsPerDatabase; + } + config.ApplyTo(new PolecatComplianceRegistrar(options)); _store = new DocumentStore(options); @@ -42,6 +47,19 @@ protected override async Task BuildStoreAsync(ComplianceStoreConfig config) await _store.Database.ApplyAllConfiguredChangesToDatabaseAsync().ConfigureAwait(false); } + private static string connectionStringFor(ComplianceStoreConfig config) + { + if (!config.MaxPoolSize.HasValue) + { + return ConnectionSource.ConnectionString; + } + + return new SqlConnectionStringBuilder(ConnectionSource.ConnectionString) + { + MaxPoolSize = config.MaxPoolSize.Value + }.ConnectionString; + } + public override IDocumentSession OpenSession() => _store.LightweightSession(); public override Task SaveChangesAsync(IDocumentSession session, CancellationToken token) @@ -59,8 +77,14 @@ public override Task SaveChangesAsync(IDocumentSession session, CancellationToke $"Polecat cannot load documents by an identity of type {id.GetType().FullName}") }; + public override void StoreDocument(IDocumentSession session, T document) => session.Store(document); + public override IEventStoreOperations EventsFor(IDocumentSession session) => session.Events; + public override IEventStore EventStore => _store; + + public override IEnumerable AllAggregateTypes() => _store.Options.Projections.AllAggregateTypes(); + public override IComplianceBatch CreateBatch(IQuerySession session) => new PolecatComplianceBatch(session.CreateBatchQuery()); @@ -130,6 +154,9 @@ public void Snapshot(SnapshotLifecycle lifecycle) where TDoc : notnull public void LiveAggregation() where TDoc : notnull { } + + public void AddProjection(ProjectionBase projection, ProjectionLifecycle lifecycle) + => _options.Projections.Add((IProjectionSource)projection, lifecycle); } internal class PolecatComplianceBatch : IComplianceBatch diff --git a/src/Polecat.Tests/Compliance/polecat_event_store_compliance.cs b/src/Polecat.Tests/Compliance/polecat_event_store_compliance.cs index ea91c0d..f4d505d 100644 --- a/src/Polecat.Tests/Compliance/polecat_event_store_compliance.cs +++ b/src/Polecat.Tests/Compliance/polecat_event_store_compliance.cs @@ -21,3 +21,15 @@ public class assign_tag_where_compliance public class async_daemon_compliance : AsyncDaemonCompliance; + +public class auto_discovered_aggregate_compliance + : AutoDiscoveredAggregateCompliance; + +public class event_projection_registration_compliance + : EventProjectionRegistrationCompliance; + +public class event_projection_enrichment_compliance + : EventProjectionEnrichmentCompliance; + +public class rebuild_concurrency_cap_compliance + : RebuildConcurrencyCapCompliance; diff --git a/src/Polecat.Tests/Events/auto_discover_aggregate_types.cs b/src/Polecat.Tests/Events/auto_discover_aggregate_types.cs deleted file mode 100644 index 4a01571..0000000 --- a/src/Polecat.Tests/Events/auto_discover_aggregate_types.cs +++ /dev/null @@ -1,43 +0,0 @@ -using JasperFx.Events.ComplianceTests; -using Polecat.Tests.Harness; - -namespace Polecat.Tests.Events; - -/// -/// Tests that verify self-aggregating types with source-generated evolvers -/// are automatically discovered and registered in StoreOptions.Projections -/// even without explicit Add<SingleStreamProjection<T, Guid>>() registration. -/// -[Collection("integration")] -public class auto_discover_aggregate_types : IntegrationContext -{ - public auto_discover_aggregate_types(DefaultStoreFixture fixture) : base(fixture) - { - } - - [Fact] - public void self_aggregating_types_are_auto_discovered() - { - // These types have source-generated evolvers via Evolve(IEvent) - // but are NOT explicitly registered via Projections.Snapshot() - var aggregateTypes = theStore.Options.Projections.AllAggregateTypes().ToArray(); - - // MutableIEventEvolveAggregate has a generated evolver - aggregateTypes.ShouldContain(typeof(MutableIEventEvolveAggregate)); - } - - [Fact] - public async Task auto_discovered_type_works_for_live_aggregation() - { - // No explicit Snapshot() registration — relies on auto-discovery - var streamId = Guid.NewGuid(); - theSession.Events.StartStream(streamId, new EvolveAEvent(), new EvolveBEvent(), new EvolveCEvent()); - await theSession.SaveChangesAsync(TestContext.Current.CancellationToken); - - var aggregate = await theSession.Events.AggregateStreamAsync(streamId, token: TestContext.Current.CancellationToken); - aggregate.ShouldNotBeNull(); - aggregate.ACount.ShouldBe(1); - aggregate.BCount.ShouldBe(1); - aggregate.CCount.ShouldBe(1); - } -} diff --git a/src/Polecat.Tests/Projections/event_projection_enrichment_tests.cs b/src/Polecat.Tests/Projections/event_projection_enrichment_tests.cs deleted file mode 100644 index 6a89c00..0000000 --- a/src/Polecat.Tests/Projections/event_projection_enrichment_tests.cs +++ /dev/null @@ -1,189 +0,0 @@ -using JasperFx.Events; -using JasperFx.Events.Projections; -using Polecat.Projections; -using Polecat.Tests.Harness; -using Shouldly; - -namespace Polecat.Tests.Projections; - -public class event_projection_enrichment_tests : OneOffConfigurationsContext -{ - [Fact] - public async Task enrichment_sets_data_before_apply_inline() - { - ConfigureStore(opts => - { - opts.Projections.Add(new SimpleEnrichmentProjection(), ProjectionLifecycle.Inline); - }); - await theDatabase.ApplyAllConfiguredChangesToDatabaseAsync(ct: TestContext.Current.CancellationToken); - - var taskId = Guid.NewGuid(); - await using (var session = theStore.LightweightSession()) - { - session.Events.StartStream(taskId, - new EnrichmentTaskAssigned { TaskId = taskId, UserId = Guid.NewGuid() }); - await session.SaveChangesAsync(TestContext.Current.CancellationToken); - } - - await using var query = theStore.QuerySession(); - var summary = await query.LoadAsync(taskId, TestContext.Current.CancellationToken); - summary.ShouldNotBeNull(); - summary.AssignedUserName.ShouldBe("Enriched User"); - } - - [Fact] - public async Task enrichment_is_called_before_apply() - { - var callOrder = new List(); - ConfigureStore(opts => - { - opts.Projections.Add( - new EnrichmentCallOrderProjection(callOrder), - ProjectionLifecycle.Inline); - }); - await theDatabase.ApplyAllConfiguredChangesToDatabaseAsync(ct: TestContext.Current.CancellationToken); - - var streamId = Guid.NewGuid(); - await using var session = theStore.LightweightSession(); - session.Events.StartStream(streamId, - new EnrichmentTaskAssigned { TaskId = streamId, UserId = Guid.NewGuid() }); - await session.SaveChangesAsync(TestContext.Current.CancellationToken); - - callOrder.ShouldBe(new[] { "EnrichEventsAsync", "Apply:EnrichmentTaskAssigned" }); - } - - [Fact] - public async Task enrichment_with_database_lookup_inline() - { - ConfigureStore(opts => - { - opts.Projections.Add(new DbLookupEnrichmentProjection(), ProjectionLifecycle.Inline); - }); - await theDatabase.ApplyAllConfiguredChangesToDatabaseAsync(ct: TestContext.Current.CancellationToken); - - // Pre-store a lookup document - var userId = Guid.NewGuid(); - await using (var session = theStore.LightweightSession()) - { - session.Store(new EnrichmentUser { Id = userId, Name = "Alice Smith" }); - await session.SaveChangesAsync(TestContext.Current.CancellationToken); - } - - var taskId = Guid.NewGuid(); - await using (var session = theStore.LightweightSession()) - { - session.Events.StartStream(taskId, - new EnrichmentTaskAssigned { TaskId = taskId, UserId = userId }); - await session.SaveChangesAsync(TestContext.Current.CancellationToken); - } - - await using var query = theStore.QuerySession(); - var summary = await query.LoadAsync(taskId, TestContext.Current.CancellationToken); - summary.ShouldNotBeNull(); - summary.AssignedUserName.ShouldBe("Alice Smith"); - } -} - -#region Test Types - -public class EnrichmentTaskAssigned -{ - public Guid TaskId { get; set; } - public Guid UserId { get; set; } - public string? UserName { get; set; } -} - -public class EnrichmentTaskSummary -{ - public Guid Id { get; set; } - public string? AssignedUserName { get; set; } -} - -public class EnrichmentUser -{ - public Guid Id { get; set; } - public string Name { get; set; } = ""; -} - -#endregion - -#region Projections - -public partial class SimpleEnrichmentProjection : EventProjection -{ - public void Project(EnrichmentTaskAssigned e, IDocumentSession ops) - { - ops.Store(new EnrichmentTaskSummary - { - Id = e.TaskId, - AssignedUserName = e.UserName - }); - } - - public override Task EnrichEventsAsync(IQuerySession querySession, - IReadOnlyList events, CancellationToken cancellation) - { - foreach (var e in events.OfType>()) - { - e.Data.UserName = "Enriched User"; - } - return Task.CompletedTask; - } -} - -public partial class EnrichmentCallOrderProjection : EventProjection -{ - private readonly List _callOrder; - - public EnrichmentCallOrderProjection(List callOrder) - { - _callOrder = callOrder; - } - - public void Project(EnrichmentTaskAssigned e, IDocumentSession ops) - { - _callOrder.Add($"Apply:{nameof(EnrichmentTaskAssigned)}"); - } - - public override Task EnrichEventsAsync(IQuerySession querySession, - IReadOnlyList events, CancellationToken cancellation) - { - _callOrder.Add(nameof(EnrichEventsAsync)); - return Task.CompletedTask; - } -} - -public partial class DbLookupEnrichmentProjection : EventProjection -{ - public void Project(EnrichmentTaskAssigned e, IDocumentSession ops) - { - ops.Store(new EnrichmentTaskSummary - { - Id = e.TaskId, - AssignedUserName = e.UserName - }); - } - - public override async Task EnrichEventsAsync(IQuerySession querySession, - IReadOnlyList events, CancellationToken cancellation) - { - var assigned = events.OfType>().ToArray(); - if (assigned.Length == 0) return; - - var userIds = assigned.Select(e => e.Data.UserId).Distinct().ToArray(); - - foreach (var userId in userIds) - { - var user = await querySession.LoadAsync(userId, cancellation); - if (user != null) - { - foreach (var e in assigned.Where(a => a.Data.UserId == userId)) - { - e.Data.UserName = user.Name; - } - } - } - } -} - -#endregion diff --git a/src/Polecat.Tests/Projections/event_projection_should_register_document_types.cs b/src/Polecat.Tests/Projections/event_projection_should_register_document_types.cs deleted file mode 100644 index 0593456..0000000 --- a/src/Polecat.Tests/Projections/event_projection_should_register_document_types.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using JasperFx.Events; -using JasperFx.Events.Projections; -using Polecat.Projections; -using Polecat.Tests.Harness; -using Shouldly; -using Xunit; - -namespace Polecat.Tests.Projections; - -/// -/// Document type used in an EventProjection but NOT explicitly registered with Polecat. -/// The source generator should discover this type from Store/Insert calls in ApplyAsync -/// and register it automatically. -/// See https://github.com/JasperFx/marten/issues/4166 -/// -public class AuditRecord -{ - public Guid Id { get; set; } - public Guid StreamId { get; set; } - public string EventType { get; set; } = string.Empty; - public DateTimeOffset Timestamp { get; set; } -} - -public class AuditableEvent -{ - public string Description { get; set; } = string.Empty; -} - -/// -/// An EventProjection with an explicit ApplyAsync override that stores a document type -/// using operations.Store<T>(). The source generator should detect this and emit a -/// constructor that registers AuditRecord as a published type. -/// See https://github.com/JasperFx/marten/issues/4166 -/// -public partial class AuditRecordProjection : EventProjection -{ - public override ValueTask ApplyAsync(IDocumentSession operations, IEvent e, CancellationToken cancellation) - { - switch (e.Data) - { - case AuditableEvent: - operations.Store(new AuditRecord - { - Id = Guid.NewGuid(), - StreamId = e.StreamId, - EventType = e.Data.GetType().Name, - Timestamp = e.Timestamp - }); - break; - } - - return new ValueTask(); - } -} - -/// -/// An EventProjection with conventional Create method that returns a document type. -/// The source generator should register this type via the emitted constructor. -/// -public partial class AuditRecordCreatorProjection : EventProjection -{ - public AuditRecord Create(AuditableEvent e) => new AuditRecord - { - Id = Guid.NewGuid(), - EventType = nameof(AuditableEvent) - }; -} - -public class event_projection_should_register_document_types -{ - [Fact] - public void explicit_apply_async_projection_should_register_document_types() - { - // Issue #4166: Document types used in operations.Store() inside an explicit - // ApplyAsync override should be automatically discovered and registered - // by the source generator. - var projection = new AuditRecordProjection(); - - var publishedTypes = projection.PublishedTypes().ToList(); - publishedTypes.Count.ShouldBeGreaterThan(0, - "AuditRecordProjection should have published types"); - publishedTypes.ShouldContain(typeof(AuditRecord), - "AuditRecord should be auto-discovered from operations.Store() in ApplyAsync"); - } - - [Fact] - public void conventional_create_projection_should_register_document_types() - { - // EventProjection with Create method should also register the return type. - var projection = new AuditRecordCreatorProjection(); - - projection.PublishedTypes().ShouldContain(typeof(AuditRecord), - "AuditRecord should be auto-discovered from Create method return type"); - } -} diff --git a/src/Polecat.Tests/rebuild_concurrency_cap_resolution.cs b/src/Polecat.Tests/rebuild_concurrency_cap_resolution.cs deleted file mode 100644 index d7941f2..0000000 --- a/src/Polecat.Tests/rebuild_concurrency_cap_resolution.cs +++ /dev/null @@ -1,82 +0,0 @@ -using JasperFx; -using JasperFx.Events; -using Microsoft.Data.SqlClient; - -namespace Polecat.Tests; - -/// -/// jasperfx#420 / marten#4710 companion: resolution of the per-database rebuild -/// concurrency cap surfaced through IEventStore.MaxConcurrentRebuildsPerDatabase. -/// Pure unit tests — the pool-size derivation parses the connection string via -/// without opening a connection. -/// -public class rebuild_concurrency_cap_resolution -{ - private const string DummyConnectionString = - "Server=localhost;Database=rebuild_cap;Integrated Security=true;TrustServerCertificate=true"; - - private static DocumentStore buildStore(string connectionString, Action? configure = null) - { - var options = new StoreOptions - { - ConnectionString = connectionString, - AutoCreateSchemaObjects = AutoCreate.None, - }; - configure?.Invoke(options); - return new DocumentStore(options); - } - - [Fact] - public void configured_value_wins_over_derived_default() - { - using var store = buildStore(DummyConnectionString, - opts => opts.DaemonSettings.MaxConcurrentRebuildsPerDatabase = 3); - ((IEventStore)store).MaxConcurrentRebuildsPerDatabase.ShouldBe(3); - } - - [Fact] - public void non_positive_configured_value_disables_the_cap() - { - using var store = buildStore(DummyConnectionString, - opts => opts.DaemonSettings.MaxConcurrentRebuildsPerDatabase = 0); - ((IEventStore)store).MaxConcurrentRebuildsPerDatabase.ShouldBeNull(); - } - - [Fact] - public void derived_default_is_pool_size_over_eight_with_floor_of_one() - { - var connectionString = new SqlConnectionStringBuilder(DummyConnectionString) - { - MaxPoolSize = 64 - }.ConnectionString; - - using var store = buildStore(connectionString); - ((IEventStore)store).MaxConcurrentRebuildsPerDatabase.ShouldBe(8); - } - - [Fact] - public void derived_default_floors_at_one_for_tiny_pools() - { - var connectionString = new SqlConnectionStringBuilder(DummyConnectionString) - { - MaxPoolSize = 5 - }.ConnectionString; - - using var store = buildStore(connectionString); - ((IEventStore)store).MaxConcurrentRebuildsPerDatabase.ShouldBe(1); - } - - [Fact] - public async Task usage_descriptor_carries_the_effective_cap() - { - // jasperfx#434: CritterWatch#309's rebuild dispatcher reads the effective cap - // off the EventStoreUsage descriptor rather than guessing. - using var store = buildStore(DummyConnectionString, - opts => opts.DaemonSettings.MaxConcurrentRebuildsPerDatabase = 6); - - var usage = await ((IEventStore)store).TryCreateUsage(CancellationToken.None); - - usage.ShouldNotBeNull(); - usage!.MaxConcurrentRebuildsPerDatabase.ShouldBe(6); - } -}