fix(mesh): memoize dialer/provider on first use — a lost PreStart race self-heals at the first refresh (review-prescribed)
This commit is contained in:
+15
-3
@@ -73,6 +73,18 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
private IMeshCommandDialer? _dialer;
|
||||
private IMeshPskProvider? _pskProvider;
|
||||
|
||||
// Memoize-on-first-use, NOT PreStart-only: PreStart runs on a dispatcher thread concurrently
|
||||
// with the builder's remaining starters, so its resolve can lose a millisecond-wide race
|
||||
// against AuditWriterActorKey's registration (starter #4) and Resolve() would swallow that
|
||||
// into a PERMANENT silent null — the known-cluster gate never published, fail-open. These
|
||||
// properties retry on first use instead; the first RefreshContacts arrives as a message,
|
||||
// long after every starter finished, so a lost boot race self-heals there. Actor-thread
|
||||
// only, so ??= needs no synchronisation; both are container singletons, so a repeat resolve
|
||||
// is a dictionary hit.
|
||||
private IMeshPskProvider? PskProvider => _pskProvider ??= Resolve(_pskProviderFactory, nameof(IMeshPskProvider));
|
||||
|
||||
private IMeshCommandDialer? Dialer => _dialer ??= Resolve(_dialerFactory, nameof(IMeshCommandDialer));
|
||||
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private readonly bool _useClusterClient;
|
||||
private readonly bool _useGrpc;
|
||||
@@ -382,7 +394,7 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
var commandType = cmd.Message.GetType().Name;
|
||||
|
||||
var dialer = _dialer;
|
||||
var dialer = Dialer;
|
||||
if (dialer is null)
|
||||
{
|
||||
_log.Error(
|
||||
@@ -796,8 +808,8 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
|
||||
/// </remarks>
|
||||
private void PublishKnownClusters(ContactsLoaded msg)
|
||||
{
|
||||
_pskProvider?.SetKnownClusters(msg.ClusterIds);
|
||||
_pskProvider?.InvalidateAll();
|
||||
PskProvider?.SetKnownClusters(msg.ClusterIds);
|
||||
PskProvider?.InvalidateAll();
|
||||
}
|
||||
|
||||
/// <summary>Stops a cluster's existing client and creates a replacement for its new contact set.</summary>
|
||||
|
||||
+29
-13
@@ -80,24 +80,35 @@ public sealed class ControlPlaneStarterOrderTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_deferred_dependencies_are_resolved_and_used_once_the_host_is_up()
|
||||
[Theory]
|
||||
[InlineData(MeshTransportOptions.ModeDps)]
|
||||
[InlineData(MeshTransportOptions.ModeClusterClient)]
|
||||
[InlineData(MeshTransportOptions.ModeGrpc)]
|
||||
public async Task The_deferred_dependencies_are_resolved_and_used_once_the_host_is_up(string mode)
|
||||
{
|
||||
// Deferring must not become "never resolved" — that would trade a loud boot failure for a
|
||||
// silent one, with the known-cluster gate permanently unpublished and the dialer permanently
|
||||
// absent. So this asserts the far end of the deferral: the actor resolved the PSK provider
|
||||
// FROM THE CONTAINER and drove it, through the ordinary contact-refresh path.
|
||||
// FROM THE CONTAINER and drove it, through the ordinary contact-refresh path. A Theory over
|
||||
// all three modes because publication is mode-independent (the gate follows the endpoint).
|
||||
//
|
||||
// The provider is registered as a TYPE with an ISecretResolver constructor, so the resolve
|
||||
// walks the same registry-bound chain the production MeshPskProvider does — which makes
|
||||
// this fact the race detector for the PreStart-vs-starter-#4 window: a lost race that
|
||||
// stayed null forever would never publish, and the TaskCompletionSource below would time
|
||||
// out red. (Memoize-on-first-use in the actor is what heals the lost race at the first
|
||||
// RefreshContacts message.)
|
||||
//
|
||||
// The signal is a TaskCompletionSource rather than a counter, because PreStart runs on the
|
||||
// actor's own thread and the contact load is a piped DB read: there is no instant after
|
||||
// StartAsync at which a synchronous assertion is correct.
|
||||
var signal = new SignallingPskProvider();
|
||||
using var host = BuildAdminHost(MeshTransportOptions.ModeGrpc, signal);
|
||||
using var host = BuildAdminHost(mode, useSignallingProvider: true);
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var signal = (SignallingPskProvider)host.Services.GetRequiredService<IMeshPskProvider>();
|
||||
await signal.Published.WaitAsync(TimeSpan.FromSeconds(20));
|
||||
}
|
||||
finally
|
||||
@@ -111,13 +122,15 @@ public sealed class ControlPlaneStarterOrderTests
|
||||
/// starter registration, with the mesh dependency graph wired as it is on a real central node.
|
||||
/// </summary>
|
||||
/// <param name="mode">The mesh transport mode to boot under.</param>
|
||||
/// <param name="pskProvider">
|
||||
/// Substitute for the real <see cref="MeshPskProvider"/>. Only the resolution test supplies
|
||||
/// one; the boot test deliberately keeps the production type so the graph it walks is the
|
||||
/// production graph.
|
||||
/// <param name="useSignallingProvider">
|
||||
/// Substitutes <see cref="SignallingPskProvider"/> for the real <see cref="MeshPskProvider"/>.
|
||||
/// Only the resolution test sets it; the boot test keeps the production type so the graph it
|
||||
/// walks is the production graph. The signalling provider is registered as a factory taking
|
||||
/// <see cref="ISecretResolver"/> from the container, so its resolution walks the same
|
||||
/// registry-bound chain either way.
|
||||
/// </param>
|
||||
/// <returns>The unstarted host.</returns>
|
||||
private static IHost BuildAdminHost(string mode, IMeshPskProvider? pskProvider = null)
|
||||
private static IHost BuildAdminHost(string mode, bool useSignallingProvider = false)
|
||||
{
|
||||
var clusterOptions = new AkkaClusterOptions
|
||||
{
|
||||
@@ -143,10 +156,11 @@ public sealed class ControlPlaneStarterOrderTests
|
||||
// never about registration order — it was about WHEN the graph is first walked.
|
||||
services.AddSingleton<IAuditWriter, RegistryBoundAuditWriter>();
|
||||
services.AddSingleton<ISecretResolver, AuditingSecretResolver>();
|
||||
if (pskProvider is null)
|
||||
services.AddSingleton<IMeshPskProvider, MeshPskProvider>();
|
||||
if (useSignallingProvider)
|
||||
services.AddSingleton<IMeshPskProvider>(sp =>
|
||||
new SignallingPskProvider(sp.GetRequiredService<ISecretResolver>()));
|
||||
else
|
||||
services.AddSingleton(pskProvider);
|
||||
services.AddSingleton<IMeshPskProvider, MeshPskProvider>();
|
||||
services.AddSingleton<IMeshCommandDialer, MeshGrpcCommandDialer>();
|
||||
|
||||
services.AddAkka("otopcua-starter-order-test", (ab, sp) =>
|
||||
@@ -188,6 +202,8 @@ public sealed class ControlPlaneStarterOrderTests
|
||||
private readonly TaskCompletionSource _published =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public SignallingPskProvider(ISecretResolver resolver) => ArgumentNullException.ThrowIfNull(resolver);
|
||||
|
||||
/// <summary>Completes once <see cref="SetKnownClusters"/> has been called.</summary>
|
||||
public Task Published => _published.Task;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user