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); } }