Public Access
Server: start a retried write transaction from the database, not from the attempt that failed
This commit is contained in:
1 parent
36fb0817a4
commit
b2516ca7fb
8 files changed
+173
-21
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user