feat(operations): add capacity and resilience gates (#18)

This commit is contained in:
KyuubiYoru
2026-07-16 15:57:01 +02:00
parent 08729ae25c
commit 609dad7cf1
21 changed files with 1759 additions and 107 deletions
@@ -1,3 +1,6 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
@@ -19,6 +22,49 @@ namespace FinalFactory.Rendezvous.Tests.Client;
public sealed class RendezvousClientIntegrationTests
{
[Fact]
public async Task RestartReturnsTypedUnavailabilityThenAllowsHostReregistration()
{
int port = ReserveTcpPort();
string address = $"http://127.0.0.1:{port}";
using HttpClient client = new() { BaseAddress = new Uri(address) };
RendezvousClientOptions noRetry = new()
{
MaximumSafeRetries = 0,
RequestTimeout = TimeSpan.FromSeconds(1),
};
ClientTestHost first = await ClientTestHost.StartAsync(address);
RendezvousPublisherClient publisher = new(client, noRetry);
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
CreateRegistration(100),
first.PublisherCredential);
PublishedSession initialSession = AssertSuccess(registered);
BindPresence(first, initialSession, 41_100);
RendezvousSessionBrowserClient browser = new(client, noRetry);
BrowseSessionsResponse beforeRestart = AssertSuccess(await browser.BrowseAsync(BrowseRequest()));
Assert.Equal(initialSession.ListingId, Assert.Single(beforeRestart.Items).ListingId);
Stopwatch restart = Stopwatch.StartNew();
await first.DisposeAsync();
RendezvousClientResult<BrowseSessionsResponse> unavailable = await browser.BrowseAsync(BrowseRequest());
Assert.False(unavailable.IsSuccess);
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, unavailable.Error);
await using ClientTestHost second = await ClientTestHost.StartAsync(address);
RendezvousClientResult<PublishedSession> reregistered = await publisher.RegisterAsync(
CreateRegistration(101),
second.PublisherCredential);
PublishedSession replacementSession = AssertSuccess(reregistered);
Assert.NotEqual(initialSession.ListingId, replacementSession.ListingId);
BindPresence(second, replacementSession, 41_101);
BrowseSessionsResponse afterRestart = AssertSuccess(await browser.BrowseAsync(BrowseRequest()));
Assert.Equal(replacementSession.ListingId, Assert.Single(afterRestart.Items).ListingId);
Assert.DoesNotContain(afterRestart.Items, item => item.ListingId == initialSession.ListingId);
Assert.True(
restart.Elapsed < TimeSpan.FromSeconds(5),
$"Local restart and host re-registration took {restart.Elapsed}.");
}
[Fact]
public async Task PublisherAndBrowserClientsCompleteTheRealSessionLifecycleAndPaging()
{
@@ -102,6 +148,27 @@ public sealed class RendezvousClientIntegrationTests
return Assert.IsAssignableFrom<T>(result.Value);
}
private static void BindPresence(ClientTestHost host, PublishedSession session, int port)
{
Assert.True(host.Capabilities.TryFingerprint(
session.HostPresenceCapability,
out SecretFingerprint fingerprint));
Assert.Equal(StoreResultCode.Success, host.Store.BindHostPresence(new(
session.HostPresenceHandle,
fingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.80", port),
null)).Code);
}
private static BrowseSessionsRequest BrowseRequest() => new()
{
GameId = new("space-game"),
EnvironmentId = new("production"),
RegionId = new("eu-central"),
ProtocolVersion = 7,
PageSize = 10,
};
private static RegisterSessionRequest CreateRegistration(int index) => new()
{
IdempotencyKey = $"sdk-integration-{index}",
@@ -139,7 +206,7 @@ public sealed class RendezvousClientIntegrationTests
internal EphemeralCapabilityIssuer Capabilities { get; }
internal string PublisherCredential { get; }
internal static async Task<ClientTestHost> StartAsync()
internal static async Task<ClientTestHost> StartAsync(string? bindAddress = null)
{
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
EphemeralStoreOptions stateOptions = new();
@@ -153,7 +220,7 @@ public sealed class RendezvousClientIntegrationTests
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.WebHost.UseUrls(bindAddress ?? "http://127.0.0.1:0");
builder.Services.ConfigureHttpJsonOptions(static options =>
ContractJson.Configure(options.SerializerOptions));
builder.Services.Configure<RouteHandlerOptions>(static options =>
@@ -180,10 +247,10 @@ public sealed class RendezvousClientIntegrationTests
app.MapRendezvousContractEndpoints();
await app.StartAsync();
IServer server = app.Services.GetRequiredService<IServer>();
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
string serviceAddress = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
return new(
app,
new HttpClient { BaseAddress = new Uri(address) },
new HttpClient { BaseAddress = new Uri(serviceAddress) },
store,
capabilities,
credential);
@@ -196,4 +263,13 @@ public sealed class RendezvousClientIntegrationTests
await _application.DisposeAsync();
}
}
private static int ReserveTcpPort()
{
TcpListener listener = new(IPAddress.Loopback, 0);
listener.Start();
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}