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;
///
/// Phase 6.1 Stream D consumption hook — bootstraps the node's current generation through
/// the pipeline + writes every successful central-DB
/// read into the so the next cache-miss path has a
/// sealed snapshot to fall back to.
///
///
/// Alongside the original (which uses the single-file
/// ). 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 directly.
///
/// Closes release blocker #2 in docs/v2/v2-release-readiness.md — 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.
///
public sealed class SealedBootstrap
{
private readonly NodeOptions _options;
private readonly GenerationSealedCache _cache;
private readonly ResilientConfigReader _reader;
private readonly StaleConfigFlag _staleFlag;
private readonly ILogger _logger;
public SealedBootstrap(
NodeOptions options,
GenerationSealedCache cache,
ResilientConfigReader reader,
StaleConfigFlag staleFlag,
ILogger logger)
{
_options = options;
_cache = cache;
_reader = reader;
_staleFlag = staleFlag;
_logger = logger;
}
///
/// Resolve the current generation for this node. Routes the central-DB fetch through
/// (timeout → retry → fallback-to-cache) + seals a
/// fresh snapshot on every successful DB read so a future cache-miss has something to
/// serve.
///
public async Task 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 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);
}
}