using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; /// /// DeleteRaw is paged: DELETE TOP (n) batches, each with its trail rows, instead of one unbounded statement and /// transaction (lock escalation on Sample, log growth, a CommandTimeout rolling the whole range back). The result, the /// window bounds and the Part 11 trail are unchanged. /// [Collection(SqlHistorianCollection.Name)] public sealed class DeleteRawPagingTests(SqlHistorianFixture fx) { private static readonly DateTime T0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); private static async Task ScalarAsync(ReadTestDb db, string sql) { await using var c = await db.Conn.OpenAsync(CancellationToken.None); await using var cmd = db.Conn.CreateCommand(c, sql); return Convert.ToInt64(await cmd.ExecuteScalarAsync()); } [Fact] public async Task A_range_larger_than_a_batch_is_deleted_in_batches_with_every_trail_row() { var db = await ReadTestDb.CreateAsync(fx); var u = new SqlServerHistorianDataUpdater(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot)) { DeleteRawBatchSize = 40 }; // Seeded with plain INSERTs (a 1,000-row HistoryUpdate MERGE is not what this test is about). await db.SeedAsync("t", Enumerable.Range(0, 102) .Select(i => (T0.AddSeconds(i), HistorianValue.FromDouble(i), ReadTestDb.Good)).ToArray()); var trailBefore = await ScalarAsync(db, $"SELECT COUNT(*) FROM {db.Conn.Q("SampleModified")}"); // [T0 + 1 s, T0 + 101 s): 100 rows — the first and the last sample lie outside the window. (await u.DeleteRawAsync("t", T0.AddSeconds(1), T0.AddSeconds(101), ConformanceData.Editor, CancellationToken.None)) .ShouldBe(HistorianStatusCodes.Good); u.DeleteRawBatches.ShouldBe(3, "40 + 40 + 20 — paged, not one statement"); (await ScalarAsync(db, $"SELECT COUNT(*) FROM {db.Conn.Q("Sample")}")).ShouldBe(2); (await ScalarAsync(db, $"SELECT COUNT(*) FROM {db.Conn.Q("SampleModified")}")).ShouldBe(trailBefore + 100, "every deleted row has its Delete trail row"); (await ScalarAsync(db, $"SELECT COUNT(*) FROM {db.Conn.Q("SampleModified")} WHERE UpdateType = {(byte)HistoryModificationType.Delete}")) .ShouldBe(100); // An empty range is still BadNoData, and an exact multiple of the batch size ends after one short batch. (await u.DeleteRawAsync("t", T0.AddSeconds(1), T0.AddSeconds(101), ConformanceData.Editor, CancellationToken.None)) .ShouldBe(HistorianStatusCodes.BadNoData); } [Fact] public async Task A_cancelled_delete_between_batches_stops_and_what_it_deleted_stays_audited() { var db = await ReadTestDb.CreateAsync(fx); var u = new SqlServerHistorianDataUpdater(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot)) { DeleteRawBatchSize = 10 }; await db.SeedAsync("t", Enumerable.Range(0, 100) .Select(i => (T0.AddSeconds(i), HistorianValue.FromDouble(i), ReadTestDb.Good)).ToArray()); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); await Should.ThrowAsync( () => u.DeleteRawAsync("t", T0, T0.AddSeconds(100), ConformanceData.Editor, cts.Token)); (await ScalarAsync(db, $"SELECT COUNT(*) FROM {db.Conn.Q("Sample")}")).ShouldBe(100, "cancelled before the first batch"); // Positive control: the same delete on a live token removes everything, auditing each row. (await u.DeleteRawAsync("t", T0, T0.AddSeconds(100), ConformanceData.Editor, CancellationToken.None)).ShouldBe(HistorianStatusCodes.Good); (await ScalarAsync(db, $"SELECT COUNT(*) FROM {db.Conn.Q("Sample")}")).ShouldBe(0); u.DeleteRawBatches.ShouldBe(11); } }