using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central.HeadExport;
///
/// Central hosted service that exports the AuditLog hash-chain heads off-box
/// (AuditLog:HashChain:HeadExport, ScadaBridge#107 item 1). Default off.
///
///
///
/// Runs on both central nodes (each record names its node). A tick reads every month's head in
/// one consistent snapshot (, chain lock
/// SHARED) and writes the heads that changed since this node's last successful export — all of
/// them on the first tick after start — to the configured .
/// A failed write is logged and retried on the next tick with the same heads (the in-memory
/// "last exported" state only advances on success). No signing: the protection is that the
/// sink sits outside the database login's reach.
///
///
public sealed class AuditChainHeadExportService : IHostedService, IDisposable
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly AuditChainHeadExportOptions _options;
private readonly IAuditChainHeadSink? _sink;
private readonly string? _node;
private readonly TimeProvider _time;
private readonly ILogger _logger;
private readonly AuditChainHeadExportStatus _status;
private readonly Dictionary _lastExported = new();
private CancellationTokenSource? _cts;
private Task? _loop;
/// Initializes the service.
/// Scope factory for the scoped head store.
/// Hash-chain options (the HeadExport sub-section is used).
/// The sink (null when the export is disabled).
/// This node's name for the records.
/// Logger.
/// Clock.
/// Health status the tick outcome is recorded on (ScadaBridge#115).
public AuditChainHeadExportService(
IServiceScopeFactory scopeFactory,
IOptions options,
IAuditChainHeadSink? sink,
string? nodeName,
ILogger logger,
TimeProvider? time = null,
AuditChainHeadExportStatus? status = null)
{
_status = status ?? new AuditChainHeadExportStatus();
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.HeadExport;
_sink = sink;
_node = nodeName;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_time = time ?? TimeProvider.System;
}
///
public Task StartAsync(CancellationToken cancellationToken)
{
if (!_options.Enabled || _sink is null)
{
_logger.LogInformation("AuditLog chain-head export disabled (AuditLog:HashChain:HeadExport:Enabled=false).");
return Task.CompletedTask;
}
_status.MarkStarted(_options.IntervalMinutes, _time.GetUtcNow().UtcDateTime);
_logger.LogInformation("AuditLog chain-head export every {Minutes} min to {Sink}.", _options.IntervalMinutes, _sink.Describe);
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_cts = cts;
var token = cts.Token;
_loop = Task.Run(() => RunLoopAsync(token), CancellationToken.None);
return Task.CompletedTask;
}
private async Task RunLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await TickAsync(ct).ConfigureAwait(false);
try
{
await Task.Delay(TimeSpan.FromMinutes(_options.IntervalMinutes), ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
/// Runs one export tick.
/// Cancellation token.
/// The number of records written (0 on failure or nothing changed).
internal async Task TickAsync(CancellationToken ct)
{
if (_sink is null)
{
return 0;
}
try
{
IReadOnlyList heads;
await using (var scope = _scopeFactory.CreateAsyncScope())
{
heads = await scope.ServiceProvider.GetRequiredService()
.ReadHeadsAsync(ct).ConfigureAwait(false);
}
var now = _time.GetUtcNow().UtcDateTime;
var changed = heads
.Where(h => _options.ExportUnchanged
|| !_lastExported.TryGetValue(h.Month, out var last)
|| last != (h.LastSeq, h.PurgedThroughSeq, h.ChainedRows + h.Tombstones))
.ToList();
if (changed.Count == 0)
{
_status.RecordSuccess(now);
return 0;
}
var records = changed.Select(h => ToRecord(h, now, _node)).ToList();
await _sink.WriteAsync(records, ct).ConfigureAwait(false);
foreach (var h in changed)
{
_lastExported[h.Month] = (h.LastSeq, h.PurgedThroughSeq, h.ChainedRows + h.Tombstones);
}
_status.RecordSuccess(now);
_logger.LogDebug("Exported {Count} AuditLog chain head(s) to {Sink}.", records.Count, _sink.Describe);
return records.Count;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return 0;
}
catch (Exception ex)
{
// Contained: the next tick retries the same heads (nothing was marked exported).
_logger.LogError(ex, "AuditLog chain-head export to {Sink} failed; retrying next tick.", _sink.Describe);
_status.RecordFailure(_time.GetUtcNow().UtcDateTime, ex.Message);
return 0;
}
}
/// Maps a database head to its exported record.
/// The head.
/// Export time.
/// Exporting node.
/// The record.
public static AuditChainHeadRecord ToRecord(AuditChainHeadSnapshot head, DateTime exportedAtUtc, string? node)
{
ArgumentNullException.ThrowIfNull(head);
return new AuditChainHeadRecord(
AuditChainHeadRecord.CurrentVersion,
AuditChainHasher.PartitionLabel(head.Month),
head.LastSeq,
AuditChainHeadComparer.ToHex(head.LastHash),
head.PurgedThroughSeq,
head.PurgedThroughHash is null ? null : AuditChainHeadComparer.ToHex(head.PurgedThroughHash),
head.ChainedRows,
head.Tombstones,
head.UpdatedAtUtc,
exportedAtUtc,
node);
}
///
public Task StopAsync(CancellationToken cancellationToken)
{
try
{
_cts?.Cancel();
}
catch (ObjectDisposedException)
{
// Dispose already cancelled the loop.
}
return _loop ?? Task.CompletedTask;
}
///
public void Dispose()
{
try
{
_cts?.Cancel();
}
catch (ObjectDisposedException)
{
// Idempotent.
}
_cts?.Dispose();
}
}