Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using DotNet.Testcontainers.Containers;
using Eventuous.Subscriptions;
using Eventuous.Subscriptions.Checkpoints;
using Eventuous.Subscriptions.Diagnostics;
using Eventuous.Sut.Domain;
using Eventuous.Tests.Persistence.Base.Fixtures;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -30,9 +31,36 @@ protected SubscriptionFixtureBase(bool autoStart = true, LogLevel logLevel = Log
IMessageSubscription Subscription { get; set; } = null!;
protected internal ILoggerFactory LoggerFactory { get; set; } = null!;

/// <summary>
/// Health check fed by the subscription's subscribed/dropped callbacks. Lets tests assert that a dropped
/// subscription reports unhealthy and that it recovers to healthy after resubscription.
/// </summary>
protected internal SubscriptionHealthCheck Health { get; } = new();

/// <summary>
/// True when the subscription has detected a drop and is trying to resubscribe.
/// </summary>
public bool IsDropped => ((EventSubscription<TSubscriptionOptions>)Subscription).IsDropped;

/// <summary>
/// Returns the subscription's end-of-stream measure delegate (requires an <see cref="IMeasuredSubscription"/>).
/// </summary>
protected internal GetSubscriptionEndOfStream GetMeasure() => ((IMeasuredSubscription)Subscription).GetMeasure();

public string SubscriptionId { get; } = $"test-{Guid.NewGuid():N}";

protected internal ValueTask StartSubscription() => Subscription.SubscribeWithLog(Log);
protected internal ValueTask StartSubscription()
=> Subscription.Subscribe(
id => {
Health.ReportHealthy(id);
Log.LogInformation("{Subscription} subscribed", id);
},
(id, reason, ex) => {
Health.ReportUnhealthy(id, ex);
Log.LogWarning(ex, "{Subscription} dropped {Reason}", id, reason);
},
CancellationToken.None
);
Comment on lines +52 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Health check data race 🐞 Bug ☼ Reliability

SubscriptionHealthCheck uses a mutable Dictionary that is iterated in CheckHealthAsync while
ReportHealthy/ReportUnhealthy can mutate it from subscription callbacks; concurrent access can throw
InvalidOperationException ("Collection was modified") or produce inconsistent results. The new drop
tests poll health repeatedly during drop/resubscribe, increasing the likelihood of this crash, and
the same health check is registered as a singleton in production wiring as well.
Agent Prompt
### Issue description
`SubscriptionHealthCheck` stores subscription health in a plain `Dictionary<string, HealthReport>`. `CheckHealthAsync` enumerates that dictionary while `ReportHealthy/ReportUnhealthy` mutate it, which can throw at runtime under normal concurrent usage.

This PR newly wires subscription callbacks to `SubscriptionHealthCheck` in `SubscriptionFixtureBase` and the new `SubscriptionDropBase` polls health while the subscription is transitioning, making the race much more likely (and flaky).

### Issue Context
- Subscription callbacks run on background threads.
- Health checks can be called concurrently (e.g., by ASP.NET health endpoint / test polling).

### Fix Focus Areas
- src/Core/src/Eventuous.Subscriptions/Diagnostics/SubscriptionHealth.cs[14-43]
- src/Core/test/Eventuous.Tests.Subscriptions.Base/Fixtures/SubscriptionFixtureBase.cs[47-58]
- src/Core/test/Eventuous.Tests.Subscriptions.Base/SubscriptionDropBase.cs[49-63]

### Suggested fix
- Protect `_healthReports` with a lock for both mutation and enumeration **or** switch to `ConcurrentDictionary` and enumerate a snapshot (e.g., `foreach (var report in _healthReports.ToArray())`).
- Ensure `CheckHealthAsync` cannot throw due to concurrent updates.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


protected internal ValueTask StopSubscription() => Subscription.UnsubscribeWithLog(Log);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using DotNet.Testcontainers.Containers;
using Eventuous.Subscriptions;
using Eventuous.Subscriptions.Checkpoints;
using Eventuous.Sut.App;
using Eventuous.Tests.Persistence.Base.Fixtures;
using Microsoft.Extensions.Diagnostics.HealthChecks;

namespace Eventuous.Tests.Subscriptions.Base;

/// <summary>
/// Base test that verifies a subscription drops and resubscribes when the underlying infrastructure
/// connection is lost and later restored, and that the subscription health check reports
/// <see cref="HealthStatus.Unhealthy"/> while dropped and <see cref="HealthStatus.Healthy"/> again after
/// recovery. The connection loss is simulated by pausing the infrastructure container (Docker pause), which
/// freezes the database process while keeping the published port intact, so the same connection string keeps
/// working once the container is unpaused. Reproduces the scenario from GitHub #308 (and #307).
/// </summary>
public abstract class SubscriptionDropBase<TContainer, TSubscription, TSubscriptionOptions, TCheckpointStore>(
SubscriptionFixtureBase<TContainer, TSubscription, TSubscriptionOptions, TCheckpointStore, TestEventHandler> fixture
) : SubscriptionTestBase(fixture)
where TContainer : DockerContainer
where TSubscription : EventSubscription<TSubscriptionOptions>
where TSubscriptionOptions : SubscriptionOptions
where TCheckpointStore : class, ICheckpointStore {
const int BatchSize = 5;

// The poll/connection failure that follows a pause surfaces only after the provider's command/connection
// timeout elapses, so give detection (and recovery) a generous window.
static readonly TimeSpan DropTimeout = TimeSpan.FromSeconds(90);

/// <summary>
/// Produces and consumes events, drops the connection by pausing the container, asserts the subscription is
/// reported unhealthy, restores the connection, then asserts that the subscription resubscribes, reports
/// healthy again, and resumes processing newly produced events.
/// </summary>
protected async Task ShouldResubscribeAfterConnectionDrop(CancellationToken cancellationToken) {
// 1. Produce and consume an initial batch; the subscription must be healthy.
await GenerateAndHandleCommands(BatchSize);
await fixture.StartSubscription();
var consumedInitial = await WaitUntil(() => fixture.Handler.Count >= BatchSize, DropTimeout, cancellationToken);
await Assert.That(consumedInitial).IsTrue();
await Assert.That(await GetHealthStatus(cancellationToken)).IsEqualTo(HealthStatus.Healthy);

// 2. Drop the connection by pausing the container.
WriteLine("Pausing the container to drop the connection");
await fixture.Container.PauseAsync(cancellationToken);

// 3. The subscription must detect the drop and report unhealthy.
var dropped = await WaitUntil(() => fixture.IsDropped, DropTimeout, cancellationToken);
await Assert.That(dropped).IsTrue();
var unhealthy = await WaitUntil(async () => await GetHealthStatus(cancellationToken) == HealthStatus.Unhealthy, DropTimeout, cancellationToken);
await Assert.That(unhealthy).IsTrue();
Comment on lines +51 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Dropped health uses unhealthy 📎 Requirement gap ◔ Observability

The new drop/resubscribe tests and the SubscriptionHealthCheck report HealthStatus.Unhealthy
when a subscription is dropped, but the compliance requirement mandates HealthStatus.Degraded.
This prevents operators from distinguishing degraded subscription state from fully unhealthy system
state per the checklist.
Agent Prompt
## Issue description
When a subscription is dropped, the health check currently reports `Unhealthy` and the new tests assert `Unhealthy`. Compliance requires reporting `Degraded` for dropped subscriptions.

## Issue Context
`SubscriptionHealthCheck` is used to surface subscription state via .NET health checks. A dropped subscription should yield `HealthStatus.Degraded` (and return to `Healthy` after recovery), and tests should assert this behavior.

## Fix Focus Areas
- src/Core/src/Eventuous.Subscriptions/Diagnostics/SubscriptionHealth.cs[17-36]
- src/Core/test/Eventuous.Tests.Subscriptions.Base/SubscriptionDropBase.cs[10-64]
- src/Core/test/Eventuous.Tests.Subscriptions.Base/Fixtures/SubscriptionFixtureBase.cs[34-58]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

WriteLine("Subscription dropped and reported unhealthy");

// 4. Restore the connection.
WriteLine("Unpausing the container to restore the connection");
await fixture.Container.UnpauseAsync(cancellationToken);

// 5. The subscription must resubscribe and report healthy again.
var recovered = await WaitUntil(() => !fixture.IsDropped, DropTimeout, cancellationToken);
await Assert.That(recovered).IsTrue();
var healthy = await WaitUntil(async () => await GetHealthStatus(cancellationToken) == HealthStatus.Healthy, DropTimeout, cancellationToken);
await Assert.That(healthy).IsTrue();
WriteLine("Subscription resubscribed and reported healthy");

// 6. Events produced after recovery must be processed.
var countBeforeRecovery = fixture.Handler.Count;
await GenerateAndHandleCommands(BatchSize);
var resumed = await WaitUntil(() => fixture.Handler.Count >= countBeforeRecovery + BatchSize, DropTimeout, cancellationToken);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Assert the recovered subscription handles the new events

In the paused-container scenario, the initial five events may not have been flushed to the checkpoint before the drop (the default checkpoint batch/delay is much larger than this batch), and resubscribe reports healthy before any backlog replay completes. If that happens, replaying the same initial events can advance Handler.Count by BatchSize and satisfy this wait before the events generated on the previous line are ever consumed, so the test can pass while recovery is still not processing post-recovery writes. Please wait on distinct event identities or force/await the checkpoint before dropping.

Useful? React with 👍 / 👎.


await fixture.StopSubscription();

Comment on lines +44 to +72

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Paused container not restored 🐞 Bug ☼ Reliability

SubscriptionDropBase pauses the infrastructure container and stops the subscription only on the
success path; if an assertion fails or the test is cancelled between pause and unpause, the
container can remain paused and the subscription can remain running, contaminating subsequent tests.
This is amplified here because the new drop tests construct fixtures with autoStart=false, so
fixture disposal won’t automatically stop the subscription.
Agent Prompt
### Issue description
`ShouldResubscribeAfterConnectionDrop` performs destructive operations (pause container) but does not guarantee cleanup (unpause + unsubscribe) if the test fails mid-flight. This can leave the container paused and/or a subscription resubscribe loop running, causing cascading failures and hangs in later tests.

### Issue Context
The drop tests pass `autoStart=false` into the fixture constructors, and `SubscriptionFixtureBase.DisposeAsync` only calls `StopSubscription()` when `_autoStart` is true, so relying on fixture disposal is insufficient.

### Fix Focus Areas
- src/Core/test/Eventuous.Tests.Subscriptions.Base/SubscriptionDropBase.cs[36-75]
- src/Core/test/Eventuous.Tests.Subscriptions.Base/Fixtures/SubscriptionFixtureBase.cs[95-103]

### Suggested fix
- Wrap the pause/unpause + subscription start/stop flow in `try/finally`.
- In `finally`:
  - Attempt to `UnpauseAsync` if the container was paused (use `CancellationToken.None` so cleanup still runs even when the test token is cancelled).
  - Call `fixture.StopSubscription()` if it was started.
- Optionally harden `SubscriptionFixtureBase.DisposeAsync` to stop the subscription if it is running even when `_autoStart` is false (defensive cleanup).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

await Assert.That(resumed).IsTrue();
WriteLine("Processed {0} events after recovery", fixture.Handler.Count - countBeforeRecovery);
}

async Task<HealthStatus> GetHealthStatus(CancellationToken cancellationToken) {
var result = await fixture.Health.CheckHealthAsync(new(), cancellationToken);

return result.Status;
}

static Task<bool> WaitUntil(Func<bool> condition, TimeSpan timeout, CancellationToken cancellationToken)
=> WaitUntil(() => Task.FromResult(condition()), timeout, cancellationToken);

static async Task<bool> WaitUntil(Func<Task<bool>> condition, TimeSpan timeout, CancellationToken cancellationToken) {
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);

try {
while (!await condition()) {
await Task.Delay(200, cts.Token);
}

return true;
} catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) {
return false;
}
}

async Task GenerateAndHandleCommands(int count) {
var commands = Enumerable
.Range(0, count)
.Select(_ => DomainFixture.CreateImportBooking())
.ToList();

var service = new BookingService(fixture.EventStore);

foreach (var cmd in commands) {
var result = await service.Handle(cmd, default);
result.ThrowIfError();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using DotNet.Testcontainers.Containers;
using Eventuous.Subscriptions;
using Eventuous.Subscriptions.Checkpoints;
using Eventuous.Subscriptions.Diagnostics;
using Eventuous.Sut.App;
using Eventuous.Tests.Persistence.Base.Fixtures;

namespace Eventuous.Tests.Subscriptions.Base;

/// <summary>
/// Base test for the subscription end-of-stream measure used by the gap/lag diagnostics. Verifies that the
/// measure reports a valid position both on an empty store and after events are appended. Reproduces GitHub
/// #548, where the relational implementation queried the wrong column, threw on 32-bit position columns, and
/// failed on the <c>NULL</c> returned by <c>MAX(...)</c> over an empty table.
/// </summary>
public abstract class SubscriptionMeasureBase<TContainer, TSubscription, TSubscriptionOptions, TCheckpointStore>(
SubscriptionFixtureBase<TContainer, TSubscription, TSubscriptionOptions, TCheckpointStore, TestEventHandler> fixture
) : SubscriptionTestBase(fixture)
where TContainer : DockerContainer
where TSubscription : EventSubscription<TSubscriptionOptions>
where TSubscriptionOptions : SubscriptionOptions
where TCheckpointStore : class, ICheckpointStore {
protected async Task ShouldMeasureEndOfStream(CancellationToken cancellationToken) {
var measure = fixture.GetMeasure();

// An empty store must yield a valid measure at position zero, not EndOfStream.Invalid.
var empty = await measure(cancellationToken);
await Assert.That(empty.SubscriptionId).IsEqualTo(fixture.SubscriptionId);
await Assert.That(empty.Position).IsEqualTo(0ul);

// After appending events, the measure must report the global end position.
await GenerateAndHandleCommands(10);
var last = await fixture.GetLastPosition();

var measured = await measure(cancellationToken);
await Assert.That(measured.SubscriptionId).IsEqualTo(fixture.SubscriptionId);
await Assert.That(measured.Position).IsEqualTo(last);
}

async Task GenerateAndHandleCommands(int count) {
var commands = Enumerable
.Range(0, count)
.Select(_ => DomainFixture.CreateImportBooking())
.ToList();

var service = new BookingService(fixture.EventStore);

foreach (var cmd in commands) {
var result = await service.Handle(cmd, default);
result.ThrowIfError();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Eventuous.KurrentDB.Subscriptions;
using Eventuous.Tests.KurrentDB.Subscriptions.Fixtures;
using Eventuous.Tests.Subscriptions.Base;
using Testcontainers.KurrentDb;

namespace Eventuous.Tests.KurrentDB.Subscriptions;

public class SubscriptionDrop()
: SubscriptionDropBase<KurrentDbContainer, AllStreamSubscription, AllStreamSubscriptionOptions, TestCheckpointStore>(
new CatchUpSubscriptionFixture<AllStreamSubscription, AllStreamSubscriptionOptions, TestEventHandler>(
_ => { },
new("$all"),
false
)
) {
[Test]
[Retry(3)]
public async Task Esdb_ShouldResubscribeAfterConnectionDrop(CancellationToken cancellationToken) {
await ShouldResubscribeAfterConnectionDrop(cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Eventuous.Postgresql;
using Eventuous.Postgresql.Subscriptions;
using Eventuous.Tests.Subscriptions.Base;
using Testcontainers.PostgreSql;

namespace Eventuous.Tests.Postgres.Subscriptions;

[NotInParallel]
public class SubscriptionDrop()
: SubscriptionDropBase<PostgreSqlContainer, PostgresAllStreamSubscription, PostgresAllStreamSubscriptionOptions, PostgresCheckpointStore>(
new SubscriptionFixture<PostgresStore, PostgresAllStreamSubscription, PostgresAllStreamSubscriptionOptions, TestEventHandler>(
_ => { },
false
)
) {
[Test]
public async Task Postgres_ShouldResubscribeAfterConnectionDrop(CancellationToken cancellationToken) {
await ShouldResubscribeAfterConnectionDrop(cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Eventuous.Postgresql;
using Eventuous.Postgresql.Subscriptions;
using Eventuous.Tests.Subscriptions.Base;
using Testcontainers.PostgreSql;

namespace Eventuous.Tests.Postgres.Subscriptions;

[NotInParallel]
public class SubscriptionMeasure()
: SubscriptionMeasureBase<PostgreSqlContainer, PostgresAllStreamSubscription, PostgresAllStreamSubscriptionOptions, PostgresCheckpointStore>(
new SubscriptionFixture<PostgresStore, PostgresAllStreamSubscription, PostgresAllStreamSubscriptionOptions, TestEventHandler>(
_ => { },
false
)
) {
[Test]
public async Task Postgres_ShouldMeasureEndOfStream(CancellationToken cancellationToken) {
await ShouldMeasureEndOfStream(cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -341,16 +341,20 @@ async ValueTask<EndOfStream> GetSubscriptionEndOfStream(CancellationToken cancel
cmd.CommandType = CommandType.Text;

cmd.CommandText = Kind switch {
SubscriptionKind.All => GetEndOfStream,
SubscriptionKind.Stream => GetEndOfAll
SubscriptionKind.All => GetEndOfAll,
SubscriptionKind.Stream => GetEndOfStream
};
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).NoContext();

var position = await reader.ReadAsync(cancellationToken).NoContext() ? reader.GetInt64(0) : 0;
// MAX(...) returns NULL on an empty table, and providers may return the position column as either
// Int32 or Int64, so guard against DBNull and convert rather than calling the strict GetInt64.
var position = await reader.ReadAsync(cancellationToken).NoContext() && reader[0] is not DBNull
? Convert.ToInt64(reader[0])
: 0;

return new(SubscriptionId, (ulong)position, DateTime.UtcNow);
} catch (Exception) {
Log.WarnLog?.Log("Failed to get end of stream");
} catch (Exception e) {
Log.WarnLog?.Log(e, "Failed to get end of stream");

return EndOfStream.Invalid;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Eventuous.SqlServer.Subscriptions;
using Eventuous.Tests.Subscriptions.Base;
using Testcontainers.MsSql;

namespace Eventuous.Tests.SqlServer.Subscriptions;

[NotInParallel]
public class SubscriptionDrop()
: SubscriptionDropBase<MsSqlContainer, SqlServerAllStreamSubscription, SqlServerAllStreamSubscriptionOptions, SqlServerCheckpointStore>(
new SubscriptionFixture<SqlServerAllStreamSubscription, SqlServerAllStreamSubscriptionOptions, TestEventHandler>(
_ => { },
false
)
) {
[Test]
public async Task SqlServer_ShouldResubscribeAfterConnectionDrop(CancellationToken cancellationToken) {
await ShouldResubscribeAfterConnectionDrop(cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Eventuous.SqlServer.Subscriptions;
using Eventuous.Tests.Subscriptions.Base;
using Testcontainers.MsSql;

namespace Eventuous.Tests.SqlServer.Subscriptions;

[NotInParallel]
public class SubscriptionMeasure()
: SubscriptionMeasureBase<MsSqlContainer, SqlServerAllStreamSubscription, SqlServerAllStreamSubscriptionOptions, SqlServerCheckpointStore>(
new SubscriptionFixture<SqlServerAllStreamSubscription, SqlServerAllStreamSubscriptionOptions, TestEventHandler>(
_ => { },
false
)
) {
[Test]
public async Task SqlServer_ShouldMeasureEndOfStream(CancellationToken cancellationToken) {
await ShouldMeasureEndOfStream(cancellationToken);
}
}
Loading