fix(communication): key gRPC client cache by (site, endpoint) so per-session failover cannot dispose shared channels

This commit is contained in:
Joseph Doherty
2026-07-08 17:33:03 -04:00
parent 76c10c5de6
commit ca5e79e922
2 changed files with 83 additions and 57 deletions
@@ -81,19 +81,47 @@ public class SiteStreamGrpcClientFactoryTests
}
[Fact]
public void GetOrCreate_EndpointChanged_DisposesPriorClient()
public void GetOrCreate_DifferentEndpointSameSite_KeepsBothClientsAlive()
{
// Communication-013 regression: a later edit to a site's gRPC address must
// invalidate (and dispose) the stale cached client, so the corrected
// endpoint takes effect without a central restart.
// Arch review 02 (High): the cache is keyed by (site, endpoint). Both node
// channels for a site coexist, so a per-session NodeA→NodeB failover flip
// must NOT dispose the other endpoint's channel — another debug session may
// be using it. (Supersedes Communication-013's dispose-on-endpoint-change.)
using var factory = new TrackingEndpointFactory();
var first = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5100");
var second = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5200");
var a = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5100");
var b = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5200");
Assert.NotSame(first, second);
Assert.True(first.Disposed, "stale client for the old endpoint should be disposed");
Assert.False(second.Disposed, "fresh client for the new endpoint should still be live");
Assert.NotSame(a, b);
Assert.False(a.Disposed, "the other endpoint's channel must stay alive");
Assert.Same(a, factory.GetOrCreate("site-a", "http://localhost:5100")); // still cached
}
[Fact]
public void TryGet_ReturnsCachedClientOrNull_WithoutCreating()
{
using var factory = new TrackingEndpointFactory();
Assert.Null(factory.TryGet("site-a", "http://localhost:5100"));
var a = factory.GetOrCreate("site-a", "http://localhost:5100");
Assert.Same(a, factory.TryGet("site-a", "http://localhost:5100"));
Assert.Null(factory.TryGet("site-a", "http://localhost:5200")); // other endpoint not cached
Assert.Equal(1, factory.CreatedCount); // TryGet never creates
}
[Fact]
public async Task RemoveSiteAsync_DisposesAllEndpointsForTheSite_OnlyThatSite()
{
using var factory = new TrackingEndpointFactory();
var a = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5100");
var b = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5200");
var other = (TrackingEndpointClient)factory.GetOrCreate("site-b", "http://localhost:5100");
await factory.RemoveSiteAsync("site-a");
Assert.True(a.Disposed);
Assert.True(b.Disposed);
Assert.False(other.Disposed);
}
[Fact]
@@ -126,7 +154,11 @@ public class SiteStreamGrpcClientFactoryTests
private sealed class TrackingEndpointFactory : SiteStreamGrpcClientFactory
{
public TrackingEndpointFactory() : base(NullLoggerFactory.Instance) { }
public int CreatedCount { get; private set; }
protected override SiteStreamGrpcClient CreateClient(string grpcEndpoint)
=> new TrackingEndpointClient(grpcEndpoint);
{
CreatedCount++;
return new TrackingEndpointClient(grpcEndpoint);
}
}
}