Server: start a retried write transaction from the database, not from the attempt that failed

This commit is contained in:
jaap-jan committed 2026-10-02 14:15:52 +02:00
1 parent 36fb0817a4
commit b2516ca7fb
8 files changed
+173 -21

No files matched your search

+9 -1
View File
@@ -14,6 +14,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;
using Xunit;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace DodoSSH.Api.Tests;
@@ -108,9 +109,16 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
Environment.SetEnvironmentVariable(name, value);
}
builder.ConfigureTestServices(services => services.AddSingleton<IRelayNetwork>(RelayNetwork));
builder.ConfigureTestServices(services =>
{
services.AddSingleton<IRelayNetwork>(RelayNetwork);
services.AddSingleton<IInterceptor>(Commits);
});
}
/// <summary>Fails a commit on request, so a test can watch the retrying execution strategy at work.</summary>
internal FlakyCommits Commits { get; } = new();
/// <summary>
/// What the relay resolves and dials, in place of the machine's own resolver and sockets.
/// </summary>
+43
View File
@@ -0,0 +1,43 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace DodoSSH.Api.Tests;
/// <summary>
/// Fails the next commit the way a connection dropped on the way to the server would, when asked to.
/// </summary>
/// <remarks>
/// Before the commit reaches the database, so the transaction is rolled back — which is what a lost commit
/// looks like from the server's side — and with a <see cref="TimeoutException"/>, which the retrying
/// execution strategy treats as transient. That is the only way to watch a retry happen against a real
/// database, and the retry is the thing worth watching: see <c>FreshAttempts</c>.
/// </remarks>
internal sealed class FlakyCommits : DbTransactionInterceptor
{
private int armed;
private int failed;
/// <summary>How many commits this has failed.</summary>
internal int Failed => Volatile.Read(ref failed);
/// <summary>Makes the next commit, whoever's it is, fail once.</summary>
internal void FailNextCommit() => Interlocked.Exchange(ref armed, 1);
/// <inheritdoc />
public override ValueTask<InterceptionResult> TransactionCommittingAsync(
DbTransaction transaction,
TransactionEventData eventData,
InterceptionResult result,
CancellationToken cancellationToken = default)
{
if (Interlocked.Exchange(ref armed, 0) == 1)
{
Interlocked.Increment(ref failed);
throw new TimeoutException("The commit was lost on its way to the server.");
}
return base.TransactionCommittingAsync(transaction, eventData, result, cancellationToken);
}
}
@@ -647,6 +647,38 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
.Role.ShouldBe(TeamMemberRole.Owner);
}
/// <remarks>
/// The commit lost on its way to the database, which the execution strategy answers by running the
/// transaction again on the same context. That context still held the two memberships as the failed
/// attempt left them — already owner, already admin, and marked unchanged — so the retry saved nothing,
/// committed, and answered 204 over a team whose roles had not moved. See <c>FreshAttempts</c>.
/// </remarks>
[Fact]
public async Task ATransferWhoseCommitIsLost_IsRetriedFromTheDatabase_AndStillLands()
{
var owner = await EnrolledClientAsync("retry-owner", "rowner@example.com");
await EnrolledClientAsync("retry-successor", "rsuccessor@example.com");
var team = await CreateTeamAsync(owner, "Retried handover");
var entry = await LookupAsync(owner, "rsuccessor@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var failedBefore = fixture.Commits.Failed;
fixture.Commits.FailNextCommit();
var response = await owner.PostContractAsync(
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
fixture.Commits.Failed.ShouldBe(failedBefore + 1, "the first commit has to have been lost for this to mean anything");
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(owner, MembersUrl(team.TeamId));
members.Where(row => row.UserId == entry.UserId).ShouldHaveSingleItem().Role.ShouldBe(TeamMemberRole.Owner);
members.Count(row => row.Role == TeamMemberRole.Owner).ShouldBe(1);
}
/// <remarks>
/// The thing that was impossible before this endpoint existed. An owner could not be removed and
/// could not be demoted, so somebody who left the company owning a team left it owned by them for