diff --git a/docs/plans/revision-write-path.md b/docs/plans/revision-write-path.md index dc41d6d..f2940cd 100644 --- a/docs/plans/revision-write-path.md +++ b/docs/plans/revision-write-path.md @@ -42,16 +42,57 @@ flow. ## Conclusion The revision-write path **does not bypass the AddS2 blocker** — it -shares the same `TagNotFoundInCache` precondition. There is no path -from a managed client to a successful AddNonStreamValues call against -a client-created sandbox tag. +shares the same `TagNotFoundInCache` precondition. -To validate this conclusion further (not done in this pass — too risky -for the production Historian) one could try AddNonStreamedValue against -a system tag like `SysTimeSec` whose key IS in the cache from upstream -registration. If that succeeds, the path is implementable in principle -for IO-registered tags; if it also fails, the prerequisite is even -stricter. +### Follow-up probe (2026-05-05): SysTimeSec + +To narrow the gate's scope, the harness was extended with +`--write-revision-target-tag ` (overrides the value's TagKey via +SQL lookup). Probed `SysTimeSec` (an auto-populated system tag whose +wwTagKey=12 is well-known in the runtime cache): + +``` +AddNonStreamedValue (TagKey=12 SysTimeSec): + Result=False + ErrorCode=TagNotFoundInCache + ErrorDescription="error = 129 (Tag not found in cache)" +``` + +Same failure. Then probed with `--write-revision-skip-validate` to set +the `validate` boolean to false on `AddNonStreamedValue` — same +`TagNotFoundInCache` failure. The cache check is intrinsic to the +function, not gated by the `validate` parameter. + +So the gate is **per-(client-session, tag)**, not per-(server-cache, tag): + +- Server-side, `SysTimeSec` IS in the runtime cache (it's auto-populated). +- Client-side, the managed library has its own per-connection tag list + that AddNonStreamedValue checks. That list is NOT populated by simply + knowing the wwTagKey — something else (likely a `RegisterTags2` call + during connection open, or the read flow as a side effect, or + IO-server-driven registration) populates it. + +The harness opens with `ReadOnly=false` for the write scenario, which +may suppress the read-flow side effect that would otherwise populate +the local cache. Without further RE on what populates the local cache, +no path is reachable for a managed client to write either streaming or +revision values. + +### Decisive blocker + +Both `AddStreamedValue` (AddS2) and `AddNonStreamedValue` (revision +write) hit the same client-side cache gate. That gate isn't bypassed by: + +1. Using a real wwTagKey from SQL +2. Targeting a server-cache-resident tag (SysTimeSec) +3. Setting `validate=false` on AddNonStreamedValue + +There is no managed-client path to a successful write against this +server architecture without first reverse-engineering and exercising +whatever populates the per-connection local cache. That's a much larger +investigation — likely involving the WCF `RegisterTags2` op, +HistorianClient C++ internals, and/or IO-server-driven cache +registration that managed clients can't trigger directly. ## Decision diff --git a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs index 87ef778..0442e42 100644 --- a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs +++ b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs @@ -460,9 +460,34 @@ internal static class Program ParameterCount = beginArgs.Length, }); + // Optional override: target a different tag (e.g. SysTimeSec) by name. + // Used to investigate whether the cache gate is per-tag (i.e. tags + // already in the runtime cache pass validation while client-created + // sandbox tags don't). + string? targetTagOverride = GetArg(args, "--write-revision-target-tag"); + uint revTagKey = tagKey; + if (!string.IsNullOrWhiteSpace(targetTagOverride)) + { + using System.Data.SqlClient.SqlConnection sqlT = new("Server=.;Database=Runtime;Integrated Security=SSPI;"); + sqlT.Open(); + using System.Data.SqlClient.SqlCommand cmdT = sqlT.CreateCommand(); + cmdT.CommandText = "SELECT wwTagKey FROM Tag WHERE TagName = @t"; + cmdT.Parameters.AddWithValue("@t", targetTagOverride); + object? overrideKey = cmdT.ExecuteScalar(); + if (overrideKey is int k) + { + revTagKey = (uint)k; + rows.Add(new { Kind = "RevisionTargetTagOverride", Tag = targetTagOverride, TagKey = revTagKey }); + } + else + { + rows.Add(new { Kind = "RevisionTargetTagOverrideNotFound", Tag = targetTagOverride }); + } + } + // Build a single HistorianDataValue for the revision sample. object revValue = Activator.CreateInstance(dataValueType)!; - SetProperty(revValue, "TagKey", tagKey); + SetProperty(revValue, "TagKey", revTagKey); SetProperty(revValue, "DataValueType", Enum.Parse(dataValueDataTypeEnum, "Float", ignoreCase: true)); SetProperty(revValue, "OpcQuality", (ushort)192); SetProperty(revValue, "Value", writeValue); @@ -483,13 +508,16 @@ internal static class Program .OrderBy(m => m.GetParameters().Length) .First(); // (HistorianDataValue value, bool validate, HistorianAccessError& error) + // --write-revision-skip-validate flips the bool to false to see if the + // cache gate is enforced inside this function or elsewhere downstream. + bool validateFlag = !HasFlag(args, "--write-revision-skip-validate"); object addError0 = Activator.CreateInstance(errorType)!; object?[] addArgs = new object?[addMethod.GetParameters().Length]; addArgs[0] = revValue; for (int i = 1; i < addArgs.Length - 1; i++) { Type pt = addMethod.GetParameters()[i].ParameterType; - addArgs[i] = pt == typeof(bool) ? true : pt.IsValueType ? Activator.CreateInstance(pt) : null; + addArgs[i] = pt == typeof(bool) ? validateFlag : pt.IsValueType ? Activator.CreateInstance(pt) : null; } addArgs[addArgs.Length - 1] = addError0; object addResult = addMethod.Invoke(listInstance, addArgs)!; @@ -513,6 +541,17 @@ internal static class Program snapshots["DataValueListBeforeSend"] = SnapshotObject(listInstance); + // Safety: require explicit --write-revision-commit to actually fire + // SendValues. Without it, the harness validates the path (cache gate, + // value validation) but does NOT push anything to the wire. Important + // when targeting system tags via --write-revision-target-tag. + bool commitRevision = HasFlag(args, "--write-revision-commit"); + if (!commitRevision) + { + rows.Add(new { Kind = "RevisionSendValuesSkipped", Reason = "Pass --write-revision-commit to actually call SendValues." }); + goto skipSendValues; + } + // SendValues drives the on-the-wire revision flow: // AddRevisionValuesBegin → AddRevisionValue × N → AddRevisionValuesEnd // → SendNonStreamedValues (the actual WCF push). @@ -550,6 +589,8 @@ internal static class Program ErrorType = GetPropertyText(sendError, "ErrorType"), }); snapshots["SendValuesError"] = SnapshotObject(sendError); + + skipSendValues:; } catch (Exception ex) {