fix(historian): QuestDB modified reads keep a landed change whose marker was lost; H2 review NITs (lmxopcua#778)
QuestDB modified-history reads dropped every still-pending trail row that was not the latest at its source time, so V0->V1 (applied marker never written) followed by V1->V2 hid the V0->V1 change for good. A pending row below the latest is now served when the NEXT trail row at that time recorded a different prior value (kind, value, status or the prior's server_ts) - the next change saw what this one wrote; an orphan of a change that never landed leaves the same prior for the next row and stays hidden. QuestDB window functions reject VARCHAR, so the next row's prior comes from a LEFT JOIN on (ts, lead(id)). The updater's comment and marker-failure log no longer claim modified reads "still serve it" unconditionally. Regression tests (QuestDB 10.0.1 throwaway container): marker lost then a replace / delete at the same time; an orphan followed by a marker-lost retry then a later change (orphan still dropped); the random marker-mix fuzz now varies the recorded priors. All four fail on the old filter. NITs: - SqlServerHistorianValueWriter: a SqlException raised while the token is cancelled (SqlClient's "Operation cancelled by user") is rethrown as OperationCanceledException instead of booked as a failed write. Integration test holds a TABLOCKX on Sample and cancels mid-INSERT; it returned false before. - SqlServerHistorianDataUpdater.DeleteRawBatches is an Interlocked counter. - HistoryReadEvents: an event filter whose SDK validation throws (unknown operator) failed with the generic BadUnexpectedError; the node manager now re-throws any failed event-filter validation as BadEventFilterInvalid. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
+28
@@ -67,6 +67,34 @@ public sealed class WriteTests(SqlHistorianFixture fx)
|
||||
(await RowsAsync(s, "tag.race")).Count.ShouldBe(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_write_cancelled_mid_statement_rethrows_instead_of_failing_the_tag()
|
||||
{
|
||||
// lmxopcua#778: SqlClient reports a command cancelled mid-flight as a SqlException, which the writer booked as a
|
||||
// failed write (false) — a shutdown could tip a healthy tag over its attempt budget. It must be a cancellation.
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, s) = Make();
|
||||
(await w.WriteLiveValuesAsync("tag.cancel", [new(T0, HistorianValue.FromDouble(0), 0)], CancellationToken.None))
|
||||
.ShouldBeTrue("bootstrap + tag id");
|
||||
|
||||
await using var blocker = new SqlConnection(fx.ConnectionString);
|
||||
var testCt = TestContext.Current.CancellationToken;
|
||||
await blocker.OpenAsync(testCt);
|
||||
await using var tx = (SqlTransaction)await blocker.BeginTransactionAsync(testCt);
|
||||
await using (var hold = new SqlCommand($"SELECT COUNT(*) FROM [{s}].Sample WITH (TABLOCKX, HOLDLOCK)", blocker, tx))
|
||||
await hold.ExecuteScalarAsync(testCt);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
var ex = await Should.ThrowAsync<OperationCanceledException>(() =>
|
||||
w.WriteLiveValuesAsync("tag.cancel", [new(T0.AddSeconds(1), HistorianValue.FromDouble(1), 0)], cts.Token));
|
||||
ex.CancellationToken.ShouldBe(cts.Token);
|
||||
await tx.RollbackAsync(testCt);
|
||||
|
||||
// Positive control: with the lock gone the same write succeeds.
|
||||
(await w.WriteLiveValuesAsync("tag.cancel", [new(T0.AddSeconds(1), HistorianValue.FromDouble(1), 0)], CancellationToken.None))
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_SQL_failure_returns_false_instead_of_throwing()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user