-
-
Notifications
You must be signed in to change notification settings - Fork 97
test: subscription drop/resubscribe + health; fix relational end-of-stream measure (#308, #548) #551
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
test: subscription drop/resubscribe + health; fix relational end-of-stream measure (#308, #548) #551
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
f0b6b91
test: add subscription drop/resubscribe + health tests (#308)
alexeyzimarev 8c873c5
fix: correct relational subscription end-of-stream measure (#548)
alexeyzimarev fafd6c7
fix(subscriptions): thread-safe health reports; harden drop test
alexeyzimarev 1f4457c
fix(test): assert post-recovery events by identity, not checkpoint po…
alexeyzimarev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
159 changes: 159 additions & 0 deletions
159
src/Core/test/Eventuous.Tests.Subscriptions.Base/SubscriptionDropBase.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| 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; | ||
| using static Eventuous.Sut.App.Commands; | ||
| using static Eventuous.Sut.Domain.BookingEvents; | ||
|
|
||
| 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) { | ||
| var subscriptionStarted = false; | ||
| var containerPaused = false; | ||
|
|
||
| try { | ||
| // 1. Produce and consume an initial batch; the subscription must be healthy. | ||
| await GenerateAndHandleCommands(BatchSize); | ||
| await fixture.StartSubscription(); | ||
| subscriptionStarted = true; | ||
| 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); | ||
| containerPaused = true; | ||
|
|
||
| // 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(); | ||
| WriteLine("Subscription dropped and reported unhealthy"); | ||
|
|
||
| // 4. Restore the connection. | ||
| WriteLine("Unpausing the container to restore the connection"); | ||
| await fixture.Container.UnpauseAsync(cancellationToken); | ||
| containerPaused = false; | ||
|
|
||
| // 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. Assert the specific post-recovery | ||
| // events are handled (by identity), so a replay of the initial batch on resubscribe can't | ||
| // satisfy this — only genuinely new events count. This is provider-agnostic, unlike comparing | ||
| // the committed checkpoint to GetLastPosition ($all includes system events the subscription | ||
| // skips, so its checkpoint never reaches that head). | ||
| var expected = (await GenerateAndHandleCommands(BatchSize)).Select(ToEvent).ToList(); | ||
| var resumed = await WaitUntil( | ||
| () => { | ||
| var handled = fixture.Handler.Handled; | ||
|
|
||
| return expected.All(e => handled.Contains(e)); | ||
| }, | ||
| DropTimeout, | ||
| cancellationToken | ||
| ); | ||
|
|
||
| await fixture.StopSubscription(); | ||
| subscriptionStarted = false; | ||
|
|
||
| await Assert.That(resumed).IsTrue(); | ||
| WriteLine("Processed {0} events after recovery", expected.Count); | ||
| } finally { | ||
| // Undo the destructive steps even if an assertion fails or the test is cancelled mid-flight: | ||
| // a container left paused, or a subscription left resubscribing, would contaminate later tests. | ||
| // These fixtures use autoStart=false, so fixture disposal won't stop the subscription either. | ||
| if (containerPaused) { | ||
| try { | ||
| await fixture.Container.UnpauseAsync(CancellationToken.None); | ||
| } catch (Exception ex) { | ||
| WriteLine("Cleanup: failed to unpause the container: {0}", ex.Message); | ||
| } | ||
|
Comment on lines
+103
to
+105
|
||
| } | ||
|
|
||
| if (subscriptionStarted) { | ||
| try { | ||
| await fixture.StopSubscription(); | ||
| } catch (Exception ex) { | ||
| WriteLine("Cleanup: failed to stop the subscription: {0}", ex.Message); | ||
| } | ||
|
Comment on lines
+111
to
+113
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| 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<List<ImportBooking>> 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(); | ||
| } | ||
|
|
||
| return commands; | ||
| } | ||
|
|
||
| static BookingImported ToEvent(ImportBooking cmd) => new(cmd.RoomId, cmd.Price, cmd.CheckIn, cmd.CheckOut); | ||
| } | ||
53 changes: 53 additions & 0 deletions
53
src/Core/test/Eventuous.Tests.Subscriptions.Base/SubscriptionMeasureBase.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } | ||
| } | ||
| } |
21 changes: 21 additions & 0 deletions
21
src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/SubscriptionDropTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
20 changes: 20 additions & 0 deletions
20
src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/SubscriptionDropTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
20 changes: 20 additions & 0 deletions
20
src/Postgres/test/Eventuous.Tests.Postgres/Subscriptions/SubscriptionMeasureTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
2. Health check data race
🐞 Bug☼ ReliabilitySubscriptionHealthCheck 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
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation toolsThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in fafd6c7. SubscriptionHealthCheck now stores reports in a ConcurrentDictionary, so CheckHealthAsync enumeration no longer races the ReportHealthy/ReportUnhealthy callbacks.