D2 (revision-write): empirically blocked by same gate as AddS2
Drove the revision-write flow via reflection in the native trace harness (--write-revision-values) to see whether it bypasses the AddS2 architectural blocker. It doesn't. Findings: - HistorianAccess.CreateHistorianDataValueList(NonStreamedOriginal) succeeds - HistorianDataValueList.NonStreamedValuesBegin() succeeds (batchID 0->1) - HistorianDataValueList.AddNonStreamedValue(value, validate=true, out err) FAILS with ErrorCode=TagNotFoundInCache (129) — same client-side validation gate that blocks AddS2 - AddNonStreamedValuesEnd() returns void; SendValues() returns true with Success because the list is empty (no value was ever added) - No AddNonStreamValues* WCF calls reach the wire Conclusion: the revision-write path requires the tag to be in the library's runtime tag cache, which is only populated by configured IO server / Application Server pipelines, not by HistorianAccess.AddTag. This matches the architectural blocker documented for AddS2 and means no public WriteRevisionsAsync / BeginRevisionAsync should be added to the SDK — the path is unreachable for client-created sandbox tags. The Wcf/Contracts/ITransactionServiceContract methods (AddNonStream- ValuesBegin/AddNonStreamValues/AddNonStreamValuesEnd) remain declared for completeness; no orchestrator or public surface is added. The harness extension is preserved as a deterministic reproducer for the blocker: re-run --write-revision-values to verify the gate any time. docs/plans/revision-write-path.md updated with the empirical finding plus the original plan retained as historical context. 177/177 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,94 @@
|
|||||||
# Plan: Revision-Write Path (`AddRevisionValuesBegin/Value/End`)
|
# Plan: Revision-Write Path (`AddRevisionValuesBegin/Value/End`)
|
||||||
|
|
||||||
Status: **NOT STARTED.** Sub-plan extracted from `speculative-items-sweep.md`
|
Status: **ARCHITECTURALLY BLOCKED — verified 2026-05-05.** Same root
|
||||||
item D2 because the work is too large for a one-push sweep.
|
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
|
## Context
|
||||||
|
|
||||||
|
|||||||
@@ -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<object>().Select(v => $"{v}={Convert.ToInt32(v)}").ToArray(),
|
||||||
|
ConnectionIndex = Enum.GetValues(connectionIndexEnum).Cast<object>().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<object>().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
|
// DeleteTags runs independently of AddStreamedValue success (write-RE
|
||||||
// sandbox cleanup); guarded by --write-delete-after to keep the default
|
// sandbox cleanup); guarded by --write-delete-after to keep the default
|
||||||
// run non-destructive.
|
// run non-destructive.
|
||||||
|
|||||||
Reference in New Issue
Block a user