Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianRetentionService.cs
T

97 lines
4.0 KiB
C#

using System.Diagnostics;
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// <summary>
/// Timer that drives an <see cref="IHistorianRetention"/> purge. Each tick is gated on
/// <see cref="IHistorizationRoleView.ShouldRecordHistorizedValues"/> so only the node that is writing
/// history purges it (an unknown role allows, matching the capture gate). A null retention (backend has
/// none, or no window configured) makes the service inert. Never throws out of the loop.
/// </summary>
public sealed class HistorianRetentionService : BackgroundService
{
/// <summary>Counter of rows purged, tagged <c>table</c>.</summary>
public const string PurgedCounterName = "otopcua.historian.retention.purged";
private static readonly Counter<long> Purged = OtOpcUaTelemetry.Meter.CreateCounter<long>(
PurgedCounterName, unit: "{row}", description: "Historian rows deleted by the retention purge.");
private readonly IHistorianRetention? _retention;
private readonly IHistorizationRoleView? _roleView;
private readonly ILogger _log;
private readonly KeyValuePair<string, object?>[] _extraTags;
/// <summary>Creates the service.</summary>
/// <param name="retention">Backend purge seam; null makes the service inert.</param>
/// <param name="roleView">Primary gate; null means always run.</param>
/// <param name="log">Logger.</param>
/// <param name="extraTags">Extra tags added to every counter increment (tests use a unique one).</param>
public HistorianRetentionService(IHistorianRetention? retention, IHistorizationRoleView? roleView,
ILogger<HistorianRetentionService>? log = null, params KeyValuePair<string, object?>[] extraTags)
{
_retention = retention;
_roleView = roleView;
_log = (ILogger?)log ?? NullLogger.Instance;
_extraTags = extraTags;
}
/// <summary>Runs one gated tick. Never throws.</summary>
/// <param name="ct">Cancellation.</param>
/// <returns>True when a purge pass ran.</returns>
public async Task<bool> TickAsync(CancellationToken ct)
{
if (_retention is null) return false;
if (_roleView is { ShouldRecordHistorizedValues: false })
{
_log.LogDebug("Historian retention purge skipped: this node is not the recording node.");
return false;
}
try
{
var results = await _retention.PurgeAsync(ct).ConfigureAwait(false);
foreach (var r in results)
{
if (r.Rows <= 0) continue;
var tags = new TagList { { "table", r.Table } };
foreach (var t in _extraTags) tags.Add(t);
Purged.Add(r.Rows, tags);
_log.LogInformation("Historian retention purged {Rows} rows from {Table}.", r.Rows, r.Table);
}
return true;
}
catch (Exception) when (ct.IsCancellationRequested)
{
// Shutdown: SqlClient surfaces cancellation as SqlException, not only OperationCanceledException.
return false;
}
catch (Exception ex)
{
_log.LogWarning(ex, "Historian retention purge failed; will retry next interval.");
return false;
}
}
/// <inheritdoc/>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (_retention is null) return;
try
{
using var timer = new PeriodicTimer(_retention.Interval);
await TickAsync(stoppingToken).ConfigureAwait(false);
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
await TickAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
}
}