97 lines
4.0 KiB
C#
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)
|
|
{
|
|
}
|
|
}
|
|
}
|