Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretReplicationStarter.cs
T
Joseph Doherty 2254ae3dea
v2-ci / build (push) Successful in 3m55s
v2-ci / unit-tests (push) Failing after 11m7s
fix(secrets): consume Secrets 0.2.2 - clustered-secrets DI deadlock fixed upstream
Bumps the four ZB.MOM.WW.Secrets pins 0.2.1 -> 0.2.2 (closes the OtOpcUa side
of scadaproj#1, tracked here as #482). 0.2.1's Akka replicator deadlocked any
hosted process at startup when Secrets:Replication:Enabled was true: the
package's DI graph closed a circular singleton dependency through factory
lambdas (store decorator -> replicator -> actor provider -> cache invalidator
-> resolver -> store), which MS.DI's StackGuard turns into a silent
cross-thread call-site-lock deadlock. 0.2.2 defers the invalidator edge to
first eviction. The flag stays default-false; enabling remains a
per-environment decision.

The_startup_hook_actually_creates_the_replication_actor is now a real test:
SecretReplicationStarter's docs had promised it since the adoption, and the
upstream fix finally makes a provider-based resolve runnable - container built
exactly as the host does, hook started under a watchdog, replication actor
proven to exist by ActorSelection on a self-joined single-node cluster (no
TestKit needed, which matters because Akka.TestKit.Xunit2 is xunit-v2-only and
this project is on xunit.v3). Also corrects the stale rationale that blamed
the old hang on DistributedPubSub needing a joined cluster - the actor
constructor was never reached; it was the DI cycle.

Verified: SecretsReplicationRegistrationTests 8/8 on the 0.2.2 feed packages;
full slnx build 0 errors; the 2-node Akka live convergence gate re-run against
the published 0.2.2 packages passes 6/6 (write->peer, tombstone propagation
without resurrection, delete visibility through the resolver cache, reverse
direction, wrong-KEK fail-closed).

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-18 15:03:35 -04:00

56 lines
3.1 KiB
C#

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// Forces this node's secret-replication actor into existence at startup.
/// </summary>
/// <remarks>
/// <para>
/// The replication actor is created <b>lazily</b>, on the first resolution of
/// <see cref="ISecretStore"/> — resolving the store builds <c>ReplicatingSecretStore</c>,
/// which takes <c>ISecretReplicator</c>, whose factory touches
/// <c>SecretReplicationActorProvider.ActorRef</c> and thereby spawns the actor.
/// </para>
/// <para>
/// Laziness is correct for the library (the <c>ActorSystem</c> is usually registered after
/// the replication call), but it makes participation in anti-entropy accidental: a node that
/// happens never to read or write a secret — a plausible steady state for a driver-role node
/// whose driver configs carry no <c>${secret:}</c> references — would never create the actor,
/// never announce its manifest, and never converge. Nothing would fail; it would just
/// silently not replicate. Resolving the store once at startup makes participation
/// unconditional.
/// </para>
/// <para>
/// <b>History — this hook has met two library defects, both fixed.</b> Against 0.2.0 it was
/// inert: the package never bound its own <c>ISecretReplicator</c> (TryAdd registration
/// order), so this resolve built a <c>ReplicatingSecretStore</c> around a no-op sink and
/// spawned no actor. Against 0.2.1 it was worse: the resolve <b>deadlocked the host at
/// startup</b> — the package's DI graph had a circular singleton dependency, invisible to
/// the container through factory lambdas (scadaproj#1). Fixed in 0.2.2 by deferring the
/// cycle-closing invalidator edge;
/// <c>SecretsReplicationRegistrationTests.The_startup_hook_actually_creates_the_replication_actor</c>
/// exercises this hook against a built provider on a real single-node cluster.
/// </para>
/// </remarks>
/// <param name="services">Root provider used to resolve the (decorated) secret store exactly once.</param>
public sealed class SecretReplicationStarter(IServiceProvider services) : IHostedService
{
/// <summary>Resolves <see cref="ISecretStore"/>, which spawns the replication actor.</summary>
/// <param name="cancellationToken">Unused — resolution is synchronous and non-blocking.</param>
/// <returns>A completed task.</returns>
public Task StartAsync(CancellationToken cancellationToken)
{
// The resolution itself is the side effect; the instance is deliberately unused.
_ = services.GetRequiredService<ISecretStore>();
return Task.CompletedTask;
}
/// <summary>No-op: the actor's lifetime is the actor system's.</summary>
/// <param name="cancellationToken">Unused.</param>
/// <returns>A completed task.</returns>
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}