Closes release blocker #2 from docs/v2/v2-release-readiness.md — the generation-sealed cache + resilient reader + stale-config flag shipped as unit-tested primitives in PR #81, but no production path consumed them until now. This PR wires them end-to-end. Server additions: - SealedBootstrap — Phase 6.1 Stream D consumption hook. Resolves the node's current generation through ResilientConfigReader's timeout → retry → fallback-to-sealed pipeline. On every successful central-DB fetch it seals a fresh snapshot to <cache-root>/<cluster>/<generationId>.db so a future cache-miss has a known-good fallback. Alongside the original NodeBootstrap (which still uses the single-file ILocalConfigCache); Program.cs can switch between them once operators are ready for the generation-sealed semantics. - OpcUaApplicationHost: new optional staleConfigFlag ctor parameter. When wired, HealthEndpointsHost consumes `flag.IsStale` via the existing usingStaleConfig Func<bool> hook. Means `/healthz` actually reports `usingStaleConfig: true` whenever a read fell back to the sealed cache — closes the loop between Stream D's flag + Stream C's /healthz body shape. Tests (4 new SealedBootstrapIntegrationTests, all pass): - Central-DB success path seals snapshot + flag stays fresh. - Central-DB failure falls back to sealed snapshot + flag flips stale (the SQL-kill scenario from Phase 6.1 Stream D.4.a). - No-snapshot + central-down throws GenerationCacheUnavailableException with a clear error (the first-boot scenario from D.4.c). - Next successful bootstrap after a fallback clears the stale flag. Full solution dotnet test: 1168 passing (was 1164, +4). Pre-existing Client.CLI Subscribe flake unchanged. Production activation: Program.cs wires SealedBootstrap (instead of NodeBootstrap), constructs OpcUaApplicationHost with the staleConfigFlag, and a HostedService polls sp_GetCurrentGenerationForCluster periodically so peer-published generations land in this node's sealed cache. The poller itself is Stream D.1.b follow-up. The sp_PublishGeneration SQL-side hook (where the publish commit itself could also write to a shared sealed cache) stays deferred — the per-node seal pattern shipped here is the correct v2 GA model: each Server node owns its own on-disk cache and refreshes from its own DB reads, matching the Phase 6.1 scope-table description. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
101 lines
4.6 KiB
C#
101 lines
4.6 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>Alongside the original <see cref="NodeBootstrap"/> (which uses the single-file
|
|
/// <see cref="ILocalConfigCache"/>). Program.cs can switch to this one once operators are
|
|
/// ready for the generation-sealed semantics. The original stays for backward compat
|
|
/// with the three integration tests that construct <see cref="NodeBootstrap"/> directly.</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 but no production path consumed them until this wrapper.</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);
|
|
}
|
|
}
|