4c053cc4d0
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
131 lines
6.4 KiB
C#
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;
|
|
}
|
|
}
|