fix(sql): attribute a duplicated key to its own tags; key-value cap gets 2x headroom (R06:S-1 review I-1, m-3)
One stray duplicate row used to fail every tag in a key-value group or none of them, depending on whether an unrelated key was absent. Duplicated keys are now refused per key: only that key's tags publish BadConfigurationError and the rest of the group reads normally. The key-value cap reads up to 2x the distinct keys (+1 to detect excess), so a few stray duplicates are attributed per key and only a gross excess (history/events table) fails the whole group. Duplicated-key refusals count as answered refusals for health. The row-cap, duplicate-key and multi-row warnings now use the per-plan limiter (m-3); the reader-wide limiter is removed. Unit tests pin both the all-keys-present and absent-key cases with Good controls; live SQL Server gate added (23/23). Refs #744 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -116,6 +116,69 @@ public sealed class SqlServerReadTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review I-1, live: one stray duplicate row in a key-value table fails ONLY that key's tag; the other
|
||||
/// key in the same group (one capped <c>SELECT TOP (5) … IN (@k0, @k1)</c>) reads Good — the positive
|
||||
/// control — and the driver stays Healthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_realSqlServer_aDuplicatedKeyFailsOnlyItsOwnTag()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
const string table = "RowCapCurrent";
|
||||
var qualified = $"{SqlPollServerFixture.Schema}.{table}";
|
||||
|
||||
await using (var arrange = await _sql.OpenInspectionConnectionAsync())
|
||||
{
|
||||
await using var command = arrange.CreateCommand();
|
||||
command.CommandText = $"""
|
||||
IF OBJECT_ID(N'{qualified}') IS NOT NULL DROP TABLE {qualified};
|
||||
CREATE TABLE {qualified} (
|
||||
{SqlPollServerFixture.KeyColumn} nvarchar(64) NOT NULL,
|
||||
{SqlPollServerFixture.ValueColumn} float NULL,
|
||||
{SqlPollServerFixture.TimestampColumn} datetime2 NOT NULL);
|
||||
INSERT INTO {qualified} VALUES
|
||||
(N'Pump7', 1.0, '2026-07-24T10:00:00'),
|
||||
(N'Pump7', 2.0, '2026-07-24T10:00:01'),
|
||||
(N'Pump8', 8.5, '2026-07-24T10:00:00');
|
||||
""";
|
||||
await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var driver = await StartAsync(
|
||||
KeyValueOn("Sql/Current/Pump7", "Pump7", table),
|
||||
KeyValueOn("Sql/Current/Pump8", "Pump8", table));
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
["Sql/Current/Pump7", "Sql/Current/Pump8"], TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
Convert.ToDouble(snapshots[1].Value).ShouldBe(8.5);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var cleanup = await _sql.OpenInspectionConnectionAsync();
|
||||
await using var drop = cleanup.CreateCommand();
|
||||
drop.CommandText = $"DROP TABLE IF EXISTS {qualified}";
|
||||
await drop.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static RawTagEntry KeyValueOn(string rawPath, string key, string table) => Tag(rawPath, new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = SqlPollServerFixture.Qualified(table),
|
||||
["keyColumn"] = SqlPollServerFixture.KeyColumn,
|
||||
["keyValue"] = key,
|
||||
["valueColumn"] = SqlPollServerFixture.ValueColumn,
|
||||
["timestampColumn"] = SqlPollServerFixture.TimestampColumn,
|
||||
});
|
||||
|
||||
// ---- key-value model ----
|
||||
|
||||
/// <summary>The plan's headline case: a seeded key-value row round-trips as a Good snapshot.</summary>
|
||||
|
||||
@@ -32,8 +32,9 @@ public class SqlGroupPlannerTests
|
||||
// ---- R06:S-1: the row cap ----
|
||||
|
||||
/// <summary>
|
||||
/// A key-value plan asks for one row more than it has distinct keys, and a where-pair wide-row plan for
|
||||
/// two: enough to SEE an ambiguous selector, never enough to materialise a history table every poll.
|
||||
/// A key-value plan reads up to twice its distinct keys (so stray duplicates are attributed per key) and
|
||||
/// asks for one row more; a where-pair wide-row plan asks for two. Enough to SEE an ambiguous selector,
|
||||
/// never enough to materialise a history table every poll.
|
||||
/// The topByTimestamp form keeps its single-row limit and needs no cap.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -49,8 +50,8 @@ public class SqlGroupPlannerTests
|
||||
], new SqlServerDialect()).ToList();
|
||||
|
||||
var kv = plans.Single(p => p.Model == SqlTagModel.KeyValue);
|
||||
kv.RowCap.ShouldBe(2);
|
||||
kv.SqlText.ShouldStartWith("SELECT TOP (3) ");
|
||||
kv.RowCap.ShouldBe(4); // 2 distinct keys, with 2x headroom for per-key attribution
|
||||
kv.SqlText.ShouldStartWith("SELECT TOP (5) ");
|
||||
|
||||
var wherePair = plans.Single(p => p.Parameters.Count == 1 && p.Model == SqlTagModel.WideRow);
|
||||
wherePair.RowCap.ShouldBe(1);
|
||||
@@ -90,7 +91,7 @@ public class SqlGroupPlannerTests
|
||||
new SqlServerDialect());
|
||||
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT TOP (2) [tag_name], [num_value], [sample_ts] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
"SELECT TOP (3) [tag_name], [num_value], [sample_ts] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { "@k0" });
|
||||
plans[0].SelectedColumns.ShouldBe(new[] { "tag_name", "num_value", "sample_ts" });
|
||||
plans[0].Model.ShouldBe(SqlTagModel.KeyValue);
|
||||
@@ -107,7 +108,7 @@ public class SqlGroupPlannerTests
|
||||
new SqlServerDialect());
|
||||
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT TOP (2) [tag_name], [num_value] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
"SELECT TOP (3) [tag_name], [num_value] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
plans[0].TimestampColumn.ShouldBeNull();
|
||||
}
|
||||
|
||||
@@ -233,7 +234,7 @@ public class SqlGroupPlannerTests
|
||||
KvTag("B", "TagValues", "tag_name", "Line1.Temp", "num_value", null),
|
||||
], new ColonMarkerDialect()).ToList();
|
||||
|
||||
plans[0].SqlText.ShouldEndWith("IN (:k0, :k1) LIMIT 3");
|
||||
plans[0].SqlText.ShouldEndWith("IN (:k0, :k1) LIMIT 5");
|
||||
plans[0].SqlText.ShouldNotContain("@");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { ":k0", ":k1" });
|
||||
}
|
||||
@@ -456,7 +457,7 @@ public class SqlGroupPlannerTests
|
||||
// A column name is an identifier — it cannot be parameterized, so the guarantee is that it is
|
||||
// bracket-quoted with ] doubled, leaving one (nonexistent) identifier rather than executable SQL.
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT TOP (2) [tag_name], [v]] ; DROP TABLE Users --] FROM [dbo].[T] WHERE [tag_name] IN (@k0)");
|
||||
"SELECT TOP (3) [tag_name], [v]] ; DROP TABLE Users --] FROM [dbo].[T] WHERE [tag_name] IN (@k0)");
|
||||
}
|
||||
|
||||
// ---- contract edges ----
|
||||
|
||||
@@ -272,25 +272,59 @@ public sealed class SqlPollReaderTests
|
||||
snapshots[0].SourceTimestampUtc!.Value.ShouldBe(snapshots[0].ServerTimestampUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-1 review I-1.</b> One stray duplicate row is attributed to ITS key: that key's tags publish
|
||||
/// <see cref="SqlStatusCodes.BadConfigurationError"/> and every other key in the group reads normally.
|
||||
/// It used to be last-row-wins Good, or the whole group Bad, depending on whether an UNRELATED key was
|
||||
/// absent. Both variants are pinned: every other key present, and another key absent. The other keys'
|
||||
/// Good / BadNoData are the positive controls.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task ReadAsync_aDuplicatedKey_failsOnlyThatKeysTags_whateverElseIsAbsent(bool withAnAbsentKey)
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new RecordingLogger();
|
||||
fixture.Execute(
|
||||
$"INSERT INTO {SqlitePollFixture.KeyValueTable} " +
|
||||
$"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " +
|
||||
$"VALUES ('{SqlitePollFixture.PresentKey}', 99.0, '2026-07-24T10:00:09Z')");
|
||||
var tags = new List<SqlTagDefinition>
|
||||
{
|
||||
KvTag("Speed", SqlitePollFixture.PresentKey),
|
||||
KvTag("Speed2", SqlitePollFixture.PresentKey), // a second tag on the duplicated key
|
||||
KvTag("Pressure", SqlitePollFixture.SecondPresentKey),
|
||||
};
|
||||
if (withAnAbsentKey) tags.Add(KvTag("Gone", SqlitePollFixture.AbsentKey));
|
||||
var reader = Reader(fixture, logger, [.. tags]);
|
||||
|
||||
var snapshots = await reader.ReadAsync([.. tags.Select(t => t.Name)], CancellationToken.None);
|
||||
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
snapshots[2].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
snapshots[2].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
|
||||
if (withAnAbsentKey) snapshots[3].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
|
||||
|
||||
var warning = logger.Entries.ShouldHaveSingleItem();
|
||||
warning.Message.ShouldContain(SqlitePollFixture.PresentKey);
|
||||
}
|
||||
|
||||
/// <summary>The driver counts a duplicated-key refusal as the database answering, like an over-cap one.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_whenASourceViolatesOneRowPerKey_takesTheLastRowDeterministically()
|
||||
public async Task ReadDetailedAsync_aDuplicatedKey_isReportedAsAnAnsweredRefusal()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
fixture.Execute(
|
||||
$"INSERT INTO {SqlitePollFixture.KeyValueTable} " +
|
||||
$"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " +
|
||||
$"VALUES ('{SqlitePollFixture.PresentKey}', 99.0, '2026-07-24T10:00:09Z')");
|
||||
// Two keys, one absent: the duplicate fits inside the row cap (2), so it is not provably a history
|
||||
// table — the last-row-wins rule still applies there (the cap test below covers the other side).
|
||||
var reader = Reader(fixture,
|
||||
KvTag("Speed", SqlitePollFixture.PresentKey), KvTag("Gone", SqlitePollFixture.AbsentKey));
|
||||
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
|
||||
var clean = Reader(fixture, KvTag("Pressure", SqlitePollFixture.SecondPresentKey));
|
||||
|
||||
var snapshots = await reader.ReadAsync(["Speed", "Gone"], CancellationToken.None);
|
||||
|
||||
// Design §3.6: the contract is one row per key; when a source breaks it the reader is at least
|
||||
// deterministic — last occurrence in reader order — rather than silently arbitrary.
|
||||
snapshots[0].Value.ShouldBe(99.0);
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
|
||||
(await reader.ReadDetailedAsync(["Speed"], CancellationToken.None)).AnsweredRefusalGroups.ShouldBe(1);
|
||||
(await clean.ReadDetailedAsync(["Pressure"], CancellationToken.None)).AnsweredRefusalGroups.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user