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
-11
@@ -175,7 +175,7 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
||||
// (ts, modified_id); maxValues + 1 rows are fetched to report HasMore exactly. A row whose status is
|
||||
// NULL had no prior value (an insert) and is served null / BadNoData. Because the trail row lands
|
||||
// first, a crash between it and the sample write leaves an orphan row describing a change that never
|
||||
// applied (documented).
|
||||
// applied; on a marker-format trail such a row stays `pending` and is reconciled below.
|
||||
/// <inheritdoc />
|
||||
public async Task<HistoryModifiedReadResult> ReadModifiedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValues, HistoryModifiedCursor? resumeAfter,
|
||||
@@ -211,20 +211,37 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
||||
"modification_ts, update_type, principal";
|
||||
|
||||
// The applied marker: a trail row still `pending` is served only when the change it describes evidently
|
||||
// landed — it is the LATEST row at its time and the sample there (a tombstone included) was written at or
|
||||
// after the modification: a crash between the change and its marker. Otherwise it is the orphan of a change
|
||||
// that never landed (typically followed by the client's successful retry, which is then the latest row) and
|
||||
// is dropped. The ASOF JOIN reads the tag's samples of the window only (indexed tag, inclusive bounds); the
|
||||
// resume position filters OUTSIDE the window function so it never cuts a time's partition.
|
||||
// landed. Two pieces of evidence, one per position in its time's trail:
|
||||
// - it is the LATEST row at its time and the sample there (a tombstone included) was written at or after
|
||||
// the modification — a crash, or a failed marker write, between the change and its marker;
|
||||
// - it is NOT the latest and the NEXT row at its time recorded a different prior value — the next change
|
||||
// saw what this one wrote (lmxopcua#778: V0→V1 lands, its marker fails, a later V1→V2 must not hide it).
|
||||
// A change that never landed left the sample alone, so the next row records the same prior value as
|
||||
// this one (kind, value columns, status and the prior's server_ts — a rewrite always moves server_ts),
|
||||
// and the row is the orphan of that failed change (typically followed by the client's successful
|
||||
// retry) and is dropped.
|
||||
// QuestDB window functions do not take VARCHAR, so the next row's prior is fetched by a LEFT JOIN on
|
||||
// (ts, next modified_id) rather than lead(). The ASOF JOIN reads the tag's samples of the window only
|
||||
// (indexed tag, inclusive bounds); the resume position filters OUTSIDE the window functions so it never
|
||||
// cuts a time's partition.
|
||||
var sql = _bootstrap.SupportsTrailMarker
|
||||
? $"SELECT {Columns} FROM (SELECT m.ts ts, m.kind kind, m.num_value num_value, m.int_value int_value, " +
|
||||
? $"SELECT {Columns} FROM (SELECT x.ts ts, x.kind kind, x.num_value num_value, x.int_value int_value, " +
|
||||
"x.str_value str_value, x.status status, x.server_ts server_ts, x.modified_id modified_id, " +
|
||||
"x.modification_ts modification_ts, x.update_type update_type, x.principal principal, " +
|
||||
"(NOT x.pending OR (x.next_id = NULL AND x.sample_ts = x.ts AND x.sample_server_ts >= x.modification_ts) " +
|
||||
"OR (x.next_id != NULL AND NOT (n.kind = x.kind AND n.num_value = x.num_value AND n.int_value = x.int_value " +
|
||||
"AND n.str_value = x.str_value AND n.status = x.status AND n.server_ts = x.server_ts))) served " +
|
||||
"FROM (SELECT m.ts ts, m.kind kind, m.num_value num_value, m.int_value int_value, " +
|
||||
"m.str_value str_value, m.status status, m.server_ts server_ts, m.modified_id modified_id, " +
|
||||
"m.modification_ts modification_ts, m.update_type update_type, m.principal principal, m.pending pending, " +
|
||||
"s.ts sample_ts, s.server_ts sample_server_ts, max(m.modified_id) OVER (PARTITION BY m.ts) latest_id " +
|
||||
"s.ts sample_ts, s.server_ts sample_server_ts, " +
|
||||
"lead(m.modified_id) OVER (PARTITION BY m.ts ORDER BY m.modified_id) next_id " +
|
||||
$"FROM {_conn.SampleModifiedTable} m ASOF JOIN (SELECT ts, tag, server_ts FROM {_conn.SampleTable} " +
|
||||
$"WHERE tag = $1 AND ts >= $2 AND ts <= $3) s ON (tag) WHERE m.tag = $1 AND {window.Replace("ts ", "m.ts ")}) " +
|
||||
"WHERE (NOT pending OR (sample_ts = ts AND sample_server_ts >= modification_ts AND modified_id = latest_id))" +
|
||||
$"{resume} ORDER BY ts {order}, modified_id {order}"
|
||||
$"WHERE tag = $1 AND ts >= $2 AND ts <= $3) s ON (tag) WHERE m.tag = $1 AND {window.Replace("ts ", "m.ts ")}) x " +
|
||||
"LEFT JOIN (SELECT ts, modified_id, kind, num_value, int_value, str_value, status, server_ts " +
|
||||
$"FROM {_conn.SampleModifiedTable} WHERE tag = $1 AND ts >= $2 AND ts <= $3) n " +
|
||||
"ON (x.ts = n.ts AND x.next_id = n.modified_id)) " +
|
||||
$"WHERE served{resume} ORDER BY ts {order}, modified_id {order}"
|
||||
: $"SELECT {Columns} FROM {_conn.SampleModifiedTable} " +
|
||||
$"WHERE tag = $1 AND {window}{resume} ORDER BY ts {order}, modified_id {order}";
|
||||
|
||||
|
||||
+12
-7
@@ -35,9 +35,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
|
||||
/// <para><b>Applied marker.</b> On a trail table with the marker format
|
||||
/// (<see cref="QuestDbSchemaBootstrapper.SupportsTrailMarker"/>) the trail row is written <c>pending</c> and, once the
|
||||
/// sample change has landed, re-upserted with <c>pending = false</c> (same <c>(ts, tag, modified_id)</c> — the table's
|
||||
/// dedup key). A modified read drops a pending row unless the sample at its time was written at or after the
|
||||
/// modification (the change landed but the marker did not — a crash in between), so an orphan is no longer served as
|
||||
/// a change that happened. The trail INSERT is re-sent once (same ids) after a transient failure: a first attempt that
|
||||
/// dedup key). A modified read drops a pending row unless it can tell the change landed: as the latest row at its
|
||||
/// time, the sample there was written at or after the modification (a crash or a failed marker in between); below a
|
||||
/// later row at that time, that next row recorded a different prior value (the next change saw this one's result —
|
||||
/// lmxopcua#778). So an orphan is no longer served as a change that happened, and a change whose marker was lost stays
|
||||
/// served after later changes at the same time. The trail INSERT is re-sent once (same ids) after a transient failure: a first attempt that
|
||||
/// did apply converges through the dedup key instead of duplicating.</para>
|
||||
/// </summary>
|
||||
internal sealed class QuestDbHistorianDataUpdater : IHistorianDataUpdater
|
||||
@@ -361,8 +363,10 @@ internal sealed class QuestDbHistorianDataUpdater : IHistorianDataUpdater
|
||||
|
||||
/// <summary>
|
||||
/// The applied marker: re-upserts the trail rows of a change that has landed with <c>pending = false</c> (same
|
||||
/// <c>(ts, tag, modified_id)</c> key). A failure here does not fail the update — the change landed, and a modified read
|
||||
/// still serves a pending row whose sample shows the change (logged as a Warning).
|
||||
/// <c>(ts, tag, modified_id)</c> key). A failure here does not fail the update — the change landed — and is logged as
|
||||
/// a Warning. Its rows stay <c>pending</c>; a modified read serves each one only while the evidence holds: as the
|
||||
/// latest row at its time, when the sample there was written at or after the modification; once a later change at
|
||||
/// that time exists, when that change's trail row recorded a different prior value (lmxopcua#778).
|
||||
/// </summary>
|
||||
private async Task MarkAppliedAsync(string tag, IReadOnlyList<TrailRow> rows, CancellationToken ct)
|
||||
{
|
||||
@@ -375,8 +379,9 @@ internal sealed class QuestDbHistorianDataUpdater : IHistorianDataUpdater
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_log.LogWarning(ex, "QuestDB applied marker for {Count} trail row(s) of tag {Tag} was not written; the change landed and " +
|
||||
"modified reads still serve it.", rows.Count, tag);
|
||||
_log.LogWarning(ex, "QuestDB applied marker for {Count} trail row(s) of tag {Tag} was not written; the change landed, but " +
|
||||
"the rows stay pending and modified reads serve them only while the sample at their time, or the next change's " +
|
||||
"recorded prior value, shows the change.", rows.Count, tag);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-3
@@ -126,7 +126,7 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
rows += batch;
|
||||
DeleteRawBatches++;
|
||||
Interlocked.Increment(ref _deleteRawBatches);
|
||||
if (batch < DeleteRawBatchSize) break;
|
||||
}
|
||||
return HistoryUpdatePlanner.DeleteRawResult(tagKnown: true, rows);
|
||||
@@ -138,8 +138,10 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
|
||||
/// </summary>
|
||||
internal int DeleteRawBatchSize { get; set; } = 4000;
|
||||
|
||||
/// <summary>DeleteRaw batches run so far (lifetime) — test/diagnostic.</summary>
|
||||
internal int DeleteRawBatches { get; private set; }
|
||||
private int _deleteRawBatches;
|
||||
|
||||
/// <summary>DeleteRaw batches run so far (lifetime) — test/diagnostic. Atomic: concurrent DeleteRaw calls share it.</summary>
|
||||
internal int DeleteRawBatches => Volatile.Read(ref _deleteRawBatches);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<uint>> DeleteAtTimeAsync(string tag, IReadOnlyList<DateTime> timesUtc,
|
||||
|
||||
+6
@@ -95,6 +95,12 @@ internal sealed class SqlServerHistorianValueWriter : IHistorianValueWriter
|
||||
{
|
||||
throw; // the drain pass was cancelled (shutdown) — not a write failure of this tag
|
||||
}
|
||||
catch (SqlException ex) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// SqlClient reports a command cancelled mid-flight as a SqlException ("Operation cancelled by user"), not an
|
||||
// OperationCanceledException: still the drain pass's cancellation, never a write failure of this tag.
|
||||
throw new OperationCanceledException("The SQL historian live-value write was cancelled.", ex, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("SQL historian live-value write failed ({Exception}); will retry.", ex.GetType().Name);
|
||||
|
||||
Reference in New Issue
Block a user