feat(historian): QuestDB HistoryUpdate with soft delete and read-your-writes
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+258
@@ -0,0 +1,258 @@
|
||||
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>
|
||||
/// OPC UA HistoryUpdate against the QuestDB sample table (spec §2.3). Every operation is an <c>INSERT</c>: the
|
||||
/// table's <c>DEDUP UPSERT KEYS(ts, tag)</c> performs the replace, and a delete writes a tombstone
|
||||
/// (<c>deleted = true</c>, NULL values, <c>BadNoData</c>). QuestDB <c>UPDATE</c>/<c>DELETE</c> are never issued.
|
||||
/// <para>Every status comes from <see cref="HistoryUpdatePlanner"/>, so codes equal the SQL Server backend's.
|
||||
/// "Exists" means present and not deleted, so Insert over a tombstone succeeds (<c>GoodEntryInserted</c>) while
|
||||
/// Replace and DeleteAtTime treat a tombstone as no entry. Existence is read with one range query and filtered
|
||||
/// client-side (never an IN list).</para>
|
||||
/// <para><b>Pre-1970 (RC1):</b> QuestDB cannot store such a designated timestamp. Insert/Replace/Update of such an
|
||||
/// item reports <c>BadOutOfRange</c> and sends nothing (a null/<c>MinValue</c> timestamp is still
|
||||
/// <c>BadInvalidTimestamp</c>); DeleteAtTime reports <c>BadNoEntryExists</c>; a DeleteRaw window may start before
|
||||
/// 1970 (read bounds are not clamped).</para>
|
||||
/// <para><b>Read-your-writes:</b> after writing, <see cref="QuestDbWalVisibility"/> waits for the rows to become
|
||||
/// visible, bounded by <c>VisibilityTimeout</c>; a timeout only logs a Warning, never changes a code.</para>
|
||||
/// </summary>
|
||||
internal sealed class QuestDbHistorianDataUpdater : IHistorianDataUpdater
|
||||
{
|
||||
/// <summary>Rows per INSERT and per DeleteRaw page (T0 Q2: 9,000 parameters, far under the 32,767 limit).</summary>
|
||||
internal const int BatchSize = 1000;
|
||||
|
||||
private const long ResolutionTicks = 10;
|
||||
private readonly QuestDbConnection _conn;
|
||||
private readonly QuestDbTagCatalog _tags;
|
||||
private readonly QuestDbWalVisibility _wal;
|
||||
private readonly ILogger _log;
|
||||
|
||||
/// <summary>Creates the updater.</summary>
|
||||
/// <param name="conn">Connection helper.</param>
|
||||
/// <param name="tags">Tag catalog (R7: the only known-tag authority).</param>
|
||||
/// <param name="wal">WAL visibility wait (R5).</param>
|
||||
/// <param name="log">Optional logger.</param>
|
||||
public QuestDbHistorianDataUpdater(QuestDbConnection conn, QuestDbTagCatalog tags, QuestDbWalVisibility wal,
|
||||
ILogger? log = null)
|
||||
{
|
||||
_conn = conn;
|
||||
_tags = tags;
|
||||
_wal = wal;
|
||||
_log = log ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode,
|
||||
IReadOnlyList<HistorizationValue> values, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
|
||||
// RC1: a valid-timestamp item before 1970 can never be stored; refuse it and plan the rest.
|
||||
var final = new uint[values.Count];
|
||||
var kept = new List<int>(values.Count);
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
if (values[i].TimestampUtc is { } ts && ts != DateTime.MinValue && !QuestDbConnection.IsStorable(ts))
|
||||
final[i] = HistorianStatusCodes.BadOutOfRange;
|
||||
else
|
||||
kept.Add(i);
|
||||
}
|
||||
|
||||
var plan = HistoryUpdatePlanner.Plan(tag, mode, kept.Select(i => values[i]).ToList(), ResolutionTicks);
|
||||
IReadOnlyList<uint> planned;
|
||||
if (plan.Winners.Count == 0)
|
||||
{
|
||||
planned = HistoryUpdatePlanner.Complete(plan, mode, new Dictionary<long, UpsertOutcome>());
|
||||
}
|
||||
else
|
||||
{
|
||||
var known = mode == HistoryUpdateMode.Replace
|
||||
? await _tags.ExistsAsync(tag, ct).ConfigureAwait(false)
|
||||
: await EnsureAsync(tag, plan.Winners[0].Row.Value.Kind, ct).ConfigureAwait(false);
|
||||
var outcome = known
|
||||
? await ApplyAsync(tag, mode, plan.Winners.Select(w => w.Row).ToList(), ct).ConfigureAwait(false)
|
||||
: new Dictionary<long, UpsertOutcome>();
|
||||
planned = HistoryUpdatePlanner.Complete(plan, mode, outcome);
|
||||
}
|
||||
for (var k = 0; k < kept.Count; k++) final[kept[k]] = planned[k];
|
||||
return final;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
if (!HistorianTagNames.IsValid(tag)) return HistorianStatusCodes.BadInvalidArgument;
|
||||
if (!await _tags.ExistsAsync(tag, ct).ConfigureAwait(false))
|
||||
return HistoryUpdatePlanner.DeleteRawResult(tagKnown: false, 0);
|
||||
|
||||
var cursor = QuestDbConnection.Round(startUtc);
|
||||
var end = QuestDbConnection.Round(endUtc);
|
||||
long total = 0;
|
||||
while (cursor < end)
|
||||
{
|
||||
var page = new List<DateTime>(BatchSize);
|
||||
await using (var conn = await _conn.OpenAsync(ct).ConfigureAwait(false))
|
||||
await using (var cmd = _conn.CreateCommand(conn,
|
||||
$"SELECT ts FROM {_conn.SampleTable} WHERE tag = $1 AND ts >= $2 AND ts < $3 AND NOT deleted ORDER BY ts LIMIT {BatchSize}"))
|
||||
{
|
||||
QuestDbConnection.BindText(cmd, tag);
|
||||
QuestDbConnection.BindTimestamp(cmd, cursor);
|
||||
QuestDbConnection.BindTimestamp(cmd, end);
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await rd.ReadAsync(ct).ConfigureAwait(false)) page.Add(QuestDbConnection.ReadUtc(rd, 0));
|
||||
}
|
||||
if (page.Count == 0) break;
|
||||
await WriteTombstonesAsync(tag, page, ct).ConfigureAwait(false);
|
||||
// The wait between pages is required: without it the next page could re-read rows just tombstoned.
|
||||
await _wal.WaitAsync(_conn.SampleTable, ct).ConfigureAwait(false);
|
||||
total += page.Count;
|
||||
if (page.Count < BatchSize) break;
|
||||
cursor = page[^1].AddTicks(ResolutionTicks);
|
||||
}
|
||||
return HistoryUpdatePlanner.DeleteRawResult(tagKnown: true, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<uint>> DeleteAtTimeAsync(string tag, IReadOnlyList<DateTime> timesUtc, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
ArgumentNullException.ThrowIfNull(timesUtc);
|
||||
if (!HistorianTagNames.IsValid(tag))
|
||||
{
|
||||
var refused = new uint[timesUtc.Count];
|
||||
Array.Fill(refused, HistorianStatusCodes.BadInvalidArgument);
|
||||
return refused;
|
||||
}
|
||||
|
||||
// M-2: round BEFORE both the delete and the comparison, so a sub-µs request time matches the row it deleted.
|
||||
// MinValue stays as is: the planner turns it into BadInvalidTimestamp.
|
||||
var rounded = timesUtc.Select(t => t == DateTime.MinValue ? t : QuestDbConnection.Round(t)).ToList();
|
||||
var candidates = new HashSet<long>();
|
||||
foreach (var t in rounded)
|
||||
if (t != DateTime.MinValue && t >= DateTime.UnixEpoch) candidates.Add(t.Ticks); // pre-1970 can hold no row
|
||||
var deleted = new HashSet<long>();
|
||||
if (candidates.Count == 0 || !await _tags.ExistsAsync(tag, ct).ConfigureAwait(false))
|
||||
return HistoryUpdatePlanner.DeleteAtTimeResults(rounded, deleted, tagKnown: false);
|
||||
|
||||
var live = await ReadLiveAsync(tag, candidates.Min(), candidates.Max(), ct).ConfigureAwait(false);
|
||||
var toDelete = candidates.Where(live.Contains).OrderBy(x => x).ToList();
|
||||
foreach (var chunk in toDelete.Chunk(BatchSize))
|
||||
await WriteTombstonesAsync(tag, chunk.Select(t => new DateTime(t, DateTimeKind.Utc)).ToList(), ct).ConfigureAwait(false);
|
||||
if (toDelete.Count > 0) await _wal.WaitAsync(_conn.SampleTable, ct).ConfigureAwait(false);
|
||||
deleted.UnionWith(toDelete);
|
||||
return HistoryUpdatePlanner.DeleteAtTimeResults(rounded, deleted, tagKnown: true);
|
||||
}
|
||||
|
||||
private async Task<bool> EnsureAsync(string tag, HistorianValueKind kind, CancellationToken ct)
|
||||
{
|
||||
await _tags.EnsureAsync(tag, HistorianDataTypes.FromKind(kind), ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<long, UpsertOutcome>> ApplyAsync(string tag, HistoryUpdateMode mode,
|
||||
List<HistorianSampleRow> rows, CancellationToken ct)
|
||||
{
|
||||
// Key = the planner's tick (what Complete looks up); the stored time is Round(of the same row).
|
||||
var live = await ReadLiveAsync(tag, rows.Min(r => QuestDbConnection.Round(r.TimeUtc).Ticks),
|
||||
rows.Max(r => QuestDbConnection.Round(r.TimeUtc).Ticks), ct).ConfigureAwait(false);
|
||||
var outcome = new Dictionary<long, UpsertOutcome>();
|
||||
var toWrite = new List<HistorianSampleRow>();
|
||||
foreach (var r in rows)
|
||||
{
|
||||
var present = live.Contains(QuestDbConnection.Round(r.TimeUtc).Ticks);
|
||||
if (mode == HistoryUpdateMode.Insert && present) continue;
|
||||
if (mode == HistoryUpdateMode.Replace && !present) continue;
|
||||
outcome[r.TimeUtc.Ticks] = present ? UpsertOutcome.Replaced : UpsertOutcome.Inserted;
|
||||
toWrite.Add(r);
|
||||
}
|
||||
foreach (var chunk in toWrite.Chunk(BatchSize))
|
||||
await WriteRowsAsync(tag, chunk, ct).ConfigureAwait(false);
|
||||
if (toWrite.Count > 0) await _wal.WaitAsync(_conn.SampleTable, ct).ConfigureAwait(false);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/// <summary>Ticks of the live (present, not deleted) rows with <c>ts</c> in <c>[minTicks, maxTicks]</c>: one range query.</summary>
|
||||
private async Task<HashSet<long>> ReadLiveAsync(string tag, long minTicks, long maxTicks, CancellationToken ct)
|
||||
{
|
||||
var live = new HashSet<long>();
|
||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = _conn.CreateCommand(conn,
|
||||
$"SELECT ts FROM {_conn.SampleTable} WHERE tag = $1 AND ts >= $2 AND ts <= $3 AND NOT deleted");
|
||||
QuestDbConnection.BindText(cmd, tag);
|
||||
QuestDbConnection.BindTimestamp(cmd, new DateTime(minTicks, DateTimeKind.Utc));
|
||||
QuestDbConnection.BindTimestamp(cmd, new DateTime(maxTicks, DateTimeKind.Utc));
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await rd.ReadAsync(ct).ConfigureAwait(false)) live.Add(QuestDbConnection.ReadUtc(rd, 0).Ticks);
|
||||
return live;
|
||||
}
|
||||
|
||||
private Task WriteRowsAsync(string tag, IReadOnlyList<HistorianSampleRow> rows, CancellationToken ct) =>
|
||||
InsertAsync(rows.Count, ct, (cmd, i) =>
|
||||
{
|
||||
var row = rows[i];
|
||||
var c = SampleColumnMapper.ToColumns(row.Value);
|
||||
QuestDbConnection.BindTimestamp(cmd, row.TimeUtc, clampToEpoch: true);
|
||||
QuestDbConnection.BindText(cmd, tag);
|
||||
QuestDbConnection.BindByte(cmd, c.ValueKind);
|
||||
Typed(cmd, NpgsqlDbType.Double, c.NumValue);
|
||||
Typed(cmd, NpgsqlDbType.Bigint, c.IntValue);
|
||||
Typed(cmd, NpgsqlDbType.Varchar, c.StrValue);
|
||||
QuestDbConnection.BindLong(cmd, SampleColumnMapper.ToLongStatus(row.StatusCode));
|
||||
Typed(cmd, NpgsqlDbType.Boolean, false);
|
||||
});
|
||||
|
||||
private Task WriteTombstonesAsync(string tag, IReadOnlyList<DateTime> times, CancellationToken ct) =>
|
||||
InsertAsync(times.Count, ct, (cmd, i) =>
|
||||
{
|
||||
QuestDbConnection.BindTimestamp(cmd, times[i], clampToEpoch: true);
|
||||
QuestDbConnection.BindText(cmd, tag);
|
||||
QuestDbConnection.BindByte(cmd, (byte)HistorianValueKind.Empty);
|
||||
Typed(cmd, NpgsqlDbType.Double, null);
|
||||
Typed(cmd, NpgsqlDbType.Bigint, null);
|
||||
Typed(cmd, NpgsqlDbType.Varchar, null);
|
||||
QuestDbConnection.BindLong(cmd, SampleColumnMapper.ToLongStatus(HistorianStatusCodes.BadNoData));
|
||||
Typed(cmd, NpgsqlDbType.Boolean, true);
|
||||
});
|
||||
|
||||
/// <summary>Multi-row INSERT; <paramref name="bindRow"/> adds the first eight parameters of a row, this adds server_ts.</summary>
|
||||
private async Task InsertAsync(int count, CancellationToken ct, Action<NpgsqlCommand, int> bindRow)
|
||||
{
|
||||
if (count == 0) return;
|
||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = _conn.CreateCommand(conn, "");
|
||||
var now = DateTime.UtcNow;
|
||||
var sb = new System.Text.StringBuilder(
|
||||
$"INSERT INTO {_conn.SampleTable} (ts, tag, kind, num_value, int_value, str_value, status, deleted, server_ts) VALUES ");
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
bindRow(cmd, i);
|
||||
QuestDbConnection.BindTimestamp(cmd, now);
|
||||
var b = i * 9;
|
||||
if (i > 0) sb.Append(',');
|
||||
sb.Append('(');
|
||||
for (var p = 1; p <= 9; p++) sb.Append(p > 1 ? "," : "").Append('$').Append(b + p);
|
||||
sb.Append(')');
|
||||
}
|
||||
cmd.CommandText = sb.ToString();
|
||||
try
|
||||
{
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_log.LogWarning(ex, "QuestDB HistoryUpdate INSERT of {Count} rows into {Table} failed.", count, _conn.SampleTable);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Typed(NpgsqlCommand cmd, NpgsqlDbType type, object? value) =>
|
||||
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = type, Value = value ?? DBNull.Value });
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
|
||||
/// <summary>Soft-delete behaviour. The writer-redelivery case lives in T7 (<c>TombstoneTests.Redelivery.cs</c>).</summary>
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class TombstoneTests(QuestDbFixture fx)
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_then_raw_read_hides_the_row_and_leaves_a_NULL_valued_tombstone()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var rig = UpdateTests.MakeRig(fx);
|
||||
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert,
|
||||
[UpdateTests.V(T0, 1), UpdateTests.V(T0.AddSeconds(1), 2)], default);
|
||||
(await rig.Updater.DeleteAtTimeAsync("t", [T0], default)).ShouldBe([HistorianStatusCodes.Good]);
|
||||
|
||||
(await UpdateTests.RowsAsync(rig, "t", liveOnly: true)).Select(r => r.Num).ShouldBe([2d]);
|
||||
var all = await UpdateTests.RowsAsync(rig, "t");
|
||||
all.Count.ShouldBe(2); // the tombstone replaced the row in place (dedup upsert)
|
||||
var tomb = all[0];
|
||||
tomb.Deleted.ShouldBeTrue();
|
||||
tomb.Num.ShouldBeNull();
|
||||
tomb.Kind.ShouldBe((byte)0);
|
||||
tomb.Status.ShouldBe((long)HistorianStatusCodes.BadNoData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Insert_over_a_tombstone_reports_GoodEntryInserted_and_the_row_is_visible()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var rig = UpdateTests.MakeRig(fx);
|
||||
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [UpdateTests.V(T0, 1)], default);
|
||||
await rig.Updater.DeleteAtTimeAsync("t", [T0], default);
|
||||
|
||||
(await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace, [UpdateTests.V(T0, 5)], default))
|
||||
.ShouldBe([HistorianStatusCodes.BadNoEntryExists]); // a tombstone is no entry for Replace
|
||||
(await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [UpdateTests.V(T0, 9)], default))
|
||||
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
|
||||
var live = await UpdateTests.RowsAsync(rig, "t", liveOnly: true);
|
||||
live.Count.ShouldBe(1);
|
||||
live[0].Num.ShouldBe(9d);
|
||||
live[0].Deleted.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
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;
|
||||
|
||||
/// <summary>QuestDB-specific checks of <see cref="QuestDbHistorianDataUpdater"/> (the literal codes run through conformance in T7).</summary>
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class UpdateTests(QuestDbFixture fx)
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc); // µs-aligned
|
||||
private const uint Good = HistorianStatusCodes.Good;
|
||||
|
||||
internal sealed record Rig(QuestDbConnection Conn, QuestDbHistorianDataUpdater Updater, QuestDbTagCatalog Tags);
|
||||
|
||||
internal static Rig MakeRig(QuestDbFixture fx, TimeSpan? visibility = null)
|
||||
{
|
||||
var o = fx.Options(fx.UniquePrefix());
|
||||
o.VisibilityTimeout = visibility ?? o.VisibilityTimeout;
|
||||
var c = new QuestDbConnection(o);
|
||||
var wal = new QuestDbWalVisibility(c, o.VisibilityTimeout);
|
||||
var tags = new QuestDbTagCatalog(c, new QuestDbSchemaBootstrapper(c, o), wal);
|
||||
return new Rig(c, new QuestDbHistorianDataUpdater(c, tags, wal), tags);
|
||||
}
|
||||
|
||||
internal static HistorizationValue V(DateTime? t, double v, uint status = Good) =>
|
||||
new(t, HistorianValue.FromDouble(v), status);
|
||||
|
||||
internal sealed record Row(DateTime Ts, byte Kind, double? Num, long? Status, bool Deleted);
|
||||
|
||||
/// <summary>Every stored row for the tag (tombstones included), straight from the table.</summary>
|
||||
internal static async Task<List<Row>> RowsAsync(Rig rig, string tag, bool liveOnly = false)
|
||||
{
|
||||
await using var conn = await rig.Conn.OpenAsync(default);
|
||||
await using var cmd = rig.Conn.CreateCommand(conn,
|
||||
$"SELECT ts, kind, num_value, status, deleted FROM {rig.Conn.SampleTable} WHERE tag = $1 {(liveOnly ? "AND NOT deleted" : "")} ORDER BY ts");
|
||||
QuestDbConnection.BindText(cmd, tag);
|
||||
var rows = new List<Row>();
|
||||
await using var r = await cmd.ExecuteReaderAsync();
|
||||
while (await r.ReadAsync())
|
||||
rows.Add(new Row(QuestDbConnection.ReadUtc(r, 0), QuestDbConnection.ReadKind(r, 1),
|
||||
r.IsDBNull(2) ? null : r.GetDouble(2), r.IsDBNull(3) ? null : r.GetInt64(3), r.GetBoolean(4)));
|
||||
return rows;
|
||||
}
|
||||
|
||||
internal static async Task<long> TagRowsAsync(Rig rig, string tag)
|
||||
{
|
||||
await using var conn = await rig.Conn.OpenAsync(default);
|
||||
await using var cmd = rig.Conn.CreateCommand(conn, $"SELECT count() FROM {rig.Conn.TagTable} WHERE name = $1");
|
||||
QuestDbConnection.BindText(cmd, tag);
|
||||
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Insert_writes_absent_and_refuses_existing_and_is_visible_immediately()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var rig = MakeRig(fx);
|
||||
(await rig.Updater.UpdateDataAsync("t1", HistoryUpdateMode.Insert, [V(T0, 1)], default))
|
||||
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
|
||||
var codes = await rig.Updater.UpdateDataAsync("t1", HistoryUpdateMode.Insert,
|
||||
[V(T0, 99), V(T0.AddSeconds(1), 2)], default);
|
||||
codes.ShouldBe([HistorianStatusCodes.BadEntryExists, HistorianStatusCodes.GoodEntryInserted]);
|
||||
var rows = await RowsAsync(rig, "t1"); // read right after return: read-your-writes
|
||||
rows.Select(r => r.Num).ShouldBe([1d, 2d]);
|
||||
(await TagRowsAsync(rig, "t1")).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replace_overwrites_present_refuses_missing_and_never_creates_the_tag()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var rig = MakeRig(fx);
|
||||
(await rig.Updater.UpdateDataAsync("nope", HistoryUpdateMode.Replace, [V(T0, 1)], default))
|
||||
.ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
|
||||
(await TagRowsAsync(rig, "nope")).ShouldBe(0);
|
||||
(await RowsAsync(rig, "nope")).ShouldBeEmpty();
|
||||
|
||||
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T0, 1)], default);
|
||||
var codes = await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace,
|
||||
[V(T0, 5), V(T0.AddSeconds(1), 6)], default);
|
||||
codes.ShouldBe([HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.BadNoEntryExists]);
|
||||
(await RowsAsync(rig, "t")).Select(r => r.Num).ShouldBe([5d]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_upserts_both_ways_and_last_duplicate_wins()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var rig = MakeRig(fx);
|
||||
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T0, 1)], default);
|
||||
var codes = await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Update,
|
||||
[V(T0, 2), V(T0.AddSeconds(1), 3), V(T0.AddSeconds(1).AddTicks(3), 4)], default);
|
||||
codes.ShouldBe([HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.GoodEntryInserted,
|
||||
HistorianStatusCodes.GoodEntryInserted]);
|
||||
(await RowsAsync(rig, "t")).Select(r => r.Num).ShouldBe([2d, 4d]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Same_microsecond_duplicates_collapse_before_the_database_sees_them()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var rig = MakeRig(fx);
|
||||
// offsets 0 and 3 ticks sit inside one 10-tick window => the same stored µs
|
||||
var codes = await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert,
|
||||
[V(T0, 1), V(T0.AddTicks(3), 2)], default);
|
||||
codes.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadEntryExists]);
|
||||
(await RowsAsync(rig, "t")).Select(r => r.Num).ShouldBe([1d]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_items_get_the_planners_codes_and_the_rest_proceed()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var rig = MakeRig(fx);
|
||||
var codes = await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert,
|
||||
[
|
||||
V(null, 1), V(DateTime.MinValue, 1), V(T0, double.NaN),
|
||||
new HistorizationValue(T0.AddSeconds(1), HistorianValue.Empty, Good),
|
||||
V(T0.AddSeconds(2), 7),
|
||||
], default);
|
||||
codes.ShouldBe([HistorianStatusCodes.BadInvalidTimestamp, HistorianStatusCodes.BadInvalidTimestamp,
|
||||
HistorianStatusCodes.BadInvalidArgument, HistorianStatusCodes.BadInvalidArgument,
|
||||
HistorianStatusCodes.GoodEntryInserted]);
|
||||
(await RowsAsync(rig, "t")).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Over_long_tag_is_refused_and_nothing_is_written()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var rig = MakeRig(fx);
|
||||
var tag = new string('x', HistorianTagNames.MaxLength + 1);
|
||||
(await rig.Updater.UpdateDataAsync(tag, HistoryUpdateMode.Insert, [V(T0, 1)], default))
|
||||
.ShouldBe([HistorianStatusCodes.BadInvalidArgument]);
|
||||
(await rig.Updater.DeleteAtTimeAsync(tag, [T0], default)).ShouldBe([HistorianStatusCodes.BadInvalidArgument]);
|
||||
(await rig.Updater.DeleteRawAsync(tag, T0, T0.AddHours(1), default)).ShouldBe(HistorianStatusCodes.BadInvalidArgument);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pre_1970_items_are_BadOutOfRange_and_nothing_is_sent()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var rig = MakeRig(fx);
|
||||
var pre = new DateTime(1969, 12, 31, 23, 59, 59, DateTimeKind.Utc);
|
||||
foreach (var mode in new[] { HistoryUpdateMode.Insert, HistoryUpdateMode.Replace, HistoryUpdateMode.Update })
|
||||
{
|
||||
var codes = await rig.Updater.UpdateDataAsync("t", mode, [V(pre, 1), V(DateTime.MinValue, 1), V(T0, 2)], default);
|
||||
codes[0].ShouldBe(HistorianStatusCodes.BadOutOfRange);
|
||||
codes[1].ShouldBe(HistorianStatusCodes.BadInvalidTimestamp);
|
||||
}
|
||||
// Pre-1970 only: the whole batch is refused, no tag row is created, no INSERT would succeed anyway.
|
||||
(await rig.Updater.UpdateDataAsync("only-pre", HistoryUpdateMode.Update, [V(pre, 1)], default))
|
||||
.ShouldBe([HistorianStatusCodes.BadOutOfRange]);
|
||||
(await TagRowsAsync(rig, "only-pre")).ShouldBe(0);
|
||||
(await rig.Updater.DeleteAtTimeAsync("t", [pre], default)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAtTime_tombstones_live_rows_and_reports_per_time_codes()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var rig = MakeRig(fx);
|
||||
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T0, 1), V(T0.AddSeconds(1), 2)], default);
|
||||
var codes = await rig.Updater.DeleteAtTimeAsync("t",
|
||||
[T0, T0.AddSeconds(5), T0, DateTime.MinValue], default);
|
||||
codes.ShouldBe([Good, HistorianStatusCodes.BadNoEntryExists, HistorianStatusCodes.BadNoEntryExists,
|
||||
HistorianStatusCodes.BadInvalidTimestamp]);
|
||||
(await RowsAsync(rig, "t", liveOnly: true)).Select(r => r.Num).ShouldBe([2d]);
|
||||
// a tombstone is not a live row: deleting it again finds nothing
|
||||
(await rig.Updater.DeleteAtTimeAsync("t", [T0], default)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
|
||||
(await rig.Updater.DeleteAtTimeAsync("unknown", [T0], default)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAtTime_with_a_sub_microsecond_request_time_still_reports_Good_for_the_row_it_deleted()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var rig = MakeRig(fx);
|
||||
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T0, 1)], default);
|
||||
// +7 ticks is inside the same µs; the row lives at T0.
|
||||
(await rig.Updater.DeleteAtTimeAsync("t", [T0.AddTicks(7)], default)).ShouldBe([Good]);
|
||||
(await RowsAsync(rig, "t", liveOnly: true)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteRaw_is_half_open_pages_through_big_ranges_and_reports_BadNoData_when_empty()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var rig = MakeRig(fx);
|
||||
(await rig.Updater.DeleteRawAsync("unknown", T0, T0.AddHours(1), default)).ShouldBe(HistorianStatusCodes.BadNoData);
|
||||
|
||||
// 2,500 rows => 3 pages of at most 1,000; plus one row exactly at the (exclusive) end.
|
||||
var values = Enumerable.Range(0, 2500).Select(i => V(T0.AddSeconds(i), i)).ToList();
|
||||
values.Add(V(T0.AddSeconds(2500), -1));
|
||||
(await rig.Updater.UpdateDataAsync("big", HistoryUpdateMode.Insert, values, default))
|
||||
.ShouldAllBe(c => c == HistorianStatusCodes.GoodEntryInserted);
|
||||
(await rig.Updater.DeleteRawAsync("big", T0, T0.AddSeconds(2500), default)).ShouldBe(Good);
|
||||
var live = await RowsAsync(rig, "big", liveOnly: true);
|
||||
live.Count.ShouldBe(1);
|
||||
live[0].Num.ShouldBe(-1d);
|
||||
(await rig.Updater.DeleteRawAsync("big", T0, T0.AddSeconds(2500), default)).ShouldBe(HistorianStatusCodes.BadNoData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteRaw_with_a_pre_1970_start_reads_from_the_bound_unclamped()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var rig = MakeRig(fx);
|
||||
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T0, 1), V(T0.AddSeconds(1), 2)], default);
|
||||
(await rig.Updater.DeleteRawAsync("t", new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc), T0.AddSeconds(1), default))
|
||||
.ShouldBe(Good);
|
||||
(await RowsAsync(rig, "t", liveOnly: true)).Select(r => r.Num).ShouldBe([2d]);
|
||||
// DateTime.MinValue as the start is nudged inside, not bound as -infinity
|
||||
(await rig.Updater.DeleteRawAsync("t", DateTime.MinValue, DateTime.MaxValue, default)).ShouldBe(Good);
|
||||
(await RowsAsync(rig, "t", liveOnly: true)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_after_write_is_visible_across_many_immediate_calls()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var rig = MakeRig(fx);
|
||||
// ~15% of un-waited inserts are not yet visible (spike Q4), so 40 round trips expose a missing wait.
|
||||
for (var i = 0; i < 40; i++)
|
||||
{
|
||||
var t = T0.AddSeconds(i);
|
||||
(await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(t, i)], default))
|
||||
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
|
||||
(await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(t, i + 1000)], default))
|
||||
.ShouldBe([HistorianStatusCodes.GoodEntryReplaced], $"iteration {i}: the insert was not visible");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user