Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAlarmHistorianWriter.cs
T

131 lines
6.4 KiB
C#

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using NpgsqlTypes;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
/// <summary>
/// <see cref="IAlarmHistorianWriter"/> over the QuestDB event table. Each event carries the deterministic
/// <see cref="AlarmEventIds.ComputeEventId"/> and is inserted through the table's <c>DEDUP UPSERT KEYS(ts, event_id)</c>
/// (T0 Q3 confirmed VARCHAR works as a key), so the store-and-forward drain's at-least-once redelivery, and both
/// pair nodes writing the same event, converge on one row (the redelivered row is identical except
/// <c>received_utc</c>). Outcomes: Ack on insert; RetryPlease on transient / unclassified errors and on
/// cancellation; PermanentFail only for data the server will always reject (a time before 1970, which is not a
/// storable designated timestamp, or a server message naming an inconvertible / out-of-range value). Never throws.
/// </summary>
internal sealed class QuestDbAlarmHistorianWriter : IAlarmHistorianWriter
{
private static readonly string[] PermanentMessageFragments =
["designated timestamp", "inconvertible", "out of range", "too long", "cannot be null"];
private readonly QuestDbConnection _conn;
private readonly QuestDbSchemaBootstrapper _bootstrap;
private readonly QuestDbWalVisibility _wal;
private readonly ILogger _log;
private readonly TimeProvider _clock;
/// <summary>Creates the writer.</summary>
/// <param name="conn">Connection helper.</param>
/// <param name="bootstrap">Schema bootstrapper.</param>
/// <param name="wal">WAL visibility wait (once per batch, so a following read sees the events).</param>
/// <param name="log">Optional logger.</param>
/// <param name="clock">Clock for <c>received_utc</c>.</param>
public QuestDbAlarmHistorianWriter(QuestDbConnection conn, QuestDbSchemaBootstrapper bootstrap, QuestDbWalVisibility wal,
ILogger? log = null, TimeProvider? clock = null)
{
_conn = conn;
_bootstrap = bootstrap;
_wal = wal;
_log = log ?? NullLogger.Instance;
_clock = clock ?? TimeProvider.System;
}
/// <inheritdoc/>
public async Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(batch);
if (batch.Count == 0) return [];
var outcomes = new HistorianWriteOutcome[batch.Count];
try
{
await _bootstrap.EnsureAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_log.LogWarning("QuestDB alarm historian bootstrap failed ({Exception}); will retry.", ex.GetType().Name);
Array.Fill(outcomes, HistorianWriteOutcome.RetryPlease);
return outcomes;
}
var wrote = false;
for (var i = 0; i < batch.Count; i++)
{
if (cancellationToken.IsCancellationRequested) { outcomes[i] = HistorianWriteOutcome.RetryPlease; continue; }
outcomes[i] = await WriteOneAsync(batch[i], cancellationToken).ConfigureAwait(false);
wrote |= outcomes[i] == HistorianWriteOutcome.Ack;
}
if (wrote)
{
try { await _wal.WaitAsync(_conn.EventTable, cancellationToken).ConfigureAwait(false); }
catch (Exception ex) { _log.LogDebug("QuestDB alarm visibility wait failed ({Exception}).", ex.GetType().Name); }
}
return outcomes;
}
private async Task<HistorianWriteOutcome> WriteOneAsync(AlarmHistorianEvent e, CancellationToken ct)
{
try
{
if (!QuestDbConnection.IsStorable(e.TimestampUtc))
{
_log.LogWarning("QuestDB alarm event dropped: its time is before 1970-01-01 and cannot be stored.");
return HistorianWriteOutcome.PermanentFail;
}
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = _conn.CreateCommand(conn,
$"INSERT INTO {_conn.EventTable} (ts, event_id, source, alarm_id, alarm_name, alarm_type_name, severity, event_kind, message, user_name, comment, received_utc) " +
"VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)");
QuestDbConnection.BindTimestamp(cmd, e.TimestampUtc);
QuestDbConnection.BindText(cmd, AlarmEventIds.ComputeEventId(e));
QuestDbConnection.BindText(cmd, e.EquipmentPath ?? "");
QuestDbConnection.BindText(cmd, e.AlarmId ?? "");
QuestDbConnection.BindText(cmd, e.AlarmName ?? "");
QuestDbConnection.BindText(cmd, e.AlarmTypeName ?? "");
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Integer, Value = AlarmEventIds.ToNumericSeverity(e.Severity) });
QuestDbConnection.BindText(cmd, e.EventKind ?? "");
QuestDbConnection.BindText(cmd, e.Message);
QuestDbConnection.BindText(cmd, e.User);
QuestDbConnection.BindText(cmd, e.Comment);
QuestDbConnection.BindTimestamp(cmd, _clock.GetUtcNow().UtcDateTime);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return HistorianWriteOutcome.Ack;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return HistorianWriteOutcome.RetryPlease;
}
catch (Exception ex)
{
var outcome = Classify(ex);
_log.LogWarning("QuestDB alarm event write failed ({Exception}); outcome {Outcome}.", ex.GetType().Name, outcome);
return outcome;
}
}
// Only a data error QuestDB will reject forever dead-letters; anything unknown or non-database retries (never lose alarm
// history). SQLSTATE is always 00000 on QuestDB, so a server rejection is classified by message text.
internal static HistorianWriteOutcome Classify(Exception ex)
{
if (QuestDbConnection.IsTransient(ex)) return HistorianWriteOutcome.RetryPlease;
if (ex is PostgresException pg &&
PermanentMessageFragments.Any(f => pg.MessageText.Contains(f, StringComparison.OrdinalIgnoreCase)))
return HistorianWriteOutcome.PermanentFail;
return HistorianWriteOutcome.RetryPlease;
}
}