-
-
Notifications
You must be signed in to change notification settings - Fork 99
fix(subscriptions): release relational gaps once the skip timeout expires #588
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
Changes from 1 commit
b74e9e7
81d7a80
457919f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| // Copyright (C) Eventuous HQ OÜ. All rights reserved | ||
| // Licensed under the Apache License, Version 2.0. | ||
|
|
||
| using Eventuous.Postgresql.Subscriptions; | ||
| using Eventuous.Tests.Persistence.Base.Fixtures; | ||
| using Eventuous.Tests.Subscriptions.Base; | ||
|
|
||
| namespace Eventuous.Tests.Postgres.Subscriptions; | ||
|
|
||
| [NotInParallel] | ||
| public class GapSkipTimeoutTest() : SubscriptionTestBase(Fixture) { | ||
| static readonly TombstonesFixture Fixture = new(ConfigureOptions); | ||
|
|
||
| [Test] | ||
| public async Task ShouldSkipGapAfterSkipTimeout(CancellationToken cancellationToken) { | ||
| var streamName = new StreamName("test-stream-gap-skip"); | ||
|
|
||
| await Fixture.AppendEvents(streamName, [.. Fixture.CreateEvents(2)], ExpectedStreamVersion.NoStream); | ||
|
|
||
| // The rolled back append burnt a global position that no transaction will ever fill, | ||
| // so the gap can only be released by the skip timeout. | ||
| await Fixture.InsertGap(streamName, 1); | ||
|
|
||
| await Fixture.AppendEvents(streamName, [.. Fixture.CreateEvents(3)], ExpectedStreamVersion.Any); | ||
|
|
||
| await Fixture.StartSubscription(); | ||
|
|
||
| await Fixture.Handler.AssertThat() | ||
| .Timebox(TimeSpan.FromSeconds(10)) | ||
| .Exactly(5) | ||
| .Match(_ => true) | ||
| .Validate(cancellationToken); | ||
|
|
||
| await Fixture.StopSubscription(); | ||
|
|
||
| var tombstonesCount = await Fixture.CountTombstones(); | ||
| await Assert.That(tombstonesCount).IsEqualTo(0); | ||
| } | ||
|
|
||
| static void ConfigureOptions(PostgresAllStreamSubscriptionOptions options) { | ||
| options.GapSkipTimeoutMs = 500; // the gap must hold the subscription for this long, then be skipped | ||
| options.GapAgeThresholdMs = null; // never release a gap by age, so the skip timeout is the only way out | ||
| options.GapHandlingTimeoutMs = null; // no tombstones | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -180,21 +180,22 @@ async Task ExecutePollCycle() { | |
| DetectedGap? DetectGap(long start, PersistedEvent persistedEvent, DetectedGap? previousGap) { | ||
| var expectedNext = start < 0 ? 1 : start + 1; // global position identity starts at 1 | ||
|
|
||
| if (persistedEvent.GlobalPosition > expectedNext) { | ||
| if (previousGap != null) { | ||
| if (Options.GapSkipTimeoutMs == null || (DateTime.UtcNow - previousGap.FirstSeen) < TimeSpan.FromMilliseconds(Options.GapSkipTimeoutMs.Value)) { | ||
| return previousGap; | ||
| } | ||
| } | ||
| if (persistedEvent.GlobalPosition <= expectedNext) return null; | ||
|
|
||
| var newGapAge = DateTime.UtcNow - persistedEvent.Created; | ||
| // The gap we are already holding. Keep the original FirstSeen, so the skip timeout can actually expire: | ||
| // reporting it as a new gap would restart the timer on every poll and hold the subscription forever | ||
| // on a position no transaction will ever fill (a rolled back append still consumes the sequence value). | ||
| if (previousGap?.Position == expectedNext) { | ||
| if (Options.GapSkipTimeoutMs == null) return previousGap; | ||
|
|
||
| if (Options.GapAgeThresholdMs == null || newGapAge.TotalMilliseconds < Options.GapAgeThresholdMs.Value) { | ||
| return new(expectedNext, DateTime.UtcNow); | ||
| } | ||
| return DateTime.UtcNow - previousGap.FirstSeen < TimeSpan.FromMilliseconds(Options.GapSkipTimeoutMs.Value) ? previousGap : null; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When PostgreSQL sets Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed, and reproduced: with Fixed in 81d7a80 by tracking remediation on the gap and giving it precedence over the skip: if (DateTime.UtcNow - previousGap.FirstSeen < TimeSpan.FromMilliseconds(Options.GapSkipTimeoutMs.Value)) return previousGap;
// Remediation resolves the position safely, unlike skipping it, so it gets its chance first.
return Options.GapHandlingTimeoutMs != null && !previousGap.RemediationAttempted ? previousGap : null;
|
||
| } | ||
|
|
||
| return null; | ||
| var newGapAge = DateTime.UtcNow - persistedEvent.Created; | ||
|
|
||
| return Options.GapAgeThresholdMs == null || newGapAge.TotalMilliseconds < Options.GapAgeThresholdMs.Value | ||
| ? new DetectedGap(expectedNext, DateTime.UtcNow) | ||
| : null; | ||
| } | ||
|
|
||
| /// <summary> | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.