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
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
IEventDatabase.QueryDeadLetterEventsAsync (dead-letter row drill-in), DeadLetterEvent.TenantId,
and the tenant-aware daemon surface. Polecat implements the dead-letter row query + per-tenant
FetchDeadLetterCountsAsync below. -->
<PackageVersion Include="JasperFx" Version="2.20.0" />
<PackageVersion Include="JasperFx.Events" Version="2.20.0" />
<PackageVersion Include="JasperFx" Version="2.23.0" />
<PackageVersion Include="JasperFx.Events" Version="2.23.0" />
<!-- Pin the sibling JasperFx packages for parity with Marten 9's matrix
even though Polecat doesn't currently reference them directly —
keeps the lockstep matrix coherent when transitive resolution
Expand Down
82 changes: 82 additions & 0 deletions src/Polecat.Tests/rebuild_concurrency_cap_resolution.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using JasperFx;
using JasperFx.Events;
using Microsoft.Data.SqlClient;

namespace Polecat.Tests;

/// <summary>
/// jasperfx#420 / marten#4710 companion: resolution of the per-database rebuild
/// concurrency cap surfaced through <c>IEventStore.MaxConcurrentRebuildsPerDatabase</c>.
/// Pure unit tests — the pool-size derivation parses the connection string via
/// <see cref="SqlConnectionStringBuilder"/> without opening a connection.
/// </summary>
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<StoreOptions>? 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);
}
}
33 changes: 33 additions & 0 deletions src/Polecat/DocumentStore.EventStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,35 @@ public partial class DocumentStore : IEventStore<IDocumentSession, IQuerySession
// Type stays the provider ("SqlServer"). See polecat#207.
EventStoreIdentity IEventStore.Identity => new(Options.StoreName.ToLowerInvariant(), "SqlServer");

// jasperfx#420 / marten#4710 companion: the resolved per-database rebuild concurrency cap read
// by the CLI rebuild path (ProjectionHost.TryRebuildShardsAsync). Explicit configuration on
// StoreOptions.DaemonSettings wins; otherwise derive max(1, MaxPoolSize / 8) from the SqlClient
// connection pool so a wide store cannot exhaust the pool during a rebuild while leaving
// headroom for application traffic. Falls back to null (the historical unbounded fan-out)
// when no pool signal is reachable.
int? IEventStore.MaxConcurrentRebuildsPerDatabase
{
get
{
if (Options.DaemonSettings.MaxConcurrentRebuildsPerDatabase.HasValue)
{
var configured = Options.DaemonSettings.MaxConcurrentRebuildsPerDatabase.Value;
return configured > 0 ? configured : null;
}

try
{
using var connection = Database.CreateStorageConnection();
var poolSize = new SqlConnectionStringBuilder(connection.ConnectionString).MaxPoolSize;
return Math.Max(1, poolSize / 8);
}
catch (Exception)
{
return null;
}
}
}

Uri IEventStore.Subject => Database.DatabaseUri;

// Store-agnostic accessor for every IEventDatabase backing this store (jasperfx#387).
Expand Down Expand Up @@ -213,6 +242,10 @@ Task IEventStore.CompactStreamAsync(string streamKey, CancellationToken token)
daemon.AddValue(nameof(Options.DaemonSettings.DaemonLockId), Options.DaemonSettings.DaemonLockId);
usage.Children["DaemonSettings"] = daemon;

// jasperfx#434: surface the effective rebuild cap so monitoring tools (CritterWatch#309's
// rebuild dispatcher) can size their orchestration off the wire.
usage.MaxConcurrentRebuildsPerDatabase = ((IEventStore)this).MaxConcurrentRebuildsPerDatabase;

// OpenTelemetry child
var otel = new OptionsDescription { Subject = "Polecat.OpenTelemetryOptions" };
otel.AddValue(nameof(Options.OpenTelemetry.TrackConnections), Options.OpenTelemetry.TrackConnections);
Expand Down
Loading