diff --git a/Directory.Build.props b/Directory.Build.props index 17a0701..7d24fb2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 2.37.1 + 2.37.2 13 1570;1571;1572;1573;1574;1587;1591;1701;1702;1711;1735;0618 Jeremy D. Miller;Jaedyn Tonee diff --git a/src/JasperFx.Events.ComplianceTests/ComplianceStoreConfig.cs b/src/JasperFx.Events.ComplianceTests/ComplianceStoreConfig.cs index 2fc07b7..7eb1050 100644 --- a/src/JasperFx.Events.ComplianceTests/ComplianceStoreConfig.cs +++ b/src/JasperFx.Events.ComplianceTests/ComplianceStoreConfig.cs @@ -23,6 +23,24 @@ public sealed class ComplianceStoreConfig /// public string? SchemaName { get; set; } + /// + /// Optional explicit value for the per-database rebuild concurrency cap. Null leaves the store + /// on its derived default; zero or negative disables the cap. + /// + /// + /// Not routed through because the products hang the knob + /// off different option objects (Marten Projections, Polecat DaemonSettings) and + /// the fixture is already the place that knows which. + /// + public int? MaxConcurrentRebuildsPerDatabase { get; set; } + + /// + /// Optional connection pool ceiling, folded into the connection string by the fixture. Exists so + /// the rebuild-cap suite can exercise the pool-size-derived default without caring whether the + /// store speaks Npgsql or SqlClient. + /// + public int? MaxPoolSize { get; set; } + public List EventTypes { get; } = new(); public List<(Type Tag, string Suffix, Type? Aggregate)> TagTypes { get; } = new(); @@ -31,6 +49,8 @@ public sealed class ComplianceStoreConfig public List LiveAggregations { get; } = new(); + public List<(ProjectionBase Projection, ProjectionLifecycle Lifecycle)> Projections { get; } = new(); + public ComplianceStoreConfig AddEventType() { EventTypes.Add(typeof(T)); @@ -68,6 +88,18 @@ public ComplianceStoreConfig LiveAggregation() where TDoc : notnull return this; } + /// + /// Register an already-constructed projection instance. Used where the projection carries test + /// state (the enrichment suite's call-order recorder) or where the point of the test is what the + /// source generator emitted onto a concrete projection type. + /// + public ComplianceStoreConfig AddProjection(ProjectionBase projection, ProjectionLifecycle lifecycle) + { + Projections.Add((projection, lifecycle)); + _registrations.Add(registrar => registrar.AddProjection(projection, lifecycle)); + return this; + } + public void ApplyTo(IComplianceStoreRegistrar registrar) { foreach (var registration in _registrations) diff --git a/src/JasperFx.Events.ComplianceTests/EventStoreComplianceFixture.cs b/src/JasperFx.Events.ComplianceTests/EventStoreComplianceFixture.cs index 75e4465..354c314 100644 --- a/src/JasperFx.Events.ComplianceTests/EventStoreComplianceFixture.cs +++ b/src/JasperFx.Events.ComplianceTests/EventStoreComplianceFixture.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using JasperFx.Events.Daemon; @@ -79,11 +80,34 @@ public async Task ConfigureAsync(Action configure) public abstract Task LoadDocumentAsync(TQuerySession session, object id, CancellationToken token) where T : class; + /// + /// Store a plain document — not an event. Only needed where a suite has to seed state the event + /// store itself did not produce, such as the lookup document an enrichment projection reads. + /// + public abstract void StoreDocument(TOperations session, T document) where T : notnull; + /// /// The payoff member — everything portable in the suites runs off the shared JasperFx surface. /// public abstract IEventStoreOperations EventsFor(TOperations session); + /// + /// The store itself, as the shared surface. Suites reach for this on + /// store-level contracts — the rebuild concurrency cap, usage descriptors — never for anything + /// session-scoped. + /// + public abstract IEventStore EventStore { get; } + + /// + /// Aggregate types the store knows about, including ones discovered from source-generated + /// evolvers rather than explicit registration. + /// + /// + /// ProjectionGraph.AllAggregateTypes() is shared, but the graph hangs off each product's + /// own options type, so reaching it costs one line of fixture code. + /// + public abstract IEnumerable AllAggregateTypes(); + public abstract IComplianceBatch CreateBatch(TQuerySession session); /// diff --git a/src/JasperFx.Events.ComplianceTests/EventStoreComplianceSuite.cs b/src/JasperFx.Events.ComplianceTests/EventStoreComplianceSuite.cs index f278f82..a5be935 100644 --- a/src/JasperFx.Events.ComplianceTests/EventStoreComplianceSuite.cs +++ b/src/JasperFx.Events.ComplianceTests/EventStoreComplianceSuite.cs @@ -51,6 +51,11 @@ public virtual async ValueTask InitializeAsync() protected Task LoadDocumentAsync(TQuerySession session, object id) where T : class => theFixture.LoadDocumentAsync(session, id, Cancellation); + protected void StoreDocument(TOperations session, T document) where T : notnull + => theFixture.StoreDocument(session, document); + + protected IEventStore EventStore => theFixture.EventStore; + protected IComplianceBatch CreateBatch(TQuerySession session) => theFixture.CreateBatch(session); /// diff --git a/src/JasperFx.Events.ComplianceTests/IComplianceStoreRegistrar.cs b/src/JasperFx.Events.ComplianceTests/IComplianceStoreRegistrar.cs index 87234d2..3f73d17 100644 --- a/src/JasperFx.Events.ComplianceTests/IComplianceStoreRegistrar.cs +++ b/src/JasperFx.Events.ComplianceTests/IComplianceStoreRegistrar.cs @@ -37,4 +37,16 @@ public interface IComplianceStoreRegistrar /// build live aggregators automatically. /// void LiveAggregation() where TDoc : notnull; + + /// + /// Register an already-constructed projection instance. + /// + /// + /// Typed as the shared rather than + /// IProjectionSource<TOperations, TQuerySession> because this interface is not + /// generic over the session pair; the implementing fixture casts down to its own closure. Every + /// projection a suite can build derives from the product's own EventProjection base, so the cast + /// is total in practice. + /// + void AddProjection(ProjectionBase projection, ProjectionLifecycle lifecycle); } diff --git a/src/JasperFx.Events.ComplianceTests/Local/ComplianceProjectionPlaceholders.cs b/src/JasperFx.Events.ComplianceTests/Local/ComplianceProjectionPlaceholders.cs new file mode 100644 index 0000000..3cf3a8e --- /dev/null +++ b/src/JasperFx.Events.ComplianceTests/Local/ComplianceProjectionPlaceholders.cs @@ -0,0 +1,35 @@ +// NOT PACKAGED. See ComplianceQuerySessionPlaceholder.cs for why Local/ exists. +// +// The EventProjection suites declare projection types at file scope, so they cannot reach the +// pair that the suite classes are generic over. Two more per-consumer +// global aliases close that gap, exactly like ComplianceQuerySession does for the self-aggregating +// fixtures: +// +// global using ComplianceOperations = Marten.IDocumentOperations; +// global using ComplianceEventProjection = Marten.Events.Projections.EventProjection; +// +// Aliases (rather than generic base classes) because both products' EventProjection base carries +// store-specific members -- Marten's IProjectionSchemaSource/IMartenRegistrable, Polecat's sealed +// storeEntity override -- so the shared sources want the product's own base type, whatever it is. + +global using ComplianceOperations = JasperFx.Events.ComplianceTests.Local.IPlaceholderOperations; +global using ComplianceEventProjection = JasperFx.Events.ComplianceTests.Local.PlaceholderEventProjection; + +using JasperFx.Events.Projections; + +namespace JasperFx.Events.ComplianceTests.Local; + +public interface IPlaceholderOperations: IPlaceholderQuerySession, IStorageOperations +{ + /// + /// Called by the registration suite's explicit ApplyAsync override, which is the whole + /// point of that test -- the source generator has to see the Store<T> call. + /// + void Store(T entity) where T : notnull; +} + +public abstract class PlaceholderEventProjection: JasperFxEventProjectionBase +{ + protected override void storeEntity(IPlaceholderOperations ops, T entity) => ops.Store(entity); +} diff --git a/src/JasperFx.Events.ComplianceTests/Local/ComplianceQuerySessionPlaceholder.cs b/src/JasperFx.Events.ComplianceTests/Local/ComplianceQuerySessionPlaceholder.cs index 59a302a..3135dbb 100644 --- a/src/JasperFx.Events.ComplianceTests/Local/ComplianceQuerySessionPlaceholder.cs +++ b/src/JasperFx.Events.ComplianceTests/Local/ComplianceQuerySessionPlaceholder.cs @@ -8,10 +8,24 @@ // // global using ComplianceQuerySession = Marten.IQuerySession; // -// This placeholder stands in for that alias here and never leaves the repo. +// This placeholder stands in for that alias here and never leaves the repo. Members are declared +// only where a shared suite actually calls them, and their shapes are the intersection of what +// Marten and Polecat already expose -- binding against the real session types in the consumers is +// what validates them for real. global using ComplianceQuerySession = JasperFx.Events.ComplianceTests.Local.IPlaceholderQuerySession; +using System; +using System.Threading; +using System.Threading.Tasks; + namespace JasperFx.Events.ComplianceTests.Local; -public interface IPlaceholderQuerySession; +public interface IPlaceholderQuerySession +{ + /// + /// Called by the enrichment suite's database-lookup projection, which reads a document from + /// inside EnrichEventsAsync. + /// + Task LoadAsync(Guid id, CancellationToken token = default) where T : class; +} diff --git a/src/JasperFx.Events.ComplianceTests/README.md b/src/JasperFx.Events.ComplianceTests/README.md index f67fd19..4fc5230 100644 --- a/src/JasperFx.Events.ComplianceTests/README.md +++ b/src/JasperFx.Events.ComplianceTests/README.md @@ -15,14 +15,20 @@ differences between the repos. Reference the package from a test project that already has xunit v3 and Shouldly, then supply two things. -**1. A global alias naming your store's read session.** The shared self-aggregating fixtures declare -`EvolveAsync(IEvent, ComplianceQuerySession)`; the source generator resolves the parameter by type -name, so an alias is enough: +**1. Three global aliases naming your store's own types.** The shared suites declare aggregates and +projections at file scope, so they cannot reach the `` pair the suite +classes are generic over. The source generator resolves these by type name, so aliases are enough: ```csharp global using ComplianceQuerySession = Marten.IQuerySession; +global using ComplianceOperations = Marten.IDocumentOperations; +global using ComplianceEventProjection = Marten.Events.Projections.EventProjection; ``` +`ComplianceQuerySession` binds the `EvolveAsync(IEvent, …)` convention on the self-aggregating +fixtures; the other two bind the EventProjection suites to your product's own projection base and +writable session. + **2. A concrete fixture** closing `EventStoreComplianceFixture` over your store's session pair. Everything portable in the suites runs through the shared JasperFx surfaces (`IEventStoreOperations`, `IEventRegistry`, `IProjectionDaemon`); the fixture only has to supply what diff --git a/src/JasperFx.Events.ComplianceTests/Suites/AutoDiscoveredAggregateCompliance.cs b/src/JasperFx.Events.ComplianceTests/Suites/AutoDiscoveredAggregateCompliance.cs new file mode 100644 index 0000000..a0da60b --- /dev/null +++ b/src/JasperFx.Events.ComplianceTests/Suites/AutoDiscoveredAggregateCompliance.cs @@ -0,0 +1,58 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Shouldly; +using Xunit; + +namespace JasperFx.Events.ComplianceTests; + +/// +/// Self-aggregating types whose evolvers were emitted by the source generator have to be usable +/// without ever being registered — no Snapshot<T>(), no explicit projection. The store +/// finds them by walking loaded assemblies for [GeneratedEvolver] at construction time. +/// +/// +/// Deliberately configures a store with nothing registered at all, which is what separates this from +/// — there the same +/// aggregates are registered as inline snapshots, so discovery is never exercised. +/// +public abstract class AutoDiscoveredAggregateCompliance + : EventStoreComplianceSuite + where TFixture : EventStoreComplianceFixture, new() + where TOperations : TQuerySession, IStorageOperations +{ + private static readonly Action _configuration = config => + { + config.SchemaName = "compliance_auto_discover"; + }; + + protected override Action Configuration => _configuration; + + [Fact] + public void self_aggregating_types_are_auto_discovered() + { + var aggregateTypes = theFixture.AllAggregateTypes().ToArray(); + + aggregateTypes.ShouldContain(typeof(MutableIEventEvolveAggregate), + "MutableIEventEvolveAggregate has a source-generated evolver and was never registered, " + + "so it can only be here by assembly discovery"); + } + + [Fact] + public async Task auto_discovered_type_works_for_live_aggregation() + { + await using var session = OpenSession(); + + var streamId = Guid.NewGuid(); + EventsFor(session).StartStream(streamId, new EvolveAEvent(), new EvolveBEvent(), new EvolveCEvent()); + await SaveChangesAsync(session); + + var aggregate = + await EventsFor(session).AggregateStreamAsync(streamId, token: Cancellation); + + aggregate.ShouldNotBeNull(); + aggregate.ACount.ShouldBe(1); + aggregate.BCount.ShouldBe(1); + aggregate.CCount.ShouldBe(1); + } +} diff --git a/src/JasperFx.Events.ComplianceTests/Suites/EventProjectionEnrichmentCompliance.cs b/src/JasperFx.Events.ComplianceTests/Suites/EventProjectionEnrichmentCompliance.cs new file mode 100644 index 0000000..2f7b04d --- /dev/null +++ b/src/JasperFx.Events.ComplianceTests/Suites/EventProjectionEnrichmentCompliance.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events.Projections; +using Shouldly; +using Xunit; + +namespace JasperFx.Events.ComplianceTests; + +#region Enrichment events and documents + +public class EnrichmentTaskAssigned +{ + public Guid TaskId { get; set; } + public Guid UserId { get; set; } + public string? UserName { get; set; } +} + +public class EnrichmentLookupAssigned +{ + public Guid TaskId { get; set; } + public Guid UserId { get; set; } + public string? UserName { get; set; } +} + +public class EnrichmentPing +{ + public Guid TaskId { get; set; } +} + +public class EnrichmentTaskSummary +{ + public Guid Id { get; set; } + public string? AssignedUserName { get; set; } +} + +public class EnrichmentLookupSummary +{ + public Guid Id { get; set; } + public string? AssignedUserName { get; set; } +} + +public class EnrichmentUser +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; +} + +#endregion + +#region Enrichment projections + +/// +/// Enrichment with no external reads — mutates the event data in place before the projection sees +/// it, which is the minimum contract: Project observes the enriched value, not the raw one. +/// +public partial class SimpleEnrichmentProjection: ComplianceEventProjection +{ + public void Project(EnrichmentTaskAssigned e, ComplianceOperations ops) + { + ops.Store(new EnrichmentTaskSummary { Id = e.TaskId, AssignedUserName = e.UserName }); + } + + public override Task EnrichEventsAsync(ComplianceQuerySession querySession, + IReadOnlyList events, CancellationToken cancellation) + { + foreach (var e in events.OfType>()) + { + e.Data.UserName = "Enriched User"; + } + + return Task.CompletedTask; + } +} + +/// +/// The reason enrichment takes a query session at all: the hook can read persisted documents before +/// the projection runs. +/// +public partial class DbLookupEnrichmentProjection: ComplianceEventProjection +{ + public void Project(EnrichmentLookupAssigned e, ComplianceOperations ops) + { + ops.Store(new EnrichmentLookupSummary { Id = e.TaskId, AssignedUserName = e.UserName }); + } + + public override async Task EnrichEventsAsync(ComplianceQuerySession querySession, + IReadOnlyList events, CancellationToken cancellation) + { + var assigned = events.OfType>().ToArray(); + if (assigned.Length == 0) + { + return; + } + + foreach (var userId in assigned.Select(x => x.Data.UserId).Distinct()) + { + var user = await querySession.LoadAsync(userId, cancellation).ConfigureAwait(false); + if (user == null) + { + continue; + } + + foreach (var e in assigned.Where(x => x.Data.UserId == userId)) + { + e.Data.UserName = user.Name; + } + } + } +} + +/// +/// Records the order in which the store calls enrichment versus projection. +/// +/// +/// The recording list is static because the suite's store configuration lives in a static delegate +/// (that is what lets the fixture skip redundant rebuilds), so the projection instance is not +/// reachable from a test method. Tests clear it before appending. +/// +public partial class EnrichmentCallOrderProjection: ComplianceEventProjection +{ + public static readonly List CallOrder = new(); + + public void Project(EnrichmentPing e, ComplianceOperations ops) + { + CallOrder.Add($"Apply:{nameof(EnrichmentPing)}"); + } + + public override Task EnrichEventsAsync(ComplianceQuerySession querySession, + IReadOnlyList events, CancellationToken cancellation) + { + if (events.OfType>().Any()) + { + CallOrder.Add(nameof(EnrichEventsAsync)); + } + + return Task.CompletedTask; + } +} + +#endregion + +/// +/// IEventEnrichment.EnrichEventsAsync runs before an inline EventProjection applies the same +/// batch, and can read from the store while it does. +/// +/// +/// Each projection owns its own event and document type on purpose: all three are registered against +/// one store, and two projections writing a summary for the same id would just overwrite each other. +/// +public abstract class EventProjectionEnrichmentCompliance + : EventStoreComplianceSuite + where TFixture : EventStoreComplianceFixture, new() + where TOperations : TQuerySession, IStorageOperations +{ + private static readonly Action _configuration = config => + { + config.SchemaName = "compliance_enrichment"; + + config.AddProjection(new SimpleEnrichmentProjection(), ProjectionLifecycle.Inline); + config.AddProjection(new DbLookupEnrichmentProjection(), ProjectionLifecycle.Inline); + config.AddProjection(new EnrichmentCallOrderProjection(), ProjectionLifecycle.Inline); + }; + + protected override Action Configuration => _configuration; + + [Fact] + public async Task enrichment_sets_data_before_apply_inline() + { + await using var session = OpenSession(); + + var taskId = Guid.NewGuid(); + EventsFor(session).StartStream(taskId, + new EnrichmentTaskAssigned { TaskId = taskId, UserId = Guid.NewGuid() }); + await SaveChangesAsync(session); + + var summary = await LoadDocumentAsync(session, taskId); + + summary.ShouldNotBeNull(); + + // The raw event carries a null UserName. Anything else means enrichment ran first. + summary.AssignedUserName.ShouldBe("Enriched User"); + } + + [Fact] + public async Task enrichment_can_read_documents_from_the_store() + { + var userId = Guid.NewGuid(); + + await using (var seeding = OpenSession()) + { + StoreDocument(seeding, new EnrichmentUser { Id = userId, Name = "Alice Smith" }); + await SaveChangesAsync(seeding); + } + + await using var session = OpenSession(); + + var taskId = Guid.NewGuid(); + EventsFor(session).StartStream(taskId, + new EnrichmentLookupAssigned { TaskId = taskId, UserId = userId }); + await SaveChangesAsync(session); + + var summary = await LoadDocumentAsync(session, taskId); + + summary.ShouldNotBeNull(); + summary.AssignedUserName.ShouldBe("Alice Smith"); + } + + [Fact] + public async Task enrichment_is_called_before_apply() + { + EnrichmentCallOrderProjection.CallOrder.Clear(); + + await using var session = OpenSession(); + + var streamId = Guid.NewGuid(); + EventsFor(session).StartStream(streamId, new EnrichmentPing { TaskId = streamId }); + await SaveChangesAsync(session); + + EnrichmentCallOrderProjection.CallOrder + .ShouldBe(new[] { "EnrichEventsAsync", $"Apply:{nameof(EnrichmentPing)}" }); + } +} diff --git a/src/JasperFx.Events.ComplianceTests/Suites/EventProjectionRegistrationCompliance.cs b/src/JasperFx.Events.ComplianceTests/Suites/EventProjectionRegistrationCompliance.cs new file mode 100644 index 0000000..6b75c30 --- /dev/null +++ b/src/JasperFx.Events.ComplianceTests/Suites/EventProjectionRegistrationCompliance.cs @@ -0,0 +1,129 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events.Projections; +using Shouldly; +using Xunit; + +namespace JasperFx.Events.ComplianceTests; + +/// +/// Document type written by an EventProjection but never registered with the store. 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 writes a document through +/// operations.Store<T>(). The source generator has to see that call and emit a +/// constructor registering as a published type — nothing else in the +/// configuration mentions it. +/// +/// +/// The record id is the stream id rather than a fresh Guid so the end-to-end test can load it back. +/// +public partial class AuditRecordProjection: ComplianceEventProjection +{ + public override ValueTask ApplyAsync(ComplianceOperations operations, IEvent e, CancellationToken cancellation) + { + switch (e.Data) + { + case AuditableEvent: + // The explicit type argument is load-bearing: the generator's scan of ApplyAsync + // bodies is syntactic, so it only sees Store/Insert/Update written with one. + operations.Store(new AuditRecord + { + Id = e.StreamId, + StreamId = e.StreamId, + EventType = e.Data.GetType().Name, + Timestamp = e.Timestamp + }); + break; + } + + return new ValueTask(); + } +} + +/// +/// The conventional route to the same registration: a Create method whose return type is the +/// published document type. +/// +public partial class AuditRecordCreatorProjection: ComplianceEventProjection +{ + public AuditRecord Create(AuditableEvent e) => new() + { + Id = Guid.NewGuid(), EventType = nameof(AuditableEvent) + }; +} + +/// +/// Document types used by an EventProjection are discovered and registered without the user +/// declaring them — whether they show up as a Store<T> call inside an explicit +/// ApplyAsync override or as the return type of a conventional Create method. +/// +public abstract class EventProjectionRegistrationCompliance + : EventStoreComplianceSuite + where TFixture : EventStoreComplianceFixture, new() + where TOperations : TQuerySession, IStorageOperations +{ + private static readonly Action _configuration = config => + { + config.SchemaName = "compliance_projection_registration"; + + // Only the ApplyAsync projection is registered: the Create-convention one is asserted as a + // bare object, and registering both would have two projections racing to write an + // AuditRecord for the same event. + config.AddProjection(new AuditRecordProjection(), ProjectionLifecycle.Inline); + }; + + protected override Action Configuration => _configuration; + + [Fact] + public void explicit_apply_async_projection_publishes_the_stored_document_type() + { + var projection = new AuditRecordProjection(); + + projection.PublishedTypes().ShouldContain(typeof(AuditRecord), + "AuditRecord should be discovered from the operations.Store() call in ApplyAsync"); + } + + [Fact] + public void conventional_create_projection_publishes_its_return_type() + { + var projection = new AuditRecordCreatorProjection(); + + projection.PublishedTypes().ShouldContain(typeof(AuditRecord), + "AuditRecord should be discovered from the Create method's return type"); + } + + [Fact] + public async Task unregistered_document_type_is_persisted_end_to_end() + { + // The assertion the registration checks above are a proxy for: if discovery worked, the + // store has real storage for AuditRecord and the inline projection can write to it. + await using var session = OpenSession(); + + var streamId = Guid.NewGuid(); + EventsFor(session).StartStream(streamId, new AuditableEvent { Description = "created" }); + await SaveChangesAsync(session); + + var record = await LoadDocumentAsync(session, streamId); + + record.ShouldNotBeNull(); + record.StreamId.ShouldBe(streamId); + record.EventType.ShouldBe(nameof(AuditableEvent)); + } +} diff --git a/src/JasperFx.Events.ComplianceTests/Suites/RebuildConcurrencyCapCompliance.cs b/src/JasperFx.Events.ComplianceTests/Suites/RebuildConcurrencyCapCompliance.cs new file mode 100644 index 0000000..a53d7f1 --- /dev/null +++ b/src/JasperFx.Events.ComplianceTests/Suites/RebuildConcurrencyCapCompliance.cs @@ -0,0 +1,109 @@ +using System; +using System.Threading.Tasks; +using Shouldly; +using Xunit; + +namespace JasperFx.Events.ComplianceTests; + +/// +/// Resolution of the per-database rebuild concurrency cap that stores surface through +/// — jasperfx#420 / marten#4710. An +/// explicit setting wins; a non-positive setting disables the cap; otherwise the store derives one +/// from its connection pool ceiling, an eighth of the pool with a floor of one. +/// +/// +/// Every test configures its own store, so this suite skips the standard build in +/// rather than paying for a store nothing uses. Pool sizes are +/// expressed store-neutrally through — the fixture +/// owns the connection string and knows whether it speaks Npgsql or SqlClient. +/// +public abstract class RebuildConcurrencyCapCompliance + : EventStoreComplianceSuite + where TFixture : EventStoreComplianceFixture, new() + where TOperations : TQuerySession, IStorageOperations +{ + private const string Schema = "compliance_rebuild_cap"; + + private static readonly Action _capOfThree = config => + { + config.SchemaName = Schema; + config.MaxConcurrentRebuildsPerDatabase = 3; + }; + + private static readonly Action _capOfZero = config => + { + config.SchemaName = Schema; + config.MaxConcurrentRebuildsPerDatabase = 0; + }; + + private static readonly Action _capOfSix = config => + { + config.SchemaName = Schema; + config.MaxConcurrentRebuildsPerDatabase = 6; + }; + + private static readonly Action _largePool = config => + { + config.SchemaName = Schema; + config.MaxPoolSize = 64; + }; + + private static readonly Action _tinyPool = config => + { + config.SchemaName = Schema; + config.MaxPoolSize = 5; + }; + + protected override Action Configuration => _capOfThree; + + /// + /// Skips the base class's standard build and per-test data cleanup: each test below builds the + /// store it needs and none of them write events. + /// + public override ValueTask InitializeAsync() => theFixture.InitializeAsync(); + + [Fact] + public async Task configured_value_wins_over_derived_default() + { + await theFixture.ConfigureAsync(_capOfThree); + + EventStore.MaxConcurrentRebuildsPerDatabase.ShouldBe(3); + } + + [Fact] + public async Task non_positive_configured_value_disables_the_cap() + { + await theFixture.ConfigureAsync(_capOfZero); + + EventStore.MaxConcurrentRebuildsPerDatabase.ShouldBeNull(); + } + + [Fact] + public async Task derived_default_is_pool_size_over_eight() + { + await theFixture.ConfigureAsync(_largePool); + + EventStore.MaxConcurrentRebuildsPerDatabase.ShouldBe(8); + } + + [Fact] + public async Task derived_default_floors_at_one_for_tiny_pools() + { + await theFixture.ConfigureAsync(_tinyPool); + + EventStore.MaxConcurrentRebuildsPerDatabase.ShouldBe(1); + } + + [Fact] + public async Task usage_descriptor_carries_the_effective_cap() + { + // jasperfx#434: CritterWatch's rebuild dispatcher reads the effective cap off the + // EventStoreUsage descriptor rather than guessing at it. + await theFixture.ConfigureAsync(_capOfSix); + + var usage = await EventStore.TryCreateUsage(Cancellation); + + usage.ShouldNotBeNull(); + usage.MaxConcurrentRebuildsPerDatabase.ShouldBe(6); + } +}