a1df7ef26c
Closes the defect in scadaproj#1. The hang was never Akka: the package's DI wiring closed a circular singleton dependency the container cannot see through factory lambdas — ISecretStore (ReplicatingSecretStore decorator) -> ISecretReplicator -> SecretReplicationActorProvider -> ISecretCacheInvalidator -> DefaultSecretResolver -> ISecretStore. Resolution recurses around the loop until MS.DI's StackGuard hops it onto a fresh thread-pool thread, which then blocks forever on a singleton call-site lock the first thread still holds: a silent permanent hang instead of a stack overflow. Managed stacks from dotnet-dump show the repeating cycle and both parked threads; both candidate causes in the issue (DistributedPubSub.Get vs the Lazy lock, missing Akka.Cluster.Tools HOCON) are disproven — the actor constructor was never reached, and the deadlock reproduces on a single non-clustered node. Fix: defer the one cycle-closing edge. The provider now gets a DeferredSecretCacheInvalidator that resolves the real invalidator on first eviction — which only happens when a replicated row is applied, strictly after graph resolution. Severing the edge instead is wrong: a null-invalidator experiment ran the live gate at 5/6, with deleted secrets still resolving on the peer. The SqlServer package never had the cycle (its replicator chain never touches the invalidator), which is why the hub gate always passed. Verified: live 2-node convergence gate now 6/6 (was: infinite hang), including the delete-visibility check that proves the deferred invalidator really evicts. New HostedProcessResolutionTests builds the graph as a host does (container- registered ActorSystem, hosted services, watchdogged resolves) and fails on 0.2.1; DeferredSecretCacheInvalidatorTests pins the wrapper contract. Full suite 180 passed / 0 failed / 15 skipped (env-gated live SQL). Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
76 lines
2.4 KiB
C#
76 lines
2.4 KiB
C#
using ZB.MOM.WW.Secrets.Abstractions;
|
|
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
|
|
|
|
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests.DependencyInjection;
|
|
|
|
/// <summary>
|
|
/// The deferred invalidator exists to break the scadaproj#1 dependency cycle, which makes it a
|
|
/// pass-through that could silently pass nothing through — the exact failure mode (inert seam,
|
|
/// green suite) this library has shipped before. These tests pin the two halves of its contract:
|
|
/// nothing resolves before the first eviction, and evictions actually reach the real invalidator.
|
|
/// </summary>
|
|
public sealed class DeferredSecretCacheInvalidatorTests
|
|
{
|
|
private sealed class RecordingInvalidator : ISecretCacheInvalidator
|
|
{
|
|
public List<SecretName> Evicted { get; } = [];
|
|
|
|
public void Invalidate(SecretName name) => Evicted.Add(name);
|
|
}
|
|
|
|
[Fact]
|
|
public void Construction_does_not_resolve_the_inner_invalidator()
|
|
{
|
|
// THE point of the type: resolving eagerly is what closed the singleton cycle and hung
|
|
// every hosted process at startup.
|
|
bool resolved = false;
|
|
|
|
_ = new DeferredSecretCacheInvalidator(() =>
|
|
{
|
|
resolved = true;
|
|
return null;
|
|
});
|
|
|
|
Assert.False(resolved);
|
|
}
|
|
|
|
[Fact]
|
|
public void Invalidate_forwards_to_the_resolved_invalidator()
|
|
{
|
|
var inner = new RecordingInvalidator();
|
|
var deferred = new DeferredSecretCacheInvalidator(() => inner);
|
|
var name = new SecretName("gate/alpha");
|
|
|
|
deferred.Invalidate(name);
|
|
|
|
Assert.Equal([name], inner.Evicted);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_lookup_runs_once_and_the_instance_is_reused()
|
|
{
|
|
int lookups = 0;
|
|
var inner = new RecordingInvalidator();
|
|
var deferred = new DeferredSecretCacheInvalidator(() =>
|
|
{
|
|
lookups++;
|
|
return inner;
|
|
});
|
|
|
|
deferred.Invalidate(new SecretName("gate/alpha"));
|
|
deferred.Invalidate(new SecretName("gate/beta"));
|
|
|
|
Assert.Equal(1, lookups);
|
|
Assert.Equal(2, inner.Evicted.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_null_resolution_means_evictions_are_no_ops()
|
|
{
|
|
// Mirrors the reconciler's own contract: no invalidator registered, nothing to evict.
|
|
var deferred = new DeferredSecretCacheInvalidator(() => null);
|
|
|
|
deferred.Invalidate(new SecretName("gate/alpha"));
|
|
}
|
|
}
|