- Server-004: pass the role-derived display name to UserIdentity's base ctor (the SDK's DisplayName has no public setter) and drop the dead Display property; make RoleBasedIdentity internal sealed. - Server-006: derive a bounded CancellationToken from the SDK's OperationContext.OperationDeadline in OnReadValue / OnWriteValue so a stalled driver call can no longer pin the request thread. - Server-008: mark handled slots via CallMethodRequest.Processed = true in RouteScriptedAlarmMethodCalls (the SDK skips on Processed, not on a Good error slot). - Server-012: PeerHttpProbeLoop.ProbeAsync stops mutating client.Timeout per call; uses a per-request CancellationTokenSource linked to the shutdown token instead. - Server-014: wire SealedBootstrap into Program.cs via AddSealedBootstrap + OpcUaServerService so the generation-sealed cache + stale-config flag + resilient reader actually run; /healthz now reflects cache-fallback state. - Server-015: replace the stale 'PR 16 / PR 17 minimum-viable scope' class summaries on OtOpcUaServer and OpcUaServerOptions with the shipped LDAP + anonymous-role + configurable security-profile prose. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
104 lines
4.8 KiB
C#
104 lines
4.8 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Data.SqlClient;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server;
|
|
|
|
/// <summary>
|
|
/// Phase 6.1 Stream D consumption hook — bootstraps the node's current generation through
|
|
/// the <see cref="ResilientConfigReader"/> pipeline + writes every successful central-DB
|
|
/// read into the <see cref="GenerationSealedCache"/> so the next cache-miss path has a
|
|
/// sealed snapshot to fall back to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>Server-014 — registered in DI via <c>ServerWiring.AddSealedBootstrap</c> and
|
|
/// consumed by <c>OpcUaServerService</c>. The legacy <see cref="NodeBootstrap"/> stays
|
|
/// registered alongside for the three integration tests that construct it directly, but
|
|
/// production boots through this wrapper so <see cref="GenerationSealedCache"/> +
|
|
/// <see cref="ResilientConfigReader"/> + <see cref="StaleConfigFlag"/> run on every
|
|
/// start-up and <c>/healthz</c>'s <c>usingStaleConfig</c> reflects the cache-fallback
|
|
/// state.</para>
|
|
///
|
|
/// <para>Closes release blocker #2 in <c>docs/v2/v2-release-readiness.md</c> — the
|
|
/// generation-sealed cache + resilient reader + stale-config flag ship as unit-tested
|
|
/// primitives in PR #81; this wrapper is the production consumer that wires them in.</para>
|
|
/// </remarks>
|
|
public sealed class SealedBootstrap
|
|
{
|
|
private readonly NodeOptions _options;
|
|
private readonly GenerationSealedCache _cache;
|
|
private readonly ResilientConfigReader _reader;
|
|
private readonly StaleConfigFlag _staleFlag;
|
|
private readonly ILogger<SealedBootstrap> _logger;
|
|
|
|
public SealedBootstrap(
|
|
NodeOptions options,
|
|
GenerationSealedCache cache,
|
|
ResilientConfigReader reader,
|
|
StaleConfigFlag staleFlag,
|
|
ILogger<SealedBootstrap> logger)
|
|
{
|
|
_options = options;
|
|
_cache = cache;
|
|
_reader = reader;
|
|
_staleFlag = staleFlag;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve the current generation for this node. Routes the central-DB fetch through
|
|
/// <see cref="ResilientConfigReader"/> (timeout → retry → fallback-to-cache) + seals a
|
|
/// fresh snapshot on every successful DB read so a future cache-miss has something to
|
|
/// serve.
|
|
/// </summary>
|
|
public async Task<BootstrapResult> LoadCurrentGenerationAsync(CancellationToken ct)
|
|
{
|
|
return await _reader.ReadAsync(
|
|
_options.ClusterId,
|
|
centralFetch: async innerCt => await FetchFromCentralAsync(innerCt).ConfigureAwait(false),
|
|
fromSnapshot: snap => BootstrapResult.FromCache(snap.GenerationId),
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
|
|
private async ValueTask<BootstrapResult> FetchFromCentralAsync(CancellationToken ct)
|
|
{
|
|
await using var conn = new SqlConnection(_options.ConfigDbConnectionString);
|
|
await conn.OpenAsync(ct).ConfigureAwait(false);
|
|
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId=@n, @ClusterId=@c";
|
|
cmd.Parameters.AddWithValue("@n", _options.NodeId);
|
|
cmd.Parameters.AddWithValue("@c", _options.ClusterId);
|
|
|
|
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
|
if (!await reader.ReadAsync(ct).ConfigureAwait(false))
|
|
{
|
|
_logger.LogWarning("Cluster {Cluster} has no Published generation yet", _options.ClusterId);
|
|
return BootstrapResult.EmptyFromDb();
|
|
}
|
|
|
|
var generationId = reader.GetInt64(0);
|
|
_logger.LogInformation("Bootstrapped from central DB: generation {GenerationId}; sealing snapshot", generationId);
|
|
|
|
// Seal a minimal snapshot with the generation pointer. A richer snapshot that carries
|
|
// the full sp_GetGenerationContent payload lands when the bootstrap flow grows to
|
|
// consume the content during offline operation (separate follow-up — see decision #148
|
|
// and phase-6-1 Stream D.3). The pointer alone is enough for the fallback path to
|
|
// surface the last-known-good generation id + flip UsingStaleConfig.
|
|
await _cache.SealAsync(new GenerationSnapshot
|
|
{
|
|
ClusterId = _options.ClusterId,
|
|
GenerationId = generationId,
|
|
CachedAt = DateTime.UtcNow,
|
|
PayloadJson = JsonSerializer.Serialize(new { generationId, source = "sp_GetCurrentGenerationForCluster" }),
|
|
}, ct).ConfigureAwait(false);
|
|
|
|
// StaleConfigFlag bookkeeping: ResilientConfigReader.MarkFresh on the returning call
|
|
// path; we're on the fresh branch so we don't touch the flag here.
|
|
_ = _staleFlag; // held so the field isn't flagged unused
|
|
|
|
return BootstrapResult.FromDb(generationId);
|
|
}
|
|
}
|