diff --git a/docs/plans/revision-write-path.md b/docs/plans/revision-write-path.md index 3cc9abb..dc41d6d 100644 --- a/docs/plans/revision-write-path.md +++ b/docs/plans/revision-write-path.md @@ -1,7 +1,94 @@ # Plan: Revision-Write Path (`AddRevisionValuesBegin/Value/End`) -Status: **NOT STARTED.** Sub-plan extracted from `speculative-items-sweep.md` -item D2 because the work is too large for a one-push sweep. +Status: **ARCHITECTURALLY BLOCKED — verified 2026-05-05.** Same root +cause as `AddS2`: client-side cache rejects values for tags that +weren't registered through a configured IO server / Application Server +pipeline. Documented below; implementation deferred until / unless that +prerequisite is removed. + +## Empirical finding (2026-05-05) + +The native trace harness was extended with `--write-revision-values` to +drive the revision flow: + +1. `HistorianAccess.CreateHistorianDataValueList(HistorianDataCategory.NonStreamedOriginal)` + succeeds — list is bound to the live `HistorianClient*` via + `GetClient(ConnectionIndex.Process)`. +2. `HistorianDataValueList.NonStreamedValuesBegin()` succeeds — list + batchID transitions 0 → 1. +3. `HistorianDataValueList.AddNonStreamedValue(value, validate=true, out error)` + **fails** with `ErrorCode=TagNotFoundInCache (129)`, + `ErrorDescription="error = 129 (Tag not found in cache)"` — the value + is never added to the list (`Count` stays 0). +4. `HistorianDataValueList.AddNonStreamedValuesEnd()` returns void. +5. `HistorianAccess.SendValues(list, out error)` returns `true` with + `ErrorCode=Success` — **but** no wire bytes left the client because + the list is empty. (Inspecting captured WriteMessage stream confirms + no `AddNonStreamValues*` Trx call appears.) + +The validation that rejects the value is the same gate that blocks +`AddStreamedValue` (`AddS2`): the library's local tag cache only knows +about tags that were: + +- Auto-populated from a configured IO server / Application Server pipeline, or +- Read via the existing read flow (which hits the cache as a side effect) + +Tags created via `HistorianAccess.AddTag` populate `Runtime.dbo.Tag` but +are not added to the in-memory cache that AddStreamedValue / +AddNonStreamedValue consult. So writes from a managed client to a +client-created tag fail at the validation gate before any wire bytes +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. + +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. + +## Decision + +Do **not** add public `WriteRevisionsAsync` / `BeginRevisionAsync` to +the SDK. The contract methods already exist in +`Wcf/Contracts/ITransactionServiceContract.cs` +(`AddNonStreamValuesBegin/AddNonStreamValues/AddNonStreamValuesEnd`) +for completeness, but the orchestrator and public surface stay absent. + +Revisit if either of these changes: + +1. AVEVA documents (or a customer demonstrates) a code path that + bypasses the cache validation for client-created tags. +2. The SDK's mission expands to include data correction for tags that + ARE in the runtime cache (i.e., tags managed by a real IO server), + in which case the harness extension below provides a starting point. + +## Harness diagnostic (preserved) + +The `--write-revision-values` flag in +`tools/AVEVA.Historian.NativeTraceHarness/Program.cs` reproduces the +above failure deterministically. Re-run it any time to verify the +blocker still holds: + +```powershell +dotnet run --no-build --project tools\AVEVA.Historian.NativeTraceHarness -- ` + --scenario write ` + --write-sandbox-tag RetestSdkWriteRevSandbox ` + --write-data-type Float ` + --write-skip-add-tag --write-skip-add-value ` + --write-revision-values +``` + +Look for the `AddNonStreamedValue` row's `ErrorCode` field in the JSON +output. + +## Original plan (preserved for context if the blocker ever lifts) ## Context diff --git a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs index 2800dbd..87ef778 100644 --- a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs +++ b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs @@ -393,6 +393,170 @@ internal static class Program } + // --write-revision-values triggers the revision (non-streamed) write path. + // Calls SendValues with a HistorianDataValueList populated via + // NonStreamedValuesBegin / AddNonStreamedValue / AddNonStreamedValuesEnd. + // Captures whatever happens — success, server-side rejection, or client + // gate — for protocol decoding purposes. + if (HasFlag(args, "--write-revision-values")) + { + try + { + Type dataValueListType = GetType(assembly, "ArchestrA.HistorianDataValueList"); + Type dataCategoryEnum = GetType(assembly, "ArchestrA.HistorianDataCategory"); + + // Use HistorianAccess.CreateHistorianDataValueList — the public factory that + // binds the list to the live HistorianClient* via GetClient(ConnectionIndex). + MethodInfo createListMethod = accessType.GetMethods() + .Where(m => m.Name == "CreateHistorianDataValueList") + .OrderBy(m => m.GetParameters().Length) + .First(); + rows.Add(new + { + Kind = "CreateHistorianDataValueListSig", + Params = createListMethod.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}").ToArray(), + }); + rows.Add(new + { + Kind = "EnumValues", + DataCategory = Enum.GetValues(dataCategoryEnum).Cast().Select(v => $"{v}={Convert.ToInt32(v)}").ToArray(), + ConnectionIndex = Enum.GetValues(connectionIndexEnum).Cast().Select(v => $"{v}={Convert.ToInt32(v)}").ToArray(), + }); + + // Pick non-zero values where appropriate. Common AVEVA convention: + // HistorianDataCategory.Real or Process is typically the first non-zero entry + // ConnectionIndex.Process is the first non-zero entry + object?[] createListArgs = createListMethod.GetParameters().Select(p => + { + if (p.ParameterType == dataCategoryEnum) + { + // Prefer first declared (likely Process or Real) + return Enum.GetValues(dataCategoryEnum).Cast().FirstOrDefault(); + } + if (p.ParameterType == connectionIndexEnum) + { + return Enum.Parse(connectionIndexEnum, "Process", ignoreCase: true); + } + return p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null; + }).ToArray(); + object listInstance = createListMethod.Invoke(access, createListArgs)!; + snapshots["DataValueListBeforeBegin"] = SnapshotObject(listInstance); + + System.Reflection.BindingFlags allInstance = + System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.NonPublic + | System.Reflection.BindingFlags.Instance; + + MethodInfo beginMethod = dataValueListType.GetMethods(allInstance) + .First(m => m.Name == "NonStreamedValuesBegin"); + object?[] beginArgs = beginMethod.GetParameters() + .Select(p => p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null) + .ToArray(); + object beginResult = beginMethod.Invoke(listInstance, beginArgs)!; + rows.Add(new + { + Kind = "NonStreamedValuesBegin", + Result = beginResult?.ToString(), + ParameterCount = beginArgs.Length, + }); + + // Build a single HistorianDataValue for the revision sample. + object revValue = Activator.CreateInstance(dataValueType)!; + SetProperty(revValue, "TagKey", tagKey); + SetProperty(revValue, "DataValueType", Enum.Parse(dataValueDataTypeEnum, "Float", ignoreCase: true)); + SetProperty(revValue, "OpcQuality", (ushort)192); + SetProperty(revValue, "Value", writeValue); + SetProperty(revValue, "StartDateTime", DateTime.UtcNow.AddSeconds(-30)); + SetProperty(revValue, "EndDateTime", DateTime.UtcNow.AddSeconds(-30)); + SetProperty(revValue, "ApplyScaling", false); + + MethodInfo[] addMethodCandidates = dataValueListType.GetMethods(allInstance) + .Where(m => m.Name == "AddNonStreamedValue") + .ToArray(); + rows.Add(new + { + Kind = "AddNonStreamedValueCandidates", + Count = addMethodCandidates.Length, + Sigs = addMethodCandidates.Select(m => string.Join(",", m.GetParameters().Select(p => p.ParameterType.Name))).ToArray(), + }); + MethodInfo addMethod = addMethodCandidates + .OrderBy(m => m.GetParameters().Length) + .First(); + // (HistorianDataValue value, bool validate, HistorianAccessError& error) + 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[addArgs.Length - 1] = addError0; + object addResult = addMethod.Invoke(listInstance, addArgs)!; + object addErrorAfter = addArgs[addArgs.Length - 1]!; + rows.Add(new + { + Kind = "AddNonStreamedValue", + Result = addResult?.ToString(), + ErrorDescription = GetPropertyText(addErrorAfter, "ErrorDescription"), + ErrorCode = GetPropertyText(addErrorAfter, "ErrorCode"), + ErrorType = GetPropertyText(addErrorAfter, "ErrorType"), + }); + + MethodInfo endListMethod = dataValueListType.GetMethods(allInstance) + .First(m => m.Name == "AddNonStreamedValuesEnd"); + object?[] endListArgs = endListMethod.GetParameters() + .Select(p => p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null) + .ToArray(); + object endListResult = endListMethod.Invoke(listInstance, endListArgs)!; + rows.Add(new { Kind = "AddNonStreamedValuesEnd", Result = endListResult?.ToString() }); + + snapshots["DataValueListBeforeSend"] = SnapshotObject(listInstance); + + // SendValues drives the on-the-wire revision flow: + // AddRevisionValuesBegin → AddRevisionValue × N → AddRevisionValuesEnd + // → SendNonStreamedValues (the actual WCF push). + object sendError = Activator.CreateInstance(errorType)!; + MethodInfo[] sendValuesCandidates = accessType.GetMethods(allInstance) + .Where(m => m.Name == "SendValues") + .ToArray(); + rows.Add(new + { + Kind = "SendValuesCandidates", + Count = sendValuesCandidates.Length, + Sigs = sendValuesCandidates.Select(m => string.Join(",", m.GetParameters().Select(p => p.ParameterType.Name))).ToArray(), + }); + MethodInfo sendValuesMethod = sendValuesCandidates + .OrderBy(m => m.GetParameters().Length) + .First(); + // (HistorianDataValueList list, HistorianAccessError& error) + object?[] sendArgs = new object?[sendValuesMethod.GetParameters().Length]; + sendArgs[0] = listInstance; + for (int i = 1; i < sendArgs.Length - 1; i++) + { + Type pt = sendValuesMethod.GetParameters()[i].ParameterType; + sendArgs[i] = pt.IsValueType ? Activator.CreateInstance(pt) : null; + } + sendArgs[sendArgs.Length - 1] = sendError; + bool sendSuccess = (bool)sendValuesMethod.Invoke(access, sendArgs)!; + sendError = sendArgs[sendArgs.Length - 1]!; + rows.Add(new + { + Kind = "SendValues", + Success = sendSuccess, + SignatureParamCount = sendArgs.Length, + ErrorDescription = GetPropertyText(sendError, "ErrorDescription"), + ErrorCode = GetPropertyText(sendError, "ErrorCode"), + ErrorType = GetPropertyText(sendError, "ErrorType"), + }); + snapshots["SendValuesError"] = SnapshotObject(sendError); + } + catch (Exception ex) + { + rows.Add(new { Kind = "RevisionFlowException", Type = ex.GetType().Name, Message = ex.Message, Inner = ex.InnerException?.Message }); + } + } + // DeleteTags runs independently of AddStreamedValue success (write-RE // sandbox cleanup); guarded by --write-delete-after to keep the default // run non-destructive.