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:
Joseph Doherty
2026-11-09 16:04:44 -05:00
parent c802f0e7ed
commit e790938f94
9 changed files with 245 additions and 37 deletions
@@ -5703,6 +5703,56 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
NormalizeHistoryValues(nodesToProcess, results);
}
/// <inheritdoc />
/// <remarks>
/// The SDK validates a HistoryReadEvents event filter before <see cref="HistoryReadEvents"/> runs and throws the
/// validation's status — <c>BadEventFilterInvalid</c> for a structural fault (operand count, a dangling element
/// operand) — but when the validation itself throws (an unknown filter operator) the request failed with the generic
/// <c>BadUnexpectedError</c>. Any failure of that validation is re-thrown as <c>BadEventFilterInvalid</c> (#778);
/// every other refusal (a bad time range…) and a valid filter pass through unchanged.
/// </remarks>
protected override void HistoryRead(
ServerSystemContext context,
HistoryReadDetails details,
TimestampsToReturn timestampsToReturn,
bool releaseContinuationPoints,
IList<HistoryReadValueId> nodesToRead,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
try
{
base.HistoryRead(context, details, timestampsToReturn, releaseContinuationPoints, nodesToRead, results, errors,
nodesToProcess, cache);
}
catch (ServiceResultException ex) when (ex.StatusCode != StatusCodes.BadEventFilterInvalid
&& details is ReadEventDetails { Filter: { } filter }
&& !IsValidEventFilter(filter, context))
{
throw ServiceResultException.Create(StatusCodes.BadEventFilterInvalid, ex,
"The HistoryReadEvents event filter is invalid: {0}", ex.Message);
}
}
/// <summary>The SDK's validation of a HistoryReadEvents event filter; a throw counts as invalid.</summary>
/// <param name="filter">The event filter.</param>
/// <param name="context">The request's context.</param>
/// <returns><c>true</c> when the filter validates Good.</returns>
private bool IsValidEventFilter(EventFilter filter, ServerSystemContext context)
{
try
{
var filterContext = new FilterContext(Server.NamespaceUris, Server.TypeTree, context.PreferredLocales, Server.Telemetry);
return ServiceResult.IsGood(filter.Validate(filterContext).Status);
}
catch (ServiceResultException)
{
return false;
}
}
/// <inheritdoc />
protected override void HistoryReadEvents(
ServerSystemContext context,