Merge qdb/T4: QuestDB writes (value writer, provisioner, alarm writer)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+130
@@ -0,0 +1,130 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IHistorianValueWriter"/> over the QuestDB sample table. Redelivery-idempotent with first-wins
|
||||
/// semantics: QuestDB's <c>DEDUP UPSERT KEYS(ts, tag)</c> would let a re-sent sample overwrite the stored one (and a
|
||||
/// re-sent live sample resurrect a tombstone), so the writer reads which timestamps already exist for the tag in
|
||||
/// the batch's range (tombstones count as present, T0 Q11 range form) and inserts only the absent rows.
|
||||
/// Never throws; returns false on any failure so the recorder keeps its entries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Batch normalisation is <see cref="LiveWritePreparer.Prepare"/> at µs resolution (non-finite doubles become Empty
|
||||
/// + Bad, an in-batch µs collision keeps the first). <b>RC1:</b> a sample whose time is before 1970 cannot be a
|
||||
/// QuestDB designated timestamp and one such row would fail the whole multi-row INSERT, so those samples are dropped
|
||||
/// (Warning once per batch, counted in <see cref="DroppedPre1970"/>) and the call still succeeds for the rest.
|
||||
/// <b>R5:</b> after inserting, the writer waits for WAL visibility so an immediate redelivery sees the rows.
|
||||
/// Inserts are chunked at <see cref="BatchRows"/> rows (8 bound parameters per row, far under the 32,767 limit).
|
||||
/// </remarks>
|
||||
internal sealed class QuestDbHistorianValueWriter : IHistorianValueWriter
|
||||
{
|
||||
/// <summary>Rows per multi-row INSERT (T0 Q2: 1,000 rows is reliable).</summary>
|
||||
internal const int BatchRows = 1000;
|
||||
|
||||
private readonly QuestDbConnection _conn;
|
||||
private readonly QuestDbTagCatalog _catalog;
|
||||
private readonly QuestDbWalVisibility _wal;
|
||||
private readonly ILogger _log;
|
||||
private readonly Func<DateTime> _utcNow;
|
||||
private long _droppedPre1970;
|
||||
|
||||
/// <summary>Creates the writer.</summary>
|
||||
/// <param name="conn">Connection helper.</param>
|
||||
/// <param name="catalog">Tag catalog (the only tag authority).</param>
|
||||
/// <param name="wal">WAL visibility wait.</param>
|
||||
/// <param name="log">Optional logger.</param>
|
||||
/// <param name="utcNow">Clock for null timestamps and <c>server_ts</c> (tests).</param>
|
||||
public QuestDbHistorianValueWriter(QuestDbConnection conn, QuestDbTagCatalog catalog, QuestDbWalVisibility wal,
|
||||
ILogger? log = null, Func<DateTime>? utcNow = null)
|
||||
{
|
||||
_conn = conn;
|
||||
_catalog = catalog;
|
||||
_wal = wal;
|
||||
_log = log ?? NullLogger.Instance;
|
||||
_utcNow = utcNow ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Samples dropped so far because their time is before 1970 (RC1).</summary>
|
||||
internal long DroppedPre1970 => Interlocked.Read(ref _droppedPre1970);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> WriteLiveValuesAsync(string tag, IReadOnlyList<HistorizationValue> values, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!HistorianTagNames.IsValid(tag))
|
||||
{
|
||||
_log.LogWarning("QuestDB historian live-value write refused: tag name is missing or longer than {Max} characters.",
|
||||
HistorianTagNames.MaxLength);
|
||||
return false;
|
||||
}
|
||||
if (values is null || values.Count == 0) return true;
|
||||
|
||||
var prepared = LiveWritePreparer.Prepare(values, _utcNow, resolutionTicks: 10);
|
||||
var rows = prepared.Where(r => QuestDbConnection.IsStorable(r.TimeUtc)).ToList();
|
||||
var dropped = prepared.Count - rows.Count;
|
||||
if (dropped > 0)
|
||||
{
|
||||
Interlocked.Add(ref _droppedPre1970, dropped);
|
||||
_log.LogWarning("QuestDB historian dropped {Count} sample(s) for a tag because their time is before 1970-01-01 (not storable).", dropped);
|
||||
}
|
||||
if (rows.Count == 0) return true;
|
||||
|
||||
var dataType = HistorianDataTypes.FromKind(rows.Select(r => r.Value.Kind).FirstOrDefault(k => k != HistorianValueKind.Empty));
|
||||
await _catalog.EnsureAsync(tag, dataType, ct).ConfigureAwait(false);
|
||||
|
||||
var present = await ExistingTicksAsync(tag, rows, ct).ConfigureAwait(false);
|
||||
var fresh = rows.Where(r => !present.Contains(QuestDbConnection.Round(r.TimeUtc).Ticks)).ToList();
|
||||
if (fresh.Count == 0) return true;
|
||||
|
||||
var serverTs = _utcNow();
|
||||
for (var i = 0; i < fresh.Count; i += BatchRows)
|
||||
await InsertAsync(tag, fresh.GetRange(i, Math.Min(BatchRows, fresh.Count - i)), serverTs, ct).ConfigureAwait(false);
|
||||
|
||||
await _wal.WaitAsync(_conn.SampleTable, ct).ConfigureAwait(false); // timeout/suspension does not change the result (R5)
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("QuestDB historian live-value write failed ({Exception}); will retry.", ex.GetType().Name);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A gap between consecutive batch times larger than this starts a new existence-query range.</summary>
|
||||
internal static readonly TimeSpan ExistenceClusterGap = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Splits ascending <paramref name="sortedTimes"/> into inclusive <c>[Lo, Hi]</c> ranges: a new range starts where the gap to the
|
||||
/// previous time exceeds <paramref name="gap"/> or the range already holds <paramref name="maxPerCluster"/> times. One outlier
|
||||
/// time therefore costs its own tiny query instead of widening one range over every stored row in between.
|
||||
/// </summary>
|
||||
/// <param name="sortedTimes">Times in ascending order.</param>
|
||||
/// <param name="gap">Largest gap kept inside one range.</param>
|
||||
/// <param name="maxPerCluster">Most times in one range (>= 1).</param>
|
||||
/// <returns>The ranges, ascending.</returns>
|
||||
internal static IReadOnlyList<(DateTime Lo, DateTime Hi)> Cluster(IReadOnlyList<DateTime> sortedTimes, TimeSpan gap, int maxPerCluster)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maxPerCluster, 1);
|
||||
var result = new List<(DateTime, DateTime)>();
|
||||
if (sortedTimes.Count == 0) return result;
|
||||
var lo = sortedTimes[0];
|
||||
var prev = lo;
|
||||
var n = 1;
|
||||
for (var i = 1; i < sortedTimes.Count; i++)
|
||||
{
|
||||
var t = sortedTimes[i];
|
||||
if (t - prev > gap || n >= maxPerCluster)
|
||||
{
|
||||
result.Add((lo, prev));
|
||||
lo = t;
|
||||
n = 0;
|
||||
}
|
||||
prev = t;
|
||||
n++;
|
||||
}
|
||||
result.Add((lo, prev));
|
||||
return result;
|
||||
}
|
||||
|
||||
// T0 Q11 range form, one query per cluster: tombstones are NOT filtered out (a deleted row must not be resurrected).
|
||||
private async Task<HashSet<long>> ExistingTicksAsync(string tag, List<HistorianSampleRow> rows, CancellationToken ct)
|
||||
{
|
||||
var times = rows.Select(r => QuestDbConnection.Round(r.TimeUtc)).OrderBy(t => t).ToList();
|
||||
var set = new HashSet<long>();
|
||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
foreach (var (lo, hi) in Cluster(times, ExistenceClusterGap, BatchRows))
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn, $"SELECT ts FROM {_conn.SampleTable} WHERE tag = $1 AND ts >= $2 AND ts <= $3");
|
||||
QuestDbConnection.BindText(cmd, tag);
|
||||
QuestDbConnection.BindTimestamp(cmd, lo);
|
||||
QuestDbConnection.BindTimestamp(cmd, hi);
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await r.ReadAsync(ct).ConfigureAwait(false)) set.Add(QuestDbConnection.ReadUtc(r, 0).Ticks);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private async Task InsertAsync(string tag, List<HistorianSampleRow> chunk, DateTime serverTs, CancellationToken ct)
|
||||
{
|
||||
var sql = new StringBuilder($"INSERT INTO {_conn.SampleTable} (ts, tag, kind, num_value, int_value, str_value, status, deleted, server_ts) VALUES ");
|
||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = _conn.CreateCommand(conn, "");
|
||||
var n = 1;
|
||||
for (var i = 0; i < chunk.Count; i++)
|
||||
{
|
||||
if (i > 0) sql.Append(',');
|
||||
sql.Append($"(${n},${n + 1},${n + 2},${n + 3},${n + 4},${n + 5},${n + 6},false,${n + 7})");
|
||||
n += 8;
|
||||
var row = chunk[i];
|
||||
var c = SampleColumnMapper.ToColumns(row.Value);
|
||||
QuestDbConnection.BindTimestamp(cmd, row.TimeUtc);
|
||||
QuestDbConnection.BindText(cmd, tag);
|
||||
QuestDbConnection.BindByte(cmd, c.ValueKind);
|
||||
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Double, Value = c.NumValue is { } d ? d : DBNull.Value });
|
||||
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Bigint, Value = c.IntValue is { } l ? l : DBNull.Value });
|
||||
QuestDbConnection.BindText(cmd, c.StrValue);
|
||||
QuestDbConnection.BindLong(cmd, SampleColumnMapper.ToLongStatus(row.StatusCode));
|
||||
QuestDbConnection.BindTimestamp(cmd, serverTs);
|
||||
}
|
||||
cmd.CommandText = sql.ToString();
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IHistorianProvisioning"/> for the QuestDB backend: upserts <c>tag</c> rows (fixed designated timestamp
|
||||
/// 1970-01-01, so the dedup key makes it an upsert by name). QuestDB replaces the WHOLE row on a key hit, so a null
|
||||
/// engineering unit / description in a request would wipe the stored one; per chunk the existing rows are read
|
||||
/// (<c>WHERE name IN (…)</c>) and <c>COALESCE(request, stored)</c> is applied client-side before inserting.
|
||||
/// Every data type is provisionable (Skipped is always 0). Never throws. After each chunk it waits for WAL
|
||||
/// visibility and tells the catalog the names are known (R7).
|
||||
/// </summary>
|
||||
internal sealed class QuestDbTagProvisioner : IHistorianProvisioning
|
||||
{
|
||||
private const int ChunkSize = 400; // 6 parameters per tag on insert, 400 on the IN list
|
||||
private const int MaxUnitLength = 64;
|
||||
private const int MaxDescriptionLength = 400;
|
||||
|
||||
private readonly QuestDbConnection _conn;
|
||||
private readonly QuestDbSchemaBootstrapper _bootstrap;
|
||||
private readonly QuestDbTagCatalog _catalog;
|
||||
private readonly QuestDbWalVisibility _wal;
|
||||
private readonly ILogger _log;
|
||||
private readonly TimeProvider _clock;
|
||||
|
||||
/// <summary>Creates the provisioner.</summary>
|
||||
/// <param name="conn">Connection helper.</param>
|
||||
/// <param name="bootstrap">Schema bootstrapper.</param>
|
||||
/// <param name="catalog">Tag catalog (told which names are now known).</param>
|
||||
/// <param name="wal">WAL visibility wait.</param>
|
||||
/// <param name="log">Optional logger.</param>
|
||||
/// <param name="clock">Clock for <c>updated_utc</c>.</param>
|
||||
public QuestDbTagProvisioner(QuestDbConnection conn, QuestDbSchemaBootstrapper bootstrap, QuestDbTagCatalog catalog,
|
||||
QuestDbWalVisibility wal, ILogger? log = null, TimeProvider? clock = null)
|
||||
{
|
||||
_conn = conn;
|
||||
_bootstrap = bootstrap;
|
||||
_catalog = catalog;
|
||||
_wal = wal;
|
||||
_log = log ?? NullLogger.Instance;
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<HistorianProvisionResult> EnsureTagsAsync(IReadOnlyList<HistorianTagProvisionRequest> requests, CancellationToken ct)
|
||||
{
|
||||
var requested = requests?.Count ?? 0;
|
||||
if (requested == 0) return new HistorianProvisionResult(0, 0, 0, 0);
|
||||
var failed = 0;
|
||||
var ensured = 0;
|
||||
try
|
||||
{
|
||||
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Distinct names (last request wins); over-long/empty names fail alone. Weight = requests folded into the name.
|
||||
var byName = new Dictionary<string, (HistorianTagProvisionRequest Req, int Count)>(StringComparer.Ordinal);
|
||||
foreach (var r in requests!)
|
||||
{
|
||||
if (r is null || string.IsNullOrWhiteSpace(r.TagName) || !HistorianTagNames.IsValid(r.TagName)) { failed++; continue; }
|
||||
byName[r.TagName] = (r, byName.TryGetValue(r.TagName, out var e) ? e.Count + 1 : 1);
|
||||
}
|
||||
|
||||
var ordered = byName.OrderBy(kv => kv.Key, StringComparer.Ordinal).Select(kv => kv.Value).ToList();
|
||||
for (var i = 0; i < ordered.Count; i += ChunkSize)
|
||||
{
|
||||
var chunk = ordered.Skip(i).Take(ChunkSize).ToList();
|
||||
var weight = chunk.Sum(c => c.Count);
|
||||
if (await TryUpsertAsync(chunk.Select(c => c.Req).ToList(), ct).ConfigureAwait(false)) ensured += weight;
|
||||
else failed += weight;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("QuestDB historian provisioning failed ({Exception}).", ex.GetType().Name);
|
||||
failed = requested - ensured;
|
||||
}
|
||||
|
||||
return new HistorianProvisionResult(requested, ensured, 0, failed);
|
||||
}
|
||||
|
||||
private async Task<bool> TryUpsertAsync(List<HistorianTagProvisionRequest> chunk, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stored = new Dictionary<string, (string? Unit, string? Desc)>(StringComparer.Ordinal);
|
||||
await using (var conn = await _conn.OpenAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
var sel = new StringBuilder($"SELECT name, eng_unit, description FROM {_conn.TagTable} WHERE name IN (");
|
||||
await using var cmd = _conn.CreateCommand(conn, "");
|
||||
for (var i = 0; i < chunk.Count; i++)
|
||||
{
|
||||
sel.Append(i == 0 ? "$" : ",$").Append(i + 1);
|
||||
QuestDbConnection.BindText(cmd, chunk[i].TagName);
|
||||
}
|
||||
cmd.CommandText = sel.Append(')').ToString();
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await r.ReadAsync(ct).ConfigureAwait(false))
|
||||
stored[r.GetString(0)] = (r.IsDBNull(1) ? null : r.GetString(1), r.IsDBNull(2) ? null : r.GetString(2));
|
||||
}
|
||||
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await using (var conn = await _conn.OpenAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
var ins = new StringBuilder($"INSERT INTO {_conn.TagTable} (ts, name, data_type, eng_unit, description, updated_utc) VALUES ");
|
||||
await using var cmd = _conn.CreateCommand(conn, "");
|
||||
var n = 1;
|
||||
for (var i = 0; i < chunk.Count; i++)
|
||||
{
|
||||
var req = chunk[i];
|
||||
stored.TryGetValue(req.TagName, out var old);
|
||||
if (i > 0) ins.Append(',');
|
||||
ins.Append($"(${n},${n + 1},${n + 2},${n + 3},${n + 4},${n + 5})");
|
||||
n += 6;
|
||||
QuestDbConnection.BindTimestamp(cmd, QuestDbTables.TagEpoch);
|
||||
QuestDbConnection.BindText(cmd, req.TagName);
|
||||
QuestDbConnection.BindByte(cmd, (byte)req.DataType);
|
||||
QuestDbConnection.BindText(cmd, Clip(req.EngineeringUnit ?? old.Unit, MaxUnitLength));
|
||||
QuestDbConnection.BindText(cmd, Clip(req.Description ?? old.Desc, MaxDescriptionLength));
|
||||
QuestDbConnection.BindTimestamp(cmd, now);
|
||||
}
|
||||
cmd.CommandText = ins.ToString();
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _wal.WaitAsync(_conn.TagTable, ct).ConfigureAwait(false);
|
||||
foreach (var c in chunk) _catalog.MarkKnown(c.TagName);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("QuestDB historian tag upsert failed ({Exception}).", ex.GetType().Name);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Clip(string? s, int max) => s is null ? null : s.Length <= max ? s : s[..max];
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
using Npgsql;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
|
||||
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class AlarmWriterTests(QuestDbFixture fx)
|
||||
{
|
||||
// µs-aligned: QuestDB stores µs, so the ts column reads back exactly.
|
||||
private static readonly DateTime T = new DateTime(2026, 10, 9, 1, 2, 3, DateTimeKind.Utc).AddTicks(1230);
|
||||
|
||||
private (QuestDbAlarmHistorianWriter W, QuestDbConnection C) Make(string? connection = null, bool auto = true)
|
||||
{
|
||||
var o = fx.Options(fx.UniquePrefix(), autoCreate: auto);
|
||||
if (connection is not null) o.ConnectionString = connection;
|
||||
var c = new QuestDbConnection(o);
|
||||
return (new QuestDbAlarmHistorianWriter(c, new QuestDbSchemaBootstrapper(c, o), new QuestDbWalVisibility(c, o.VisibilityTimeout)), c);
|
||||
}
|
||||
|
||||
private static AlarmHistorianEvent Ev(string kind = "Activated", string type = "LimitAlarm", string? comment = "c", DateTime? at = null) =>
|
||||
new("alarm-1", "Plant/Line/Eq", "HiTemp", type, AlarmSeverity.High, kind, "msg", "user", comment, at ?? T);
|
||||
|
||||
private static async Task<long> CountAsync(QuestDbConnection c, string? kind = null)
|
||||
{
|
||||
await using var conn = await c.OpenAsync(default);
|
||||
await using var cmd = c.CreateCommand(conn, $"SELECT count() FROM {c.EventTable}" + (kind is null ? "" : " WHERE event_kind = $1"));
|
||||
if (kind is not null) QuestDbConnection.BindText(cmd, kind);
|
||||
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Event_round_trips_and_a_repeat_is_one_row_with_every_write_Ack()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, c) = Make();
|
||||
(await w.WriteBatchAsync([Ev(), Ev()], CancellationToken.None)).ShouldBe([HistorianWriteOutcome.Ack, HistorianWriteOutcome.Ack]);
|
||||
(await w.WriteBatchAsync([Ev()], CancellationToken.None)).ShouldBe([HistorianWriteOutcome.Ack]);
|
||||
(await w.WriteBatchAsync([Ev(kind: "Cleared")], CancellationToken.None)).ShouldBe([HistorianWriteOutcome.Ack]);
|
||||
|
||||
await using var conn = await c.OpenAsync(default);
|
||||
await using var cmd = c.CreateCommand(conn,
|
||||
$"SELECT event_id, source, alarm_id, alarm_name, alarm_type_name, severity, message, user_name, comment, ts, received_utc FROM {c.EventTable} WHERE event_kind = 'Activated'");
|
||||
await using var r = await cmd.ExecuteReaderAsync();
|
||||
(await r.ReadAsync()).ShouldBeTrue();
|
||||
r.GetString(0).ShouldBe(AlarmEventIds.ComputeEventId(Ev()));
|
||||
r.GetString(1).ShouldBe("Plant/Line/Eq");
|
||||
r.GetString(2).ShouldBe("alarm-1");
|
||||
r.GetString(3).ShouldBe("HiTemp");
|
||||
r.GetString(4).ShouldBe("LimitAlarm");
|
||||
r.GetInt32(5).ShouldBe(AlarmEventIds.ToNumericSeverity(AlarmSeverity.High));
|
||||
r.GetInt32(5).ShouldBe(700);
|
||||
r.GetString(6).ShouldBe("msg");
|
||||
r.GetString(7).ShouldBe("user");
|
||||
r.GetString(8).ShouldBe("c");
|
||||
QuestDbConnection.ReadUtc(r, 9).ShouldBe(T);
|
||||
(DateTime.UtcNow - QuestDbConnection.ReadUtc(r, 10)).ShouldBeLessThan(TimeSpan.FromMinutes(1));
|
||||
(await r.ReadAsync()).ShouldBeFalse();
|
||||
await r.CloseAsync();
|
||||
(await CountAsync(c)).ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_null_comment_and_a_very_long_type_name_are_both_stored_and_Ack()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, c) = Make();
|
||||
var r = await w.WriteBatchAsync([Ev(kind: "A"), Ev(kind: "B", type: new string('t', 500)), Ev(kind: "C", comment: null)], CancellationToken.None);
|
||||
r.ShouldBe([HistorianWriteOutcome.Ack, HistorianWriteOutcome.Ack, HistorianWriteOutcome.Ack]);
|
||||
(await CountAsync(c)).ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_event_before_1970_is_PermanentFail_and_siblings_still_Ack()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, c) = Make();
|
||||
var r = await w.WriteBatchAsync([Ev(kind: "A"), Ev(kind: "B", at: new DateTime(1960, 1, 1, 0, 0, 0, DateTimeKind.Utc)), Ev(kind: "C")], CancellationToken.None);
|
||||
r.ShouldBe([HistorianWriteOutcome.Ack, HistorianWriteOutcome.PermanentFail, HistorianWriteOutcome.Ack]);
|
||||
(await CountAsync(c)).ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_writers_of_the_same_event_all_Ack_with_one_row()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, c) = Make();
|
||||
await w.WriteBatchAsync([Ev(kind: "warm")], CancellationToken.None);
|
||||
var rs = await Task.WhenAll(Enumerable.Range(0, 12).Select(_ => Task.Run(() => w.WriteBatchAsync([Ev()], CancellationToken.None))));
|
||||
rs.ShouldAllBe(x => x[0] == HistorianWriteOutcome.Ack);
|
||||
(await CountAsync(c, "Activated")).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unreachable_database_is_RetryPlease_not_an_exception_and_empty_batch_is_empty()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, _) = Make("Host=localhost;Port=1;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading;Timeout=2", auto: false);
|
||||
(await w.WriteBatchAsync([Ev()], CancellationToken.None)).ShouldBe([HistorianWriteOutcome.RetryPlease]);
|
||||
(await w.WriteBatchAsync([], CancellationToken.None)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_maps_unknown_and_transport_errors_to_RetryPlease_and_data_rejections_to_PermanentFail()
|
||||
{
|
||||
QuestDbAlarmHistorianWriter.Classify(new InvalidOperationException("boom")).ShouldBe(HistorianWriteOutcome.RetryPlease);
|
||||
QuestDbAlarmHistorianWriter.Classify(new IOException("reset")).ShouldBe(HistorianWriteOutcome.RetryPlease);
|
||||
QuestDbAlarmHistorianWriter.Classify(new TimeoutException()).ShouldBe(HistorianWriteOutcome.RetryPlease);
|
||||
QuestDbAlarmHistorianWriter.Classify(new PostgresException("table is busy", "ERROR", "ERROR", "00000")).ShouldBe(HistorianWriteOutcome.RetryPlease);
|
||||
QuestDbAlarmHistorianWriter.Classify(new PostgresException("designated timestamp before 1970-01-01 is not allowed", "ERROR", "ERROR", "00000"))
|
||||
.ShouldBe(HistorianWriteOutcome.PermanentFail);
|
||||
QuestDbAlarmHistorianWriter.Classify(new PostgresException("inconvertible value: x [VARCHAR -> INT]", "ERROR", "ERROR", "00000"))
|
||||
.ShouldBe(HistorianWriteOutcome.PermanentFail);
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
|
||||
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class ProvisioningTests(QuestDbFixture fx)
|
||||
{
|
||||
private (QuestDbTagProvisioner P, QuestDbConnection C, QuestDbTagCatalog Cat) Make(string? prefix = null, string? connectionString = null,
|
||||
bool autoCreate = true)
|
||||
{
|
||||
var o = fx.Options(prefix ?? fx.UniquePrefix(), autoCreate: autoCreate);
|
||||
if (connectionString is not null) o.ConnectionString = connectionString;
|
||||
var c = new QuestDbConnection(o);
|
||||
var b = new QuestDbSchemaBootstrapper(c, o);
|
||||
var wal = new QuestDbWalVisibility(c, o.VisibilityTimeout);
|
||||
var cat = new QuestDbTagCatalog(c, b, wal);
|
||||
return (new QuestDbTagProvisioner(c, b, cat, wal), c, cat);
|
||||
}
|
||||
|
||||
private static async Task<(byte Type, string? Eu, string? Desc)> TagAsync(QuestDbConnection c, string name)
|
||||
{
|
||||
await using var conn = await c.OpenAsync(default);
|
||||
await using var cmd = c.CreateCommand(conn, $"SELECT data_type, eng_unit, description FROM {c.TagTable} WHERE name = $1");
|
||||
QuestDbConnection.BindText(cmd, name);
|
||||
await using var r = await cmd.ExecuteReaderAsync();
|
||||
(await r.ReadAsync()).ShouldBeTrue();
|
||||
return (QuestDbConnection.ReadKind(r, 0), r.IsDBNull(1) ? null : r.GetString(1), r.IsDBNull(2) ? null : r.GetString(2));
|
||||
}
|
||||
|
||||
private static async Task<long> CountAsync(QuestDbConnection c)
|
||||
{
|
||||
await using var conn = await c.OpenAsync(default);
|
||||
await using var cmd = c.CreateCommand(conn, $"SELECT count() FROM {c.TagTable}");
|
||||
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_creates_then_changes_DataType_on_rerun_and_every_type_is_accepted()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (p, c, _) = Make();
|
||||
var r1 = await p.EnsureTagsAsync([new("a", DriverDataType.Boolean, "degC", "desc"), new("s", DriverDataType.String, null, null)], CancellationToken.None);
|
||||
r1.ShouldBe(new HistorianProvisionResult(2, 2, 0, 0));
|
||||
(await TagAsync(c, "a")).ShouldBe(((byte)DriverDataType.Boolean, "degC", "desc"));
|
||||
var r2 = await p.EnsureTagsAsync([new("a", DriverDataType.Float64, null, null)], CancellationToken.None);
|
||||
r2.ShouldBe(new HistorianProvisionResult(1, 1, 0, 0));
|
||||
(await TagAsync(c, "a")).ShouldBe(((byte)DriverDataType.Float64, "degC", "desc")); // null keeps stored metadata
|
||||
(await CountAsync(c)).ShouldBe(2); // upsert by name, not a second row
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_request_with_values_replaces_only_the_fields_it_carries()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (p, c, _) = Make();
|
||||
await p.EnsureTagsAsync([new("m", DriverDataType.Int32, "A", "first")], CancellationToken.None);
|
||||
await p.EnsureTagsAsync([new("m", DriverDataType.Int32, "B", null)], CancellationToken.None);
|
||||
(await TagAsync(c, "m")).ShouldBe(((byte)DriverDataType.Int32, "B", "first"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duplicate_names_and_bad_names_are_tallied_not_thrown()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (p, _, _) = Make();
|
||||
var r = await p.EnsureTagsAsync([
|
||||
new("d", DriverDataType.Int32, null, null), new("d", DriverDataType.Int64, null, null),
|
||||
new("", DriverDataType.Int32, null, null), new(new string('x', 401), DriverDataType.Int32, null, null)], CancellationToken.None);
|
||||
r.ShouldBe(new HistorianProvisionResult(4, 2, 0, 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task More_tags_than_one_chunk_all_land()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (p, c, _) = Make();
|
||||
var reqs = Enumerable.Range(0, 1000).Select(i => new HistorianTagProvisionRequest($"bulk{i}", DriverDataType.Float32, null, null)).ToList();
|
||||
(await p.EnsureTagsAsync(reqs, CancellationToken.None)).ShouldBe(new HistorianProvisionResult(1000, 1000, 0, 0));
|
||||
(await CountAsync(c)).ShouldBe(1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_later_catalog_Ensure_of_a_provisioned_tag_does_not_wipe_its_metadata()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (p, c, cat) = Make();
|
||||
await p.EnsureTagsAsync([new("k", DriverDataType.Float64, "kg", "mass")], CancellationToken.None);
|
||||
(await cat.ExistsAsync("k", CancellationToken.None)).ShouldBeTrue();
|
||||
await cat.EnsureAsync("k", 1, CancellationToken.None);
|
||||
(await TagAsync(c, "k")).ShouldBe(((byte)DriverDataType.Float64, "kg", "mass"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_connection_failure_is_a_Failed_tally_not_an_exception()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (p, _, _) = Make(connectionString: "Host=localhost;Port=1;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading;Timeout=2");
|
||||
(await p.EnsureTagsAsync([new("x", DriverDataType.Int32, null, null)], CancellationToken.None)).ShouldBe(new HistorianProvisionResult(1, 0, 0, 1));
|
||||
}
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
|
||||
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class WriteTests(QuestDbFixture fx)
|
||||
{
|
||||
// µs-aligned (ticks % 10 == 0) so "same µs" offsets of a few ticks stay inside one 10-tick window.
|
||||
private static readonly DateTime T0 = new(2026, 10, 9, 1, 2, 3, DateTimeKind.Utc);
|
||||
private static readonly DateTime ServerNow = new(2026, 10, 9, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private (QuestDbHistorianValueWriter W, QuestDbConnection C) Make(string? prefix = null, string? connectionString = null)
|
||||
{
|
||||
var o = fx.Options(prefix ?? fx.UniquePrefix());
|
||||
if (connectionString is not null) o.ConnectionString = connectionString;
|
||||
var c = new QuestDbConnection(o);
|
||||
var b = new QuestDbSchemaBootstrapper(c, o);
|
||||
var wal = new QuestDbWalVisibility(c, o.VisibilityTimeout);
|
||||
return (new QuestDbHistorianValueWriter(c, new QuestDbTagCatalog(c, b, wal), wal, utcNow: () => ServerNow), c);
|
||||
}
|
||||
|
||||
private static async Task<List<(DateTime T, byte Kind, double? N, long? I, string? S, long Status, bool Deleted, DateTime ServerTs)>> RowsAsync(
|
||||
QuestDbConnection c, string tag)
|
||||
{
|
||||
await using var conn = await c.OpenAsync(default);
|
||||
await using var cmd = c.CreateCommand(conn,
|
||||
$"SELECT ts, kind, num_value, int_value, str_value, status, deleted, server_ts FROM {c.SampleTable} WHERE tag = $1 ORDER BY ts");
|
||||
QuestDbConnection.BindText(cmd, tag);
|
||||
var l = new List<(DateTime, byte, double?, long?, string?, long, bool, DateTime)>();
|
||||
await using var r = await cmd.ExecuteReaderAsync();
|
||||
while (await r.ReadAsync())
|
||||
l.Add((QuestDbConnection.ReadUtc(r, 0), QuestDbConnection.ReadKind(r, 1), r.IsDBNull(2) ? null : r.GetDouble(2),
|
||||
r.IsDBNull(3) ? null : r.GetInt64(3), r.IsDBNull(4) ? null : r.GetString(4), r.GetInt64(5), r.GetBoolean(6),
|
||||
QuestDbConnection.ReadUtc(r, 7)));
|
||||
return l;
|
||||
}
|
||||
|
||||
private static async Task SeedTombstoneAsync(QuestDbConnection c, QuestDbWalVisibility wal, string tag, DateTime t)
|
||||
{
|
||||
await using (var conn = await c.OpenAsync(default))
|
||||
{
|
||||
await using var cmd = c.CreateCommand(conn,
|
||||
$"INSERT INTO {c.SampleTable} (ts, tag, kind, num_value, int_value, str_value, status, deleted, server_ts) VALUES ($1,$2,$3,NULL,NULL,NULL,$4,true,$5)");
|
||||
QuestDbConnection.BindTimestamp(cmd, t);
|
||||
QuestDbConnection.BindText(cmd, tag);
|
||||
QuestDbConnection.BindByte(cmd, 0);
|
||||
QuestDbConnection.BindLong(cmd, 0);
|
||||
QuestDbConnection.BindTimestamp(cmd, t);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
await wal.WaitAsync(c.SampleTable, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task All_six_kinds_round_trip_with_a_non_Good_status()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, c) = Make();
|
||||
const uint uncertain = HistorianStatusCodes.UncertainDataSubNormal;
|
||||
const uint bad = HistorianStatusCodes.BadOutOfRange;
|
||||
var dt = new DateTime(2025, 5, 6, 7, 8, 9, DateTimeKind.Utc).AddTicks(1230);
|
||||
HistorizationValue[] vals =
|
||||
[
|
||||
new(T0, HistorianValue.FromDouble(1.5), uncertain),
|
||||
new(T0.AddSeconds(1), HistorianValue.FromInt64(-77), uncertain),
|
||||
new(T0.AddSeconds(2), HistorianValue.FromBoolean(true), uncertain),
|
||||
new(T0.AddSeconds(3), HistorianValue.FromString("héllo"), uncertain),
|
||||
new(T0.AddSeconds(4), HistorianValue.FromDateTime(dt), uncertain),
|
||||
new(T0.AddSeconds(5), HistorianValue.Empty, bad),
|
||||
];
|
||||
(await w.WriteLiveValuesAsync("tag.kinds", vals, CancellationToken.None)).ShouldBeTrue();
|
||||
|
||||
var rows = await RowsAsync(c, "tag.kinds");
|
||||
rows.Count.ShouldBe(6);
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
rows[i].T.ShouldBe(vals[i].TimestampUtc!.Value);
|
||||
rows[i].Status.ShouldBe(SampleColumnMapper.ToLongStatus(vals[i].StatusCode));
|
||||
rows[i].Deleted.ShouldBeFalse();
|
||||
rows[i].ServerTs.ShouldBe(ServerNow);
|
||||
SampleColumnMapper.FromColumns(rows[i].Kind, rows[i].N, rows[i].I, rows[i].S).ShouldBe(vals[i].Value);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_redelivered_batch_leaves_one_row_and_the_first_value()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, c) = Make();
|
||||
(await w.WriteLiveValuesAsync("tag.redeliver", [new(T0, HistorianValue.FromDouble(1), 0)], CancellationToken.None)).ShouldBeTrue();
|
||||
(await w.WriteLiveValuesAsync("tag.redeliver", [new(T0, HistorianValue.FromDouble(2), 0)], CancellationToken.None)).ShouldBeTrue();
|
||||
var rows = await RowsAsync(c, "tag.redeliver");
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].N.ShouldBe(1d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Redelivery_straight_after_the_write_always_sees_it_because_the_writer_waits_for_WAL_visibility()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, c) = Make();
|
||||
// Without the visibility wait ~35 % of immediate re-reads miss the row (T0 Q4) and the second value would replace the first.
|
||||
for (var i = 0; i < 40; i++)
|
||||
{
|
||||
var t = T0.AddSeconds(i);
|
||||
(await w.WriteLiveValuesAsync("tag.rw", [new(t, HistorianValue.FromDouble(1), 0)], CancellationToken.None)).ShouldBeTrue();
|
||||
(await w.WriteLiveValuesAsync("tag.rw", [new(t, HistorianValue.FromDouble(2), 0)], CancellationToken.None)).ShouldBeTrue();
|
||||
}
|
||||
var rows = await RowsAsync(c, "tag.rw");
|
||||
rows.Count.ShouldBe(40);
|
||||
rows.ShouldAllBe(r => r.N == 1d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tombstoned_key_is_not_resurrected_by_redelivery()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, c) = Make();
|
||||
var wal = new QuestDbWalVisibility(c, TimeSpan.FromSeconds(5));
|
||||
(await w.WriteLiveValuesAsync("tag.tomb", [new(T0, HistorianValue.FromDouble(9), 0)], CancellationToken.None)).ShouldBeTrue();
|
||||
await SeedTombstoneAsync(c, wal, "tag.tomb", T0.AddSeconds(1));
|
||||
|
||||
// Redeliver the tombstoned key plus a fresh neighbour.
|
||||
(await w.WriteLiveValuesAsync("tag.tomb",
|
||||
[new(T0.AddSeconds(1), HistorianValue.FromDouble(5), 0), new(T0.AddSeconds(2), HistorianValue.FromDouble(6), 0)],
|
||||
CancellationToken.None)).ShouldBeTrue();
|
||||
|
||||
var rows = await RowsAsync(c, "tag.tomb");
|
||||
rows.Count.ShouldBe(3);
|
||||
rows.Single(r => r.T == T0.AddSeconds(1)).Deleted.ShouldBeTrue();
|
||||
rows.Single(r => r.T == T0.AddSeconds(2)).N.ShouldBe(6d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NaN_is_stored_as_Empty_with_BadOutOfRange()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, c) = Make();
|
||||
(await w.WriteLiveValuesAsync("tag.nan", [new(T0, HistorianValue.FromDouble(double.NaN), 0)], CancellationToken.None)).ShouldBeTrue();
|
||||
var rows = await RowsAsync(c, "tag.nan");
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].Kind.ShouldBe((byte)HistorianValueKind.Empty);
|
||||
rows[0].Status.ShouldBe((long)HistorianStatusCodes.BadOutOfRange);
|
||||
rows[0].N.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_microsecond_collision_within_a_batch_keeps_the_first()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, c) = Make();
|
||||
// +2 and +7 ticks stay inside one 10-tick (1 µs) window of the aligned T0.
|
||||
(await w.WriteLiveValuesAsync("tag.us",
|
||||
[new(T0.AddTicks(2), HistorianValue.FromDouble(1), 0), new(T0.AddTicks(7), HistorianValue.FromDouble(2), 0)],
|
||||
CancellationToken.None)).ShouldBeTrue();
|
||||
var rows = await RowsAsync(c, "tag.us");
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].N.ShouldBe(1d);
|
||||
rows[0].T.ShouldBe(T0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Samples_before_1970_are_dropped_and_the_rest_still_land()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, c) = Make();
|
||||
var old = new DateTime(1960, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
(await w.WriteLiveValuesAsync("tag.old",
|
||||
[new(old, HistorianValue.FromDouble(1), 0), new(T0, HistorianValue.FromDouble(2), 0)], CancellationToken.None)).ShouldBeTrue();
|
||||
w.DroppedPre1970.ShouldBe(1);
|
||||
var rows = await RowsAsync(c, "tag.old");
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].T.ShouldBe(T0);
|
||||
|
||||
// A batch of nothing but unstorable samples succeeds (nothing to retry) and stores nothing.
|
||||
(await w.WriteLiveValuesAsync("tag.old2", [new(old, HistorianValue.FromDouble(1), 0)], CancellationToken.None)).ShouldBeTrue();
|
||||
w.DroppedPre1970.ShouldBe(2);
|
||||
(await RowsAsync(c, "tag.old2")).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_batch_larger_than_one_insert_chunk_lands_completely()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, c) = Make();
|
||||
var vals = Enumerable.Range(0, 2500).Select(i => new HistorizationValue(T0.AddMilliseconds(i), HistorianValue.FromInt64(i), 0)).ToArray();
|
||||
(await w.WriteLiveValuesAsync("tag.big", vals, CancellationToken.None)).ShouldBeTrue();
|
||||
var rows = await RowsAsync(c, "tag.big");
|
||||
rows.Count.ShouldBe(2500);
|
||||
rows[1999].I.ShouldBe(1999);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_over_long_tag_name_returns_false()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, _) = Make();
|
||||
(await w.WriteLiveValuesAsync(new string('x', 401), [new(T0, HistorianValue.FromDouble(1), 0)], CancellationToken.None)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stopped_port_returns_false_without_throwing()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, _) = Make(connectionString: "Host=localhost;Port=1;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading;Timeout=2");
|
||||
(await w.WriteLiveValuesAsync("x", [new(T0, HistorianValue.FromDouble(1), 0)], CancellationToken.None)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cancelled_write_returns_false_without_throwing()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, _) = Make();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
(await w.WriteLiveValuesAsync("x", [new(T0, HistorianValue.FromDouble(1), 0)], cts.Token)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static async Task SeedRowAsync(QuestDbConnection c, QuestDbWalVisibility wal, string tag, DateTime t, double v)
|
||||
{
|
||||
await using (var conn = await c.OpenAsync(default))
|
||||
{
|
||||
await using var cmd = c.CreateCommand(conn,
|
||||
$"INSERT INTO {c.SampleTable} (ts, tag, kind, num_value, int_value, str_value, status, deleted, server_ts) VALUES ($1,$2,1,$3,NULL,NULL,0,false,$4)");
|
||||
QuestDbConnection.BindTimestamp(cmd, t);
|
||||
QuestDbConnection.BindText(cmd, tag);
|
||||
cmd.Parameters.Add(new Npgsql.NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Double, Value = v });
|
||||
QuestDbConnection.BindTimestamp(cmd, t);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
await wal.WaitAsync(c.SampleTable, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_row_on_another_tag_at_the_same_timestamp_does_not_suppress_the_write()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, c) = Make();
|
||||
var wal = new QuestDbWalVisibility(c, TimeSpan.FromSeconds(5));
|
||||
(await w.WriteLiveValuesAsync("tag.A", [new(T0, HistorianValue.FromDouble(1), 0)], CancellationToken.None)).ShouldBeTrue();
|
||||
(await w.WriteLiveValuesAsync("tag.B", [new(T0, HistorianValue.FromDouble(2), 0)], CancellationToken.None)).ShouldBeTrue();
|
||||
(await RowsAsync(c, "tag.A")).Single().N.ShouldBe(1d);
|
||||
(await RowsAsync(c, "tag.B")).Single().N.ShouldBe(2d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_absent_interior_timestamp_is_written_while_both_present_ends_are_left_alone()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, c) = Make();
|
||||
var wal = new QuestDbWalVisibility(c, TimeSpan.FromSeconds(5));
|
||||
await w.WriteLiveValuesAsync("tag.ends", [new(T0.AddSeconds(100), HistorianValue.FromDouble(0), 0)], CancellationToken.None); // creates the tag/tables
|
||||
await SeedRowAsync(c, wal, "tag.ends", T0, 10);
|
||||
await SeedRowAsync(c, wal, "tag.ends", T0.AddSeconds(20), 30);
|
||||
(await w.WriteLiveValuesAsync("tag.ends",
|
||||
[new(T0, HistorianValue.FromDouble(-1), 0), new(T0.AddSeconds(10), HistorianValue.FromDouble(20), 0), new(T0.AddSeconds(20), HistorianValue.FromDouble(-3), 0)],
|
||||
CancellationToken.None)).ShouldBeTrue();
|
||||
var rows = (await RowsAsync(c, "tag.ends")).Where(r => r.T <= T0.AddSeconds(20)).ToList();
|
||||
rows.Select(r => r.N).ShouldBe([10d, 20d, 30d]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_outlier_timestamp_does_not_widen_the_existence_read_over_the_stored_rows_between()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, c) = Make();
|
||||
// Behavioural check of the whole path with a far-apart batch: both samples land, a redelivery adds nothing.
|
||||
var far = T0.AddYears(1);
|
||||
HistorizationValue[] vals = [new(T0, HistorianValue.FromDouble(1), 0), new(far, HistorianValue.FromDouble(2), 0)];
|
||||
(await w.WriteLiveValuesAsync("tag.far", vals, CancellationToken.None)).ShouldBeTrue();
|
||||
(await w.WriteLiveValuesAsync("tag.far", vals, CancellationToken.None)).ShouldBeTrue();
|
||||
(await RowsAsync(c, "tag.far")).Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_splits_on_a_gap_over_the_limit_and_at_the_per_cluster_cap()
|
||||
{
|
||||
var g = TimeSpan.FromMinutes(1);
|
||||
QuestDbHistorianValueWriter.Cluster([], g, 10).ShouldBeEmpty();
|
||||
QuestDbHistorianValueWriter.Cluster([T0], g, 10).ShouldBe([(T0, T0)]);
|
||||
// exactly the gap stays together; one tick over splits
|
||||
QuestDbHistorianValueWriter.Cluster([T0, T0 + g], g, 10).ShouldBe([(T0, T0 + g)]);
|
||||
QuestDbHistorianValueWriter.Cluster([T0, T0 + g + TimeSpan.FromTicks(10)], g, 10)
|
||||
.ShouldBe([(T0, T0), (T0 + g + TimeSpan.FromTicks(10), T0 + g + TimeSpan.FromTicks(10))]);
|
||||
// outlier gets its own range
|
||||
var far = T0.AddYears(1);
|
||||
QuestDbHistorianValueWriter.Cluster([T0, T0.AddSeconds(1), far], g, 10).ShouldBe([(T0, T0.AddSeconds(1)), (far, far)]);
|
||||
// cap: 5 close times, max 2 per cluster
|
||||
var five = Enumerable.Range(0, 5).Select(i => T0.AddSeconds(i)).ToList();
|
||||
QuestDbHistorianValueWriter.Cluster(five, g, 2)
|
||||
.ShouldBe([(five[0], five[1]), (five[2], five[3]), (five[4], five[4])]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user