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; /// /// over the QuestDB event table. Each event carries the deterministic /// and is inserted through the table's DEDUP UPSERT KEYS(ts, event_id) /// (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 /// received_utc). 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. /// 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; /// Creates the writer. /// Connection helper. /// Schema bootstrapper. /// WAL visibility wait (once per batch, so a following read sees the events). /// Optional logger. /// Clock for received_utc. 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; } /// public async Task> WriteBatchAsync(IReadOnlyList 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 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; } }