diff --git a/docs/plans/revision-write-path.md b/docs/plans/revision-write-path.md index f2940cd..f25e2e7 100644 --- a/docs/plans/revision-write-path.md +++ b/docs/plans/revision-write-path.md @@ -78,21 +78,61 @@ 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 +### Cache gate is inside the native C++ HistorianClient -Both `AddStreamedValue` (AddS2) and `AddNonStreamedValue` (revision -write) hit the same client-side cache gate. That gate isn't bypassed by: +Followup probe (2026-05-05) tested the **direct** public overload +`HistorianAccess.AddNonStreamedValue(ConnectionIndex, HistorianDataValue, bool validate, ref error)` +which bypasses the `HistorianDataValueList` layer entirely and goes +straight to `HistorianClient.AddNonStreamedValueAsync` (a C++ method). + +Even with `validate=false` and `TagKey=12 (SysTimeSec)`, the call +fails: `ErrorCode=TagNotFoundInCache (129)`. + +So the 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 +4. Bypassing the `HistorianDataValueList` layer (calling the direct + `HistorianAccess.AddNonStreamedValue` overload) -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. +The check is inside the **native C++ `HistorianClient`'s per-connection +tag cache**, not in the managed wrapper. No managed-callable path exists +to populate that cache. + +### Critical insight: the SDK doesn't use the C++ HistorianClient + +The SDK's production code talks **WCF directly** — no C++ HistorianClient +instance, no per-connection local cache to gate against. The cache check +is enforced by the `aahClientManaged.dll` wrapper, not by the WCF server. + +This means the SDK could **plausibly** implement the revision-write +path against the existing +`ITransactionServiceContract.AddNonStreamValuesBegin/AddNonStreamValues/AddNonStreamValuesEnd` +contract methods and have the server accept it directly — bypassing the +gate that blocks the native wrapper. + +**Unverified assumptions:** + +- The server may have its own cache requirement that mirrors the + C++ wrapper's. If yes, the SDK is also blocked. If no, the SDK + can write where the wrapper can't. +- The server may require `RTag2` (RegisterTags2) to be called per-tag + before AddNonStreamValues — that's a known WCF op, already declared + in `IHistoryServiceContract2`, used by the existing event flow. The + SDK could call it. +- The server may require an IO-server-style registration that's not + exposable over the WCF surface at all. + +**Recommendation:** if D2 is ever pursued, do it as a **direct +WCF-level implementation in the SDK**, NOT as a wrapper over the +native HistorianAccess methods. The harness can no longer help (the +wrapper itself is gated). Test paths against the live server by +calling the contract methods directly and observing what the server +returns. If `AddNonStreamValues` succeeds without registration, the +path is implementable. If it fails with a server-side cache error, +try `RTag2` first. If it still fails, the path is genuinely blocked +server-side. ## Decision diff --git a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs index 0442e42..c29be6d 100644 --- a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs +++ b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs @@ -541,6 +541,63 @@ internal static class Program snapshots["DataValueListBeforeSend"] = SnapshotObject(listInstance); + // Try the DIRECT public AddNonStreamedValue overload on HistorianAccess — + // (ConnectionIndex, HistorianDataValue, bool, ref error). This bypasses + // the DataValueList layer and goes straight to HistorianClient.AddNonStreamedValueAsync. + // If it succeeds where the list path failed, the cache gate is in the list-side + // ValidateValue rather than the native client. + if (HasFlag(args, "--write-revision-direct")) + { + try + { + MethodInfo[] directCandidates = accessType.GetMethods(allInstance) + .Where(m => m.Name == "AddNonStreamedValue") + .ToArray(); + rows.Add(new + { + Kind = "DirectAddNonStreamedValueCandidates", + Count = directCandidates.Length, + Sigs = directCandidates.Select(m => string.Join(",", m.GetParameters().Select(p => p.ParameterType.Name))).ToArray(), + }); + // Pick the 4-param overload — (ConnectionIndex, HistorianDataValue, + // bool, error&). Drop the IsPublic filter; reflection with + // NonPublic binding flags can call internal methods. + MethodInfo direct = directCandidates.First(m => m.GetParameters().Length == 4); + object directError = Activator.CreateInstance(errorType)!; + object?[] directArgs = new object?[4]; + // ConnectionIndex enum values are internal — list with NonPublic + // flags first, then probe both 0 and 1 (most enums use these for + // primary connection slots). For Process scenario it's typically 0. + System.Reflection.FieldInfo[] ciFields = connectionIndexEnum.GetFields( + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + rows.Add(new + { + Kind = "ConnectionIndexFields", + Fields = ciFields.Where(f => f.IsLiteral) + .Select(f => $"{f.Name}={Convert.ToInt32(f.GetRawConstantValue())}").ToArray(), + }); + // Default: index 0 (cast int -> enum) + directArgs[0] = Enum.ToObject(connectionIndexEnum, 0); + directArgs[1] = revValue; + directArgs[2] = false; // skip validate + directArgs[3] = directError; + bool directSuccess = (bool)direct.Invoke(access, directArgs)!; + object directErrorAfter = directArgs[3]!; + rows.Add(new + { + Kind = "DirectAddNonStreamedValue", + Success = directSuccess, + ErrorDescription = GetPropertyText(directErrorAfter, "ErrorDescription"), + ErrorCode = GetPropertyText(directErrorAfter, "ErrorCode"), + ErrorType = GetPropertyText(directErrorAfter, "ErrorType"), + }); + } + catch (Exception ex) + { + rows.Add(new { Kind = "DirectAddNonStreamedValueException", Type = ex.GetType().Name, Message = ex.Message, Inner = ex.InnerException?.Message }); + } + } + // 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