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
112 lines
5.0 KiB
C#
112 lines
5.0 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
using ZB.MOM.WW.Secrets.DependencyInjection;
|
|
using ZB.MOM.WW.Secrets.Replication;
|
|
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
|
|
using ZB.MOM.WW.Secrets.Sqlite;
|
|
|
|
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests.DependencyInjection;
|
|
|
|
/// <summary>
|
|
/// Registration tests for <c>AddZbSecretsAkkaReplication</c>.
|
|
/// <para>
|
|
/// These exist because 0.2.0 shipped with Akka replication silently inert and every other test in
|
|
/// this assembly still passed. The bug was pure registration ORDER: the extension called
|
|
/// <c>AddZbSecrets</c> first, which does
|
|
/// <c>TryAddSingleton<ISecretReplicator, NoOpSecretReplicator>()</c>, so the package's own
|
|
/// <c>TryAddSingleton<ISecretReplicator></c> found a descriptor already present and was
|
|
/// silently discarded. Writes then published into a no-op sink and no replication actor was ever
|
|
/// spawned — with no exception and no log line anywhere.
|
|
/// </para>
|
|
/// <para>
|
|
/// The equivalent SQL-Server package always had a test asserting its replicator type, and always
|
|
/// had the correct order. That asymmetry is exactly why the defect landed in one package and not
|
|
/// the other, so the gap is closed here.
|
|
/// </para>
|
|
/// <para>
|
|
/// These assert against the <see cref="IServiceCollection"/> because the 0.2.0 defect was entirely
|
|
/// about which descriptor wins, and the descriptor is where it is visible. Resolution through a
|
|
/// BUILT provider — where the scadaproj#1 singleton-cycle deadlock lived — is covered by
|
|
/// <see cref="HostedProcessResolutionTests"/>, and end-to-end actor behaviour by
|
|
/// <c>TwoNodeClusterReplicationTests</c>. (An earlier revision of this comment claimed a
|
|
/// provider-based test was impossible because <c>DistributedPubSub.Get</c> needs a joined
|
|
/// cluster; the hang it was avoiding was actually that DI deadlock, and a single self-joined
|
|
/// node is all the cluster the actor needs.)
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class AddZbSecretsAkkaReplicationTests
|
|
{
|
|
private static IServiceCollection Register()
|
|
{
|
|
IConfigurationRoot config = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["Secrets:SqlitePath"] = Path.Combine(Path.GetTempPath(), $"zb-akkadi-{Guid.NewGuid():N}.db"),
|
|
["Secrets:Replication:AnnounceInterval"] = "00:00:30",
|
|
})
|
|
.Build();
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddZbSecretsAkkaReplication(config, "Secrets", "Secrets:Replication");
|
|
return services;
|
|
}
|
|
|
|
private static ServiceDescriptor Descriptor<T>(IServiceCollection services) =>
|
|
services.Single(d => d.ServiceType == typeof(T));
|
|
|
|
[Fact]
|
|
public void ISecretReplicator_is_not_bound_to_the_no_op_sink()
|
|
{
|
|
// THE REGRESSION TEST. Before the fix this descriptor was NoOpSecretReplicator, so every
|
|
// replicated write went nowhere. A no-op secret replicator is the worst failure mode
|
|
// available: the cluster reports healthy and silently never converges.
|
|
ServiceDescriptor descriptor = Descriptor<ISecretReplicator>(Register());
|
|
|
|
Assert.NotEqual(typeof(NoOpSecretReplicator), descriptor.ImplementationType);
|
|
}
|
|
|
|
[Fact]
|
|
public void ISecretReplicator_is_registered_by_this_package_via_a_factory()
|
|
{
|
|
// The package registers through a factory (it needs the lazily-created actor ref), whereas
|
|
// the core no-op is registered by concrete type. A factory registration is therefore proof
|
|
// that this package's descriptor won the TryAdd race rather than core's.
|
|
ServiceDescriptor descriptor = Descriptor<ISecretReplicator>(Register());
|
|
|
|
Assert.NotNull(descriptor.ImplementationFactory);
|
|
Assert.Null(descriptor.ImplementationType);
|
|
}
|
|
|
|
[Fact]
|
|
public void ISecretStore_is_decorated_so_writes_actually_publish()
|
|
{
|
|
// AddSingleton (not TryAdd) means the decorator is appended, and the LAST registration for
|
|
// a service type is what the container resolves. If this regressed to the bare SQLite
|
|
// store, writes would never publish.
|
|
ServiceDescriptor last = Register()
|
|
.Last(d => d.ServiceType == typeof(ISecretStore));
|
|
|
|
Assert.NotNull(last.ImplementationFactory);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_undecorated_concrete_store_is_registered()
|
|
{
|
|
// ReplicatingSecretStore resolves SqliteSecretStore by concrete type. Core registering it
|
|
// only behind ISecretStore was the OTHER defect this library shipped, so pin it here too.
|
|
IServiceCollection services = Register();
|
|
|
|
Assert.Contains(services, d => d.ServiceType == typeof(SqliteSecretStore));
|
|
}
|
|
|
|
[Fact]
|
|
public void The_store_migrator_seam_is_registered()
|
|
{
|
|
IServiceCollection services = Register();
|
|
|
|
Assert.Contains(services, d => d.ServiceType == typeof(ISecretsStoreMigrator));
|
|
}
|
|
}
|