From 2d4fbc857f86cb26b4b1efef3debe652e8a1ac4e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 9 Oct 2026 13:07:44 -0400 Subject: [PATCH] feat(historian): SQL Server HistoryUpdate data updater (T11) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../SqlServerHistorianDataUpdater.cs | 190 ++++++++++++++++ .../UpdateTests.cs | 204 ++++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/UpdateTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs new file mode 100644 index 00000000..fbca0c44 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs @@ -0,0 +1,190 @@ +using Microsoft.Data.SqlClient; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; + +/// Status codes owned by the updater (named consts so StatusCodeParityTests sees them). +internal static class SqlHistorianUpdateStatusCodes +{ + /// A value that cannot be stored (Double NaN / ±Infinity). + public const uint BadInvalidArgument = 0x80AB0000u; +} + +/// +/// OPC UA HistoryUpdate against the SQL Server sample table (spec §4 "Updates"). One transaction per call; +/// exactly one status per input item, in input order. +/// Duplicate timestamps within one request (compared at full 100 ns tick precision): +/// Insert — the first occurrence is applied, later ones are BadEntryExists; Replace/Update — last wins, +/// and every earlier occurrence of that timestamp reports the same code as the winning one. +/// DeleteAtTime — the first occurrence reports the real outcome, later ones BadNoEntryExists. +/// Invalid items. A null or timestamp is +/// BadInvalidTimestamp. A Double NaN/±Infinity cannot live in the SQL float column and is +/// rejected with BadInvalidArgument (not silently stored as Empty). Other items still proceed. +/// Unknown tag. Insert/Update create it via ; Replace and the +/// deletes report BadNoEntryExists (DeleteRaw: BadNoData) without creating anything. +/// +internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater +{ + private readonly SqlHistorianConnection _conn; + private readonly SqlServerTagCatalog _tags; + + /// Creates the updater. + /// Connection helper. + /// Tag catalog (runs the schema bootstrap itself). + public SqlServerHistorianDataUpdater(SqlHistorianConnection conn, SqlServerTagCatalog tags) + { + _conn = conn; + _tags = tags; + } + + /// + public async Task> UpdateDataAsync(string tag, HistoryUpdateMode mode, + IReadOnlyList values, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(tag); + ArgumentNullException.ThrowIfNull(values); + var results = new uint[values.Count]; + var winner = new Dictionary(); // tick -> index of the item actually sent + for (var i = 0; i < values.Count; i++) + { + var v = values[i]; + if (v.TimestampUtc is not { } ts || ts == DateTime.MinValue) + { + results[i] = SqlHistorianStatusCodes.BadInvalidTimestamp; + continue; + } + if (v.Value.Kind == HistorianValueKind.Double && !double.IsFinite(v.Value.Number)) + { + results[i] = SqlHistorianUpdateStatusCodes.BadInvalidArgument; + continue; + } + if (!winner.TryGetValue(ts.Ticks, out _)) + winner[ts.Ticks] = i; + else if (mode == HistoryUpdateMode.Insert) + results[i] = SqlHistorianStatusCodes.BadEntryExists; // first occurrence stays the winner + else + winner[ts.Ticks] = i; // last wins + } + + var send = winner.Values.OrderBy(i => i).ToList(); + if (send.Count == 0) return results; + + var tagId = mode == HistoryUpdateMode.Replace + ? await _tags.TryGetTagIdAsync(tag, ct).ConfigureAwait(false) + : await _tags.GetOrCreateTagIdAsync(tag, DataTypeFor(values[send[0]].Value.Kind), ct).ConfigureAwait(false); + + var outcome = tagId is null + ? new Dictionary() + : await MergeAsync(tagId.Value, mode, send.Select(i => new SqlSampleRow( + values[i].TimestampUtc!.Value, values[i].Value, values[i].StatusCode)).ToList(), ct).ConfigureAwait(false); + + // No OUTPUT row means the MERGE's absent branch: Insert => matched (exists); Replace => not matched. + var absent = mode == HistoryUpdateMode.Replace + ? SqlHistorianStatusCodes.BadNoEntryExists + : SqlHistorianStatusCodes.BadEntryExists; + foreach (var i in send) + results[i] = outcome.TryGetValue(values[i].TimestampUtc!.Value.Ticks, out var code) ? code : absent; + + // Superseded earlier duplicates (Replace/Update) share the winner's code. + if (mode != HistoryUpdateMode.Insert) + for (var i = 0; i < values.Count; i++) + if (values[i].TimestampUtc is { } ts && results[i] == 0 + && winner.TryGetValue(ts.Ticks, out var w) && w != i) + results[i] = results[w]; + return results; + } + + /// + public async Task DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(tag); + var tagId = await _tags.TryGetTagIdAsync(tag, ct).ConfigureAwait(false); + if (tagId is null) return SqlHistorianStatusCodes.BadNoData; + await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); + await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(ct).ConfigureAwait(false); + int rows; + await using (var cmd = _conn.CreateCommand(conn, + $"DELETE FROM {_conn.Q("Sample")} WHERE TagId = @tagId AND SourceTimeUtc >= @s AND SourceTimeUtc < @e", tx)) + { + cmd.Parameters.Add(new SqlParameter("@tagId", System.Data.SqlDbType.Int) { Value = tagId.Value }); + SqlHistorianConnection.AddDateTime2(cmd, "@s", startUtc); + SqlHistorianConnection.AddDateTime2(cmd, "@e", endUtc); + rows = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + await tx.CommitAsync(ct).ConfigureAwait(false); + return rows > 0 ? SqlHistorianStatusCodes.Good : SqlHistorianStatusCodes.BadNoData; + } + + /// + public async Task> DeleteAtTimeAsync(string tag, IReadOnlyList timesUtc, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(tag); + ArgumentNullException.ThrowIfNull(timesUtc); + var results = new uint[timesUtc.Count]; + var first = new Dictionary(); + for (var i = 0; i < timesUtc.Count; i++) + { + results[i] = SqlHistorianStatusCodes.BadNoEntryExists; + if (timesUtc[i] == DateTime.MinValue) results[i] = SqlHistorianStatusCodes.BadInvalidTimestamp; + else first.TryAdd(timesUtc[i].Ticks, i); + } + if (first.Count == 0) return results; + var tagId = await _tags.TryGetTagIdAsync(tag, ct).ConfigureAwait(false); + if (tagId is null) return results; + + await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); + await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(ct).ConfigureAwait(false); + await using (var cmd = _conn.CreateCommand(conn, + $"DELETE s OUTPUT deleted.SourceTimeUtc FROM {_conn.Q("Sample")} AS s JOIN @times AS t ON s.SourceTimeUtc = t.T WHERE s.TagId = @tagId", tx)) + { + cmd.Parameters.Add(new SqlParameter("@tagId", System.Data.SqlDbType.Int) { Value = tagId.Value }); + _conn.AddStructured(cmd, "@times", "TimeRow", + SampleRowMapper.ToTimeRecords(first.Keys.Select(t => new DateTime(t, DateTimeKind.Utc)))); + await using var rd = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await rd.ReadAsync(ct).ConfigureAwait(false)) + if (first.TryGetValue(SqlHistorianConnection.GetUtc(rd, 0).Ticks, out var idx)) + results[idx] = SqlHistorianStatusCodes.Good; + } + await tx.CommitAsync(ct).ConfigureAwait(false); + return results; + } + + private async Task> MergeAsync(int tagId, HistoryUpdateMode mode, + List rows, CancellationToken ct) + { + var sql = $"MERGE {_conn.Q("Sample")} WITH (HOLDLOCK) AS t USING @rows AS r ON t.TagId = @tagId AND t.SourceTimeUtc = r.SourceTimeUtc "; + if (mode != HistoryUpdateMode.Insert) + sql += "WHEN MATCHED THEN UPDATE SET ValueKind = r.ValueKind, NumValue = r.NumValue, IntValue = r.IntValue, " + + "StrValue = r.StrValue, StatusCode = r.StatusCode, ServerTimeUtc = SYSUTCDATETIME() "; + if (mode != HistoryUpdateMode.Replace) + sql += "WHEN NOT MATCHED THEN INSERT (TagId, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc) " + + "VALUES (@tagId, r.SourceTimeUtc, r.ValueKind, r.NumValue, r.IntValue, r.StrValue, r.StatusCode, SYSUTCDATETIME()) "; + sql += "OUTPUT $action, r.SourceTimeUtc;"; + + var outcome = new Dictionary(); + await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); + await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(ct).ConfigureAwait(false); + await using (var cmd = _conn.CreateCommand(conn, sql, tx)) + { + cmd.Parameters.Add(new SqlParameter("@tagId", System.Data.SqlDbType.Int) { Value = tagId }); + _conn.AddStructured(cmd, "@rows", "SampleRow", SampleRowMapper.ToSampleRecords(rows)); + await using var rd = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await rd.ReadAsync(ct).ConfigureAwait(false)) + outcome[SqlHistorianConnection.GetUtc(rd, 1).Ticks] = rd.GetString(0) == "INSERT" + ? SqlHistorianStatusCodes.GoodEntryInserted + : SqlHistorianStatusCodes.GoodEntryReplaced; + } + await tx.CommitAsync(ct).ConfigureAwait(false); + return outcome; + } + + // T9 owns the canonical HistorianValueKind -> Tag.DataType mapping; a later fix wave will dedupe the two. + private static byte DataTypeFor(HistorianValueKind kind) => (byte)(kind switch + { + HistorianValueKind.Int64 => DriverDataType.Int64, + HistorianValueKind.Boolean => DriverDataType.Boolean, + HistorianValueKind.String => DriverDataType.String, + HistorianValueKind.DateTime => DriverDataType.DateTime, + _ => DriverDataType.Float64, + }); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/UpdateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/UpdateTests.cs new file mode 100644 index 00000000..670aeead --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/UpdateTests.cs @@ -0,0 +1,204 @@ +using Microsoft.Data.SqlClient; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; + +[Collection(SqlHistorianCollection.Name)] +public sealed class UpdateTests(SqlHistorianFixture fx) +{ + private const uint Good = SqlHistorianStatusCodes.Good; + private const uint UncertainStatus = SqlHistorianStatusCodes.UncertainDataSubNormal; + + // Deliberately NOT millisecond aligned: sub-ms ticks must survive the TVP, MERGE join and DELETE join. + private static readonly DateTime T0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static DateTime T(int n) => T0.AddTicks(1234567L * n + 3); + + private (SqlServerHistorianDataUpdater U, string Schema) Make() + { + fx.SkipIfUnavailable(); + var schema = SqlHistorianFixture.UniqueSchema(); + var o = new SqlServerHistorianOptions { ConnectionString = fx.ConnectionString, Schema = schema }; + var conn = new SqlHistorianConnection(o); + var boot = new SqlServerSchemaBootstrapper(conn, o); + return (new SqlServerHistorianDataUpdater(conn, new SqlServerTagCatalog(conn, boot)), schema); + } + + private static HistorizationValue V(DateTime? t, double d, uint status = Good) => + new(t, HistorianValue.FromDouble(d), status); + + private async Task> RowsAsync(string schema, string tag) + { + await using var c = new SqlConnection(fx.ConnectionString); + await c.OpenAsync(); + await using var cmd = c.CreateCommand(); + cmd.CommandText = $"SELECT s.SourceTimeUtc, s.NumValue, s.StatusCode FROM [{schema}].Sample s JOIN [{schema}].Tag g ON g.TagId=s.TagId WHERE g.Name=@n ORDER BY s.SourceTimeUtc"; + cmd.Parameters.AddWithValue("@n", tag); + var list = new List<(DateTime, double?, int)>(); + await using var rd = await cmd.ExecuteReaderAsync(); + while (await rd.ReadAsync()) + list.Add((DateTime.SpecifyKind(rd.GetDateTime(0), DateTimeKind.Utc), rd.IsDBNull(1) ? null : rd.GetDouble(1), rd.GetInt32(2))); + return list; + } + + private async Task TagCountAsync(string schema) + { + await using var c = new SqlConnection(fx.ConnectionString); + await c.OpenAsync(); + await using var cmd = c.CreateCommand(); + cmd.CommandText = $"SELECT COUNT(*) FROM [{schema}].Tag"; + return Convert.ToInt64(await cmd.ExecuteScalarAsync()); + } + + private static readonly CancellationToken Ct = CancellationToken.None; + + [Fact] + public async Task Insert_new_is_GoodEntryInserted_and_existing_is_BadEntryExists_in_order() + { + var (u, s) = Make(); + var r1 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], Ct); + r1.ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.GoodEntryInserted]); + // Mixed: T(2) exists, T(3) new, T(1) exists - out of timestamp order on purpose. + var r2 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(3), 3), V(T(2), 99), V(T(1), 98)], Ct); + r2.ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.BadEntryExists, SqlHistorianStatusCodes.BadEntryExists]); + var rows = await RowsAsync(s, "t"); + rows.Select(x => x.Num).ShouldBe([1d, 2d, 3d]); // existing values untouched + rows.Select(x => x.Time).ShouldBe([T(1), T(2), T(3)]); // sub-ms ticks preserved + } + + [Fact] + public async Task Replace_existing_is_GoodEntryReplaced_with_new_value_and_status_missing_is_BadNoEntryExists() + { + var (u, s) = Make(); + await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], Ct); + var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, + [V(T(2), 22, UncertainStatus), V(T(5), 5), V(T(1), 11)], Ct); + r.ShouldBe([SqlHistorianStatusCodes.GoodEntryReplaced, SqlHistorianStatusCodes.BadNoEntryExists, SqlHistorianStatusCodes.GoodEntryReplaced]); + var rows = await RowsAsync(s, "t"); + rows.Count.ShouldBe(2); // T(5) was not created + rows[0].Num.ShouldBe(11d); + rows[0].Status.ShouldBe((int)Good); + rows[1].Num.ShouldBe(22d); + rows[1].Status.ShouldBe(unchecked((int)UncertainStatus)); // replaced value visible with its new status + } + + [Fact] + public async Task Update_upserts_inserted_or_replaced_per_item() + { + var (u, s) = Make(); + await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct); + var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(2), 2), V(T(1), 10)], Ct); + r.ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.GoodEntryReplaced]); + (await RowsAsync(s, "t")).Select(x => x.Num).ShouldBe([10d, 2d]); + } + + [Fact] + public async Task Unknown_tag_Insert_and_Update_create_it_but_Replace_and_deletes_do_not() + { + var (u, s) = Make(); + (await u.UpdateDataAsync("rep", HistoryUpdateMode.Replace, [V(T(1), 1)], Ct)) + .ShouldBe([SqlHistorianStatusCodes.BadNoEntryExists]); + (await u.DeleteRawAsync("del", T(0), T(9), Ct)).ShouldBe(SqlHistorianStatusCodes.BadNoData); + (await u.DeleteAtTimeAsync("del2", [T(1)], Ct)).ShouldBe([SqlHistorianStatusCodes.BadNoEntryExists]); + (await TagCountAsync(s)).ShouldBe(0); // none of those created a tag + + (await u.UpdateDataAsync("ins", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct)).ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted]); + (await u.UpdateDataAsync("upd", HistoryUpdateMode.Update, [V(T(1), 1)], Ct)).ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted]); + (await TagCountAsync(s)).ShouldBe(2); + } + + [Fact] + public async Task Null_and_MinValue_timestamps_are_BadInvalidTimestamp_and_others_proceed() + { + var (u, s) = Make(); + var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, + [V(null, 1), V(T(1), 2), V(DateTime.MinValue, 3)], Ct); + r.ShouldBe([SqlHistorianStatusCodes.BadInvalidTimestamp, SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.BadInvalidTimestamp]); + (await RowsAsync(s, "t")).Count.ShouldBe(1); + + // All-invalid request: no DB work, no tag created, still one code per item. + var (u2, s2) = Make(); + await u2.UpdateDataAsync("seed", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct); // bootstraps the schema + (await u2.UpdateDataAsync("x", HistoryUpdateMode.Insert, [V(null, 1)], Ct)).ShouldBe([SqlHistorianStatusCodes.BadInvalidTimestamp]); + (await TagCountAsync(s2)).ShouldBe(1); // only "seed"; "x" was never created + } + + [Fact] + public async Task NaN_and_Infinity_are_rejected_with_BadInvalidArgument_and_others_proceed() + { + var (u, s) = Make(); + var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, + [V(T(1), double.NaN), V(T(2), 2), V(T(3), double.PositiveInfinity), V(T(4), double.NegativeInfinity)], Ct); + r.ShouldBe([SqlHistorianUpdateStatusCodes.BadInvalidArgument, SqlHistorianStatusCodes.GoodEntryInserted, + SqlHistorianUpdateStatusCodes.BadInvalidArgument, SqlHistorianUpdateStatusCodes.BadInvalidArgument]); + (await RowsAsync(s, "t")).Select(x => x.Num).ShouldBe([2d]); + } + + [Fact] + public async Task Duplicate_timestamp_in_one_Insert_request_second_is_BadEntryExists_first_wins() + { + var (u, s) = Make(); + var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(1), 99)], Ct); + r.ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.BadEntryExists]); + (await RowsAsync(s, "t")).Select(x => x.Num).ShouldBe([1d, 2d]); + } + + [Fact] + public async Task Duplicate_timestamp_in_one_Replace_or_Update_request_last_wins_and_all_share_the_code() + { + var (u, s) = Make(); + await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct); + var rep = await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T(1), 5), V(T(1), 6)], Ct); + rep.ShouldBe([SqlHistorianStatusCodes.GoodEntryReplaced, SqlHistorianStatusCodes.GoodEntryReplaced]); + (await RowsAsync(s, "t")).Single().Num.ShouldBe(6d); + + var upd = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(7), 7), V(T(7), 8)], Ct); + upd.ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.GoodEntryInserted]); + (await RowsAsync(s, "t")).Last().Num.ShouldBe(8d); + } + + [Fact] + public async Task Replace_and_Update_can_change_the_value_kind_and_clear_stale_columns() + { + var (u, s) = Make(); + await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [new HistorizationValue(T(1), HistorianValue.FromDouble(1.5), Good)], Ct); + (await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, + [new HistorizationValue(T(1), HistorianValue.FromString("hi"), Good)], Ct)).ShouldBe([SqlHistorianStatusCodes.GoodEntryReplaced]); + var rows = await RowsAsync(s, "t"); + rows.Single().Num.ShouldBeNull(); // NumValue cleared by the String replace + } + + [Fact] + public async Task DeleteRaw_is_half_open_Good_when_rows_removed_BadNoData_when_none() + { + var (u, s) = Make(); + await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(3), 3)], Ct); + // [T(1), T(3)) removes T(1) and T(2); T(3) (the end) survives. + (await u.DeleteRawAsync("t", T(1), T(3), Ct)).ShouldBe(Good); + (await RowsAsync(s, "t")).Select(x => x.Time).ShouldBe([T(3)]); + (await u.DeleteRawAsync("t", T(1), T(3), Ct)).ShouldBe(SqlHistorianStatusCodes.BadNoData); + // sub-ms boundary: a start one tick after T(3) excludes it. + (await u.DeleteRawAsync("t", T(3).AddTicks(1), T(3).AddTicks(100), Ct)).ShouldBe(SqlHistorianStatusCodes.BadNoData); + (await RowsAsync(s, "t")).Count.ShouldBe(1); + } + + [Fact] + public async Task DeleteAtTime_reports_per_time_in_order_with_exact_tick_matching() + { + var (u, s) = Make(); + await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(3), 3)], Ct); + var r = await u.DeleteAtTimeAsync("t", [T(3), T(2).AddTicks(1) /* near miss */, T(1), DateTime.MinValue], Ct); + r.ShouldBe([Good, SqlHistorianStatusCodes.BadNoEntryExists, Good, SqlHistorianStatusCodes.BadInvalidTimestamp]); + (await RowsAsync(s, "t")).Select(x => x.Time).ShouldBe([T(2)]); + } + + [Fact] + public async Task DeleteAtTime_duplicate_time_first_reports_Good_later_BadNoEntryExists() + { + var (u, s) = Make(); + await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct); + (await u.DeleteAtTimeAsync("t", [T(1), T(1)], Ct)).ShouldBe([Good, SqlHistorianStatusCodes.BadNoEntryExists]); + (await RowsAsync(s, "t")).ShouldBeEmpty(); + } +}