fix(historian): T9 review round 1 - dup-key retry counter, alarm Classify retries unknown, test hardening

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 13:30:42 -04:00
parent bef47f89da
commit c2ab68ceb5
5 changed files with 71 additions and 39 deletions
@@ -68,18 +68,32 @@ public sealed class ProvisioningTests(SqlHistorianFixture fx)
var s = SqlHistorianFixture.UniqueSchema();
var (p0, _) = Make(s);
await p0.EnsureTagsAsync([new("warm", DriverDataType.Int32, null, null)], CancellationToken.None); // bootstrap once
for (var round = 0; round < 10; round++)
for (var round = 0; round < 20; round++)
{
var reqs = Enumerable.Range(0, 60).Select(i => new HistorianTagProvisionRequest($"r{round}.t{i}", DriverDataType.Float64, "u", null)).ToList();
var rs = await Task.WhenAll(Enumerable.Range(0, 4).Select(_ => Task.Run(() => Make(s).P.EnsureTagsAsync(reqs, CancellationToken.None))));
foreach (var r in rs) r.ShouldBe(new HistorianProvisionResult(60, 60, 0, 0));
var ps = Enumerable.Range(0, 16).Select(_ => Make(s).P).ToList();
// Warm each instance (its own bootstrap-once) and release all together, else bootstrap serialisation staggers the race away.
foreach (var p in ps) await p.EnsureTagsAsync([new("warm", DriverDataType.Int32, null, null)], CancellationToken.None);
var gate = new TaskCompletionSource();
// Staggered windows over one pool: every worker's FIRST name differs, so a MERGE without HOLDLOCK cannot hide behind
// a lock convoy on a shared first key and the overlap really races (union per round = 6 names; the single/few-row shape is the one T8 proved races).
var tasks = ps.Select((p, k) => Task.Run(async () =>
{
var reqs = Enumerable.Range(k % 4, 3).Select(i => new HistorianTagProvisionRequest($"r{round}.t{i}", DriverDataType.Float64, "u", null)).ToList();
await gate.Task;
return await p.EnsureTagsAsync(reqs, CancellationToken.None);
})).ToList();
gate.SetResult();
var rs = await Task.WhenAll(tasks);
foreach (var r in rs) r.ShouldBe(new HistorianProvisionResult(3, 3, 0, 0));
// The production dup-key retry would mask a missing HOLDLOCK; HOLDLOCK must mean it never fires.
ps.Sum(p => p.DupKeyRetryCount).ShouldBe(0);
}
await using var c = new SqlConnection(fx.ConnectionString);
await c.OpenAsync();
await using var cmd = c.CreateCommand();
cmd.CommandText = $"SELECT COUNT(*) FROM [{s}].Tag";
Convert.ToInt32(await cmd.ExecuteScalarAsync()).ShouldBe(601);
Convert.ToInt32(await cmd.ExecuteScalarAsync()).ShouldBe(20 * 6 + 1);
}
[Fact]
@@ -57,6 +57,7 @@ public sealed class WriteTests(SqlHistorianFixture fx)
for (var i = 0; i < 6; i++) rows[i].T.Ticks.ShouldBe(vals[i].TimestampUtc!.Value.Ticks);
var back = rows.Select(r => SampleRowMapper.FromColumns(r.Kind, r.N, r.I, r.S)).ToList();
back.ShouldBe(vals.Select(v => v.Value).ToList());
(rows[5].N, rows[5].I, rows[5].S).ShouldBe((null, null, null));
rows[1].Status.ShouldBe(unchecked((int)0x40000000u));
SampleRowMapper.FromDbStatus(rows[5].Status).ShouldBe(0x80310000u);
}
@@ -148,7 +149,13 @@ public sealed class WriteTests(SqlHistorianFixture fx)
fx.SkipIfUnavailable();
var (w, s) = Make();
var vals = Enumerable.Range(0, 200).Select(i => new HistorizationValue(T0.AddMilliseconds(i), HistorianValue.FromDouble(i), 0)).ToArray();
var results = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() => w.WriteLiveValuesAsync("tag.race", vals, CancellationToken.None))));
// A deadlock victim (1205) legitimately returns false and the recorder retries; model that, and require convergence.
var results = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(async () =>
{
for (var i = 0; i < 5; i++)
if (await w.WriteLiveValuesAsync("tag.race", vals, CancellationToken.None)) return true;
return false;
})));
results.ShouldAllBe(r => r);
(await RowsAsync(s, "tag.race")).Count.ShouldBe(200);
}
@@ -44,4 +44,12 @@ public sealed class AlarmEventIdTests
})
SqlServerAlarmHistorianWriter.ComputeEventId(other).ShouldNotBe(basis);
}
[Theory]
[InlineData(typeof(InvalidOperationException))]
[InlineData(typeof(ArgumentException))]
[InlineData(typeof(FormatException))]
public void Non_SQL_exceptions_retry_rather_than_dead_letter(Type type) =>
SqlServerAlarmHistorianWriter.Classify((Exception)Activator.CreateInstance(type)!)
.ShouldBe(HistorianWriteOutcome.RetryPlease);
}