From d652ca10f1ae8fb7ece468ebae6dc1bcb07deaa7 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sun, 2 Aug 2026 11:58:50 -0500 Subject: [PATCH] Adopt compliance wave 2: retire four more mirrored Marten test pairs Companion to marten#5118 (epic marten#5110). Moves four Polecat test files onto the shared JasperFx.Events.ComplianceTests suites (2.37.2) and deletes them here, the same trade #393 made for the first four. Retired, and what replaced each: - Events/auto_discover_aggregate_types.cs -> AutoDiscoveredAggregateCompliance - Projections/event_projection_should_register_document_types.cs -> EventProjectionRegistrationCompliance - Projections/event_projection_enrichment_tests.cs -> EventProjectionEnrichmentCompliance - rebuild_concurrency_cap_resolution.cs -> RebuildConcurrencyCapCompliance Coverage went up on both sides of the port. Polecat's registration test asserted only PublishedTypes() and Marten's asserted only its own known-document-types list; the shared suite asserts both routes and adds an end-to-end append proving the store really provisioned storage for a document type nobody registered, which is the actual point of marten#4166. Polecat's rebuild-cap test was a pure unit test against a dummy connection string; the shared one builds a real store. PolecatComplianceFixture picks up the seam members the new suites need: StoreDocument, EventStore, AllAggregateTypes, an AddProjection registrar member, and connection-string / DaemonSettings handling for the rebuild-cap knobs. The alias file gains ComplianceOperations and ComplianceEventProjection beside the existing ComplianceQuerySession, because the EventProjection suites declare projection types at file scope and cannot reach the suite's generics. Also documents in CLAUDE.md that Polecat test runs must never overlap. The suite isolates by DatabaseSchemaName inside one shared master database, so a second concurrent run -- including one left alive after its parent shell was killed -- produces a large, scattered failure set across unrelated areas that reads like a real regression. That cost real time in this session. Compliance namespace: 55/55, no capability gates. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VpDCvJcBDZerieJB4JEHde --- CLAUDE.md | 7 + Directory.Packages.props | 10 +- .../Compliance/ComplianceQuerySessionAlias.cs | 5 + .../Compliance/PolecatComplianceFixture.cs | 29 ++- .../polecat_event_store_compliance.cs | 12 ++ .../Events/auto_discover_aggregate_types.cs | 43 ---- .../event_projection_enrichment_tests.cs | 189 ------------------ ...ojection_should_register_document_types.cs | 99 --------- .../rebuild_concurrency_cap_resolution.cs | 82 -------- 9 files changed, 57 insertions(+), 419 deletions(-) delete mode 100644 src/Polecat.Tests/Events/auto_discover_aggregate_types.cs delete mode 100644 src/Polecat.Tests/Projections/event_projection_enrichment_tests.cs delete mode 100644 src/Polecat.Tests/Projections/event_projection_should_register_document_types.cs delete mode 100644 src/Polecat.Tests/rebuild_concurrency_cap_resolution.cs diff --git a/CLAUDE.md b/CLAUDE.md index b86c10e9..bd69415e 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 e90a1df3..4a88bbd7 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 ec12b3de..f730b274 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 3a85212f..3a073925 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 ea91c0d9..f4d505d9 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 4a015718..00000000 --- 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 6a89c002..00000000 --- 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 05934562..00000000 --- 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 d7941f25..00000000 --- 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); - } -}