576 lines
30 KiB
Markdown
576 lines
30 KiB
Markdown
# WriteSecured Completion Correlation Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Populate `MxCommandReply.statuses[0]` on unary `WriteSecured`/`WriteSecured2` replies with the correlated MXAccess `OnWriteComplete` outcome, bounded by a configurable wait (default 1.5 s), falling back to today's empty-statuses shape on timeout.
|
|
|
|
**Architecture:** Worker-side only (plus one gateway config option). A versioned per-`(serverHandle, itemHandle)` completion cache (mirror of `MxAccessValueCache`) is populated by the event sink's `OnWriteComplete` post-publish hook; the STA command executor captures a version baseline before the COM call, then pump-waits (ReadBulk precedent) until a newer completion lands or the deadline passes. Design: `docs/plans/2026-08-09-write-completion-correlation-design.md`.
|
|
|
|
**Tech Stack:** .NET Framework 4.8 x86 worker (no init-only props/positional records!), .NET 10 gateway, protobuf via Grpc.Tools regen. Worker builds/tests run ONLY on windev (10.100.0.48) — local macOS verification covers the gateway + contracts.
|
|
|
|
---
|
|
|
|
### Task 0: Create feature branch
|
|
|
|
**Classification:** trivial
|
|
**Estimated implement time:** ~1 min
|
|
**Parallelizable with:** none
|
|
|
|
```bash
|
|
cd /Users/dohertj2/Desktop/MxAccessGateway && git checkout -b feat/write-completion-correlation
|
|
```
|
|
|
|
### Task 1: Proto contract comments + regen
|
|
|
|
**Classification:** small
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** none (everything builds on the regenerated contracts)
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (~line 528 `statuses`, ~line 259 `WriteSecuredCommand`, ~line 269 `WriteSecured2Command`)
|
|
- Regenerate: `src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs`
|
|
|
|
**Step 1:** On `repeated MxStatusProxy statuses = 7;` in `MxCommandReply`, add above the field:
|
|
|
|
```proto
|
|
// Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
|
|
// replies the worker holds the reply for a bounded window (default 1.5 s,
|
|
// MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
|
|
// MXAccess OnWriteComplete callback and copies its status rows here, so
|
|
// statuses[0] carries the real MXAccess commit outcome (success OR failure)
|
|
// while protocol_status/hresult still describe command acceptance only.
|
|
// Empty statuses on a write reply means the completion did not arrive
|
|
// within the window — the write is unconfirmed, not failed. Correlation is
|
|
// best-effort per (server_handle, item_handle): MXAccess's callback carries
|
|
// no transaction id, so concurrent writes to the same item within the
|
|
// window can swap rows. The OnWriteComplete event still flows on the event
|
|
// stream unchanged. Other command kinds leave this field as before.
|
|
```
|
|
|
|
**Step 2:** On `message WriteSecuredCommand` and `message WriteSecured2Command`, append to the existing leading comment (or add one): `// The unary reply's statuses field carries the correlated OnWriteComplete outcome when it arrives within the worker's bounded wait — see MxCommandReply.statuses.`
|
|
|
|
**Step 3:** Regenerate + verify wire-identical build:
|
|
|
|
```bash
|
|
rm src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs
|
|
dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
|
|
```
|
|
Expected: build succeeds, `git diff --stat` shows only comment-churn in Generated.
|
|
|
|
**Step 4:** Commit: `git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto src/ZB.MOM.WW.MxGateway.Contracts/Generated && git commit -m "docs(proto): document the correlated write-completion statuses contract"`
|
|
(Comment-only proto change is wire-identical; other clients' generated code is intentionally not regenerated — no functional delta.)
|
|
|
|
### Task 2: MxAccessWriteCompletionCache + tests
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** Task 7
|
|
|
|
**Files:**
|
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs`
|
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs`
|
|
|
|
**Step 1:** Create the cache — mirror `MxAccessValueCache`'s shape, locking, and net48 constraints (no init-only, plain struct/class):
|
|
|
|
```csharp
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using Google.Protobuf.Collections;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
|
|
/// <summary>
|
|
/// Per-session cache of the most recent <c>OnWriteComplete</c> status rows
|
|
/// for each (server handle, item handle) pair. Written by the MXAccess
|
|
/// event sink as completion callbacks arrive; read by the write command
|
|
/// executor so a WriteSecured/WriteSecured2 reply can carry the correlated
|
|
/// MXAccess outcome instead of proving command acceptance only.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Same threading posture as <see cref="MxAccessValueCache"/>: writers and
|
|
/// readers run on the worker's STA thread (COM dispatches events on the
|
|
/// apartment thread; commands also execute on the STA), so no internal
|
|
/// locking is required. A single sync root keeps it nominally thread-safe
|
|
/// for tests that drive it from a non-STA thread.
|
|
/// </remarks>
|
|
public sealed class MxAccessWriteCompletionCache
|
|
{
|
|
private readonly Dictionary<long, CompletionEntry> entries = new();
|
|
private readonly object syncRoot = new();
|
|
|
|
/// <summary>Records the status rows of a fresh OnWriteComplete callback for the given handle pair.</summary>
|
|
/// <param name="serverHandle">MXAccess server handle.</param>
|
|
/// <param name="itemHandle">MXAccess item handle.</param>
|
|
/// <param name="statuses">Status rows from the mapped OnWriteComplete event; cloned before storing.</param>
|
|
public void Record(
|
|
int serverHandle,
|
|
int itemHandle,
|
|
RepeatedField<MxStatusProxy> statuses)
|
|
{
|
|
if (statuses is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(statuses));
|
|
}
|
|
|
|
lock (syncRoot)
|
|
{
|
|
long key = CreateItemKey(serverHandle, itemHandle);
|
|
ulong version = entries.TryGetValue(key, out CompletionEntry existing)
|
|
? existing.Version + 1
|
|
: 1UL;
|
|
entries[key] = new CompletionEntry(version, statuses.Clone());
|
|
}
|
|
}
|
|
|
|
/// <summary>Returns the current completion version for a handle pair, or 0 if none was recorded.</summary>
|
|
/// <param name="serverHandle">MXAccess server handle.</param>
|
|
/// <param name="itemHandle">MXAccess item handle.</param>
|
|
/// <returns>The current completion version, or 0 if no completion was recorded.</returns>
|
|
public ulong CurrentVersion(
|
|
int serverHandle,
|
|
int itemHandle)
|
|
{
|
|
lock (syncRoot)
|
|
{
|
|
return entries.TryGetValue(CreateItemKey(serverHandle, itemHandle), out CompletionEntry existing)
|
|
? existing.Version
|
|
: 0UL;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Polls for a completion newer than <paramref name="sinceVersion"/> until it
|
|
/// arrives or the deadline elapses, calling <paramref name="pumpStep"/> on every
|
|
/// poll iteration so the worker's STA can dispatch the inbound MXAccess
|
|
/// OnWriteComplete message. Same loop shape as
|
|
/// <see cref="MxAccessValueCache.TryWaitForUpdate"/>.
|
|
/// </summary>
|
|
/// <param name="serverHandle">MXAccess server handle.</param>
|
|
/// <param name="itemHandle">MXAccess item handle.</param>
|
|
/// <param name="sinceVersion">Version snapshot captured before the write COM call.</param>
|
|
/// <param name="deadlineUtc">Absolute UTC deadline.</param>
|
|
/// <param name="pumpStep">Action that pumps any pending Windows messages.</param>
|
|
/// <param name="statuses">The recorded status rows if a completion arrived before the deadline.</param>
|
|
/// <param name="pollIntervalMs">How long to sleep between pump cycles. Default 5 ms.</param>
|
|
/// <returns><see langword="true"/> if a completion newer than <paramref name="sinceVersion"/> arrived before the deadline; otherwise <see langword="false"/>.</returns>
|
|
public bool TryWaitForCompletion(
|
|
int serverHandle,
|
|
int itemHandle,
|
|
ulong sinceVersion,
|
|
DateTime deadlineUtc,
|
|
Action pumpStep,
|
|
out RepeatedField<MxStatusProxy> statuses,
|
|
int pollIntervalMs = 5)
|
|
{
|
|
if (pumpStep is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(pumpStep));
|
|
}
|
|
|
|
while (true)
|
|
{
|
|
pumpStep();
|
|
|
|
lock (syncRoot)
|
|
{
|
|
if (entries.TryGetValue(CreateItemKey(serverHandle, itemHandle), out CompletionEntry entry)
|
|
&& entry.Version > sinceVersion)
|
|
{
|
|
statuses = entry.Statuses;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (DateTime.UtcNow >= deadlineUtc)
|
|
{
|
|
statuses = new RepeatedField<MxStatusProxy>();
|
|
return false;
|
|
}
|
|
|
|
Thread.Sleep(pollIntervalMs);
|
|
}
|
|
}
|
|
|
|
private static long CreateItemKey(
|
|
int serverHandle,
|
|
int itemHandle)
|
|
{
|
|
return ((long)serverHandle << 32) | (uint)itemHandle;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Snapshot of the most recent OnWriteComplete status rows for a handle
|
|
/// pair. <see cref="Version"/> increments by one on every
|
|
/// <see cref="Record"/> call so the write executor can detect "a new
|
|
/// completion arrived since I captured my baseline".
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Plain readonly struct (not a record) so this compiles under the
|
|
/// worker's net48 target, which lacks <c>IsExternalInit</c>.
|
|
/// </remarks>
|
|
private readonly struct CompletionEntry
|
|
{
|
|
public CompletionEntry(
|
|
ulong version,
|
|
RepeatedField<MxStatusProxy> statuses)
|
|
{
|
|
Version = version;
|
|
Statuses = statuses;
|
|
}
|
|
|
|
public ulong Version { get; }
|
|
|
|
public RepeatedField<MxStatusProxy> Statuses { get; }
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2:** Tests (mirror `MxAccessValueCacheTests` style; build `MxStatusProxy` rows inline):
|
|
|
|
- `Record_IncrementsVersionPerKey` — two `Record` calls on the same pair → `CurrentVersion` 1 then 2; a different pair stays independent.
|
|
- `TryWaitForCompletion_WhenCompletionNewerThanBaseline_ReturnsStatuses` — record once, wait with `sinceVersion: 0`, deadline in the future → `true`, statuses round-trip (assert an `MxStatusProxy` field value survives the clone).
|
|
- `TryWaitForCompletion_WhenOnlyStaleCompletion_TimesOut` — record once, wait with `sinceVersion: CurrentVersion(...)` and a deadline ~50 ms out → `false`, out statuses empty.
|
|
- `TryWaitForCompletion_InvokesPumpStepEachIteration` — pumpStep increments a counter; on the counter's second call, `Record` the completion (this proves the pump loop is what lets the callback land); assert `true` and counter >= 2.
|
|
- `Record_ClonesStatuses` — mutate the caller's `RepeatedField` after `Record`; waited-out statuses unaffected.
|
|
|
|
**Step 3:** Cannot compile locally (worker is Windows-only) — defer build/test to Task 9 (windev). Commit: `git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs && git commit -m "feat(worker): versioned OnWriteComplete completion cache"`
|
|
|
|
### Task 3: Sink records completions (+ provider seam)
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** Task 7
|
|
|
|
**Files:**
|
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IWriteCompletionCacheProvider.cs`
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs`
|
|
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessBaseEventSinkTests.cs`
|
|
|
|
**Step 1:** New seam interface:
|
|
|
|
```csharp
|
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
|
|
/// <summary>
|
|
/// Exposes the per-session <see cref="MxAccessWriteCompletionCache"/> an
|
|
/// event sink populates from OnWriteComplete callbacks, so
|
|
/// <see cref="MxAccessSession.Create"/> can share one instance between the
|
|
/// sink (writer) and the write command executor (reader). Implemented by
|
|
/// <see cref="MxAccessBaseEventSink"/> and by test sinks that cannot
|
|
/// attach to a live MXAccess COM object.
|
|
/// </summary>
|
|
public interface IWriteCompletionCacheProvider
|
|
{
|
|
/// <summary>The completion cache bound to this sink.</summary>
|
|
MxAccessWriteCompletionCache WriteCompletionCache { get; }
|
|
}
|
|
```
|
|
|
|
**Step 2:** `MxAccessBaseEventSink` — declare `IWriteCompletionCacheProvider` on the class; add a `private readonly MxAccessWriteCompletionCache writeCompletionCache;` initialized in the widest ctor (add a new optional-most ctor overload following the existing chain pattern: the 3-arg `(eventQueue, eventMapper, valueCache)` ctor chains to a new 4-arg `(eventQueue, eventMapper, valueCache, writeCompletionCache)` with a fresh cache); expose `public MxAccessWriteCompletionCache WriteCompletionCache => writeCompletionCache;`. Change `OnWriteComplete` to use the post-publish hook (same pattern as `OnDataChange`'s value-cache publish — post-publish only runs after the event cleared the queue, and a queue overflow faults the session anyway):
|
|
|
|
```csharp
|
|
MXSTATUS_PROXY[] statuses = pVars;
|
|
EnqueueEvent(
|
|
() => eventMapper.CreateOnWriteComplete(
|
|
sessionId,
|
|
hLMXServerHandle,
|
|
phItemHandle,
|
|
statuses),
|
|
mxEvent => writeCompletionCache.Record(hLMXServerHandle, phItemHandle, mxEvent.Statuses));
|
|
```
|
|
|
|
**Step 3:** Tests in `MxAccessBaseEventSinkTests` (mirror `OnDataChange_ComCallback_PopulatesValueCache` and `ValueCache_ReturnsTheInstanceBoundAtConstruction`):
|
|
- `OnWriteComplete_ComCallback_RecordsCompletionAndStillEnqueuesEvent` — drive `sink.OnWriteComplete(7, 21, ref proxies)`; assert the queue got the OnWriteComplete event AND `cache.CurrentVersion(7, 21) == 1`.
|
|
- `WriteCompletionCache_ReturnsTheInstanceBoundAtConstruction`.
|
|
|
|
**Step 4:** Commit: `git commit -m "feat(worker): event sink records OnWriteComplete rows into the completion cache"` (explicit paths).
|
|
|
|
### Task 4: MxAccessSession plumbing
|
|
|
|
**Classification:** small
|
|
**Estimated implement time:** ~3 min
|
|
**Parallelizable with:** Task 7
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs` (private ctor ~line 18, `Create` ~line 145)
|
|
|
|
**Step 1:** Add `private readonly MxAccessWriteCompletionCache writeCompletionCache;` + ctor param (after `valueCache`) + null guard; add property mirroring `ValueCache`:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Per-session OnWriteComplete completion cache populated by the event
|
|
/// sink. The write command executor consults it after a
|
|
/// WriteSecured/WriteSecured2 COM call so the unary reply can carry
|
|
/// the correlated completion outcome.
|
|
/// </summary>
|
|
public MxAccessWriteCompletionCache WriteCompletionCache => writeCompletionCache;
|
|
```
|
|
|
|
**Step 2:** In `Create`, next to the value-cache sharing block:
|
|
|
|
```csharp
|
|
// Share the sink's completion cache the same way (production sink
|
|
// and completion-aware test sinks implement the provider seam);
|
|
// fall back to a fresh cache for other fakes — the write executor
|
|
// then simply never observes a completion and replies unconfirmed.
|
|
MxAccessWriteCompletionCache writeCompletionCache = eventSink is IWriteCompletionCacheProvider provider
|
|
? provider.WriteCompletionCache
|
|
: new MxAccessWriteCompletionCache();
|
|
```
|
|
|
|
Pass it to the ctor.
|
|
|
|
**Step 3:** Commit: `git commit -m "feat(worker): share the completion cache between sink and session"`.
|
|
|
|
### Task 5: Executor bounded wait + StaSession env plumbing
|
|
|
|
**Classification:** high-risk (STA/pump semantics)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** none (touches the same files as 6's tests exercise)
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs` (~lines 14-85 ctors, 447-497 write methods)
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs` (~line 205 executor construction)
|
|
|
|
**Step 1:** Executor — next to `DefaultReadBulkTimeout`:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Default bounded wait for the OnWriteComplete callback after a
|
|
/// WriteSecured/WriteSecured2 COM call. 1.5 s keeps the unary reply
|
|
/// inside the OtOpcUa driver's 2 s Tier A write-resilience budget (a
|
|
/// longer gateway wait must raise that consumer timeout in step) while
|
|
/// covering the common fast-commit case; on expiry the reply returns
|
|
/// with empty statuses — unconfirmed, not failed.
|
|
/// </summary>
|
|
internal static readonly TimeSpan DefaultWriteCompletionTimeout = TimeSpan.FromMilliseconds(1500);
|
|
|
|
private readonly TimeSpan writeCompletionTimeout;
|
|
```
|
|
|
|
Add `TimeSpan? writeCompletionTimeout = null` as a trailing optional parameter on the widest (4-arg) ctor; `this.writeCompletionTimeout = writeCompletionTimeout ?? DefaultWriteCompletionTimeout;`.
|
|
|
|
**Step 2:** In `ExecuteWriteSecured`, replace the tail (`session.WriteSecured(...); return CreateOkReply(command);`) with:
|
|
|
|
```csharp
|
|
MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache;
|
|
// Baseline BEFORE the COM call: a completion that dispatches during or
|
|
// immediately after WriteSecured bumps the version past this snapshot,
|
|
// so a fast commit still correlates (no missed-callback window).
|
|
ulong completionBaseline = completionCache.CurrentVersion(
|
|
writeSecuredCommand.ServerHandle,
|
|
writeSecuredCommand.ItemHandle);
|
|
|
|
session.WriteSecured(
|
|
writeSecuredCommand.ServerHandle,
|
|
writeSecuredCommand.ItemHandle,
|
|
writeSecuredCommand.CurrentUserId,
|
|
writeSecuredCommand.VerifierUserId,
|
|
variantConverter.ConvertToComValue(writeSecuredCommand.Value));
|
|
|
|
MxCommandReply reply = CreateOkReply(command);
|
|
AwaitWriteCompletion(
|
|
reply,
|
|
completionCache,
|
|
writeSecuredCommand.ServerHandle,
|
|
writeSecuredCommand.ItemHandle,
|
|
completionBaseline);
|
|
return reply;
|
|
```
|
|
|
|
Mirror in `ExecuteWriteSecured2`. Shared private helper:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Bounded pump-wait for the OnWriteComplete row matching a
|
|
/// WriteSecured/WriteSecured2 call, copied onto the reply when it
|
|
/// arrives in time. The executor holds the STA thread but pumps
|
|
/// Windows messages each poll (ReadBulk precedent) so the COM callback
|
|
/// can dispatch re-entrantly; on expiry the reply keeps its empty
|
|
/// statuses — the consumer's unconfirmed path, never a synthesized
|
|
/// failure. Protocol status/hresult stay acceptance-only either way.
|
|
/// </summary>
|
|
private void AwaitWriteCompletion(
|
|
MxCommandReply reply,
|
|
MxAccessWriteCompletionCache completionCache,
|
|
int serverHandle,
|
|
int itemHandle,
|
|
ulong completionBaseline)
|
|
{
|
|
if (writeCompletionTimeout <= TimeSpan.Zero)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (completionCache.TryWaitForCompletion(
|
|
serverHandle,
|
|
itemHandle,
|
|
completionBaseline,
|
|
DateTime.UtcNow + writeCompletionTimeout,
|
|
pumpStep,
|
|
out Google.Protobuf.Collections.RepeatedField<MxStatusProxy> statuses))
|
|
{
|
|
reply.Statuses.Add(statuses);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3:** `MxAccessStaSession` — add:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Environment variable the gateway's WorkerProcessLauncher sets from
|
|
/// MxGateway:Worker:WriteCompletionWaitMilliseconds. 0 disables the
|
|
/// write-completion wait (pure fire-and-forget replies).
|
|
/// </summary>
|
|
internal const string WriteCompletionWaitEnvironmentVariableName =
|
|
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
|
|
|
|
/// <summary>
|
|
/// Bounded WriteSecured/WriteSecured2 completion wait handed to the
|
|
/// command executor at StartAsync. Internal-settable as a test seam so
|
|
/// Worker.Tests can shorten it without env-var plumbing.
|
|
/// </summary>
|
|
internal TimeSpan WriteCompletionTimeout { get; set; } = ResolveWriteCompletionTimeout();
|
|
|
|
internal static TimeSpan ResolveWriteCompletionTimeout()
|
|
{
|
|
string value = Environment.GetEnvironmentVariable(WriteCompletionWaitEnvironmentVariableName);
|
|
return int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out int milliseconds)
|
|
&& milliseconds >= 0
|
|
? TimeSpan.FromMilliseconds(milliseconds)
|
|
: MxAccessCommandExecutor.DefaultWriteCompletionTimeout;
|
|
}
|
|
```
|
|
|
|
and pass `writeCompletionTimeout: WriteCompletionTimeout` when constructing `MxAccessCommandExecutor` in `StartAsync`. (net48: `Environment.GetEnvironmentVariable` returns `string` — keep nullable annotations consistent with the file.)
|
|
|
|
**Step 4:** Commit: `git commit -m "feat(worker): bounded pump-wait correlates OnWriteComplete onto secured-write replies"`.
|
|
|
|
### Task 6: Executor tests
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** none (depends on Tasks 2-5)
|
|
|
|
**Files:**
|
|
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs`
|
|
|
|
**Step 1:** Test support inside the test class file:
|
|
- New sink: `private sealed class CompletionCacheEventSink : IMxAccessEventSink, IWriteCompletionCacheProvider { public MxAccessWriteCompletionCache WriteCompletionCache { get; } = new MxAccessWriteCompletionCache(); public void Attach(object mxAccessComObject, string sessionId) { } public void Detach() { } }` (match the exact `IMxAccessEventSink` member list — read the interface first).
|
|
- `FakeMxAccessComObject`: add `public Action OnWriteSecuredCallback { get; set; }` (nullable per file convention) invoked at the end of `WriteSecured` and `WriteSecured2`.
|
|
|
|
**Step 2:** Tests (all through `MxAccessStaSession.DispatchAsync`, constructing the session with the completion sink; set `session.WriteCompletionTimeout` before `StartAsync`):
|
|
|
|
- `DispatchAsync_WriteSecured_WhenCompletionArrivesDuringComCall_ReturnsStatuses` (fast-completion edge): wire `OnWriteSecuredCallback = () => sink.WriteCompletionCache.Record(82, 821, StatusRows(1))` (helper building a `RepeatedField<MxStatusProxy>` with a recognizable value); dispatch; assert `ProtocolStatus.Code == Ok`, `reply.Statuses.Count == 1`, row round-trips.
|
|
- `DispatchAsync_WriteSecured_WhenCompletionArrivesWhileWaiting_ReturnsStatuses`: no fake callback; `WriteCompletionTimeout = TimeSpan.FromSeconds(10)`; start `Task<MxCommandReply> pending = session.DispatchAsync(...)`, then `sink.WriteCompletionCache.Record(...)` from the test thread after a short `Task.Delay(50)`; await; assert statuses present. (No `.ConfigureAwait(false)` in `[Fact]` bodies — xUnit1030 fails the Windows build.)
|
|
- `DispatchAsync_WriteSecured_WhenNoCompletion_TimesOutWithEmptyStatusesAndOkProtocol`: `WriteCompletionTimeout = TimeSpan.FromMilliseconds(100)`; assert Ok + `reply.Statuses.Count == 0`.
|
|
- `DispatchAsync_WriteSecured2_WhenCompletionArrivesDuringComCall_ReturnsStatuses` (mirror of the fast test).
|
|
- `DispatchAsync_Write_DoesNotWaitForCompletion`: `WriteCompletionTimeout = TimeSpan.FromSeconds(30)`, plain `Write` command, no completion recorded; assert `await` completes within a 5 s guard (`Task.WhenAny` with `Task.Delay`) — proving plain writes never enter the wait.
|
|
- Baseline test `DispatchAsync_WriteSecured_IgnoresStaleCompletionFromBeforeTheCall`: `Record` once BEFORE dispatch, `WriteCompletionTimeout = 100 ms`, no new completion → empty statuses (stale row not misattributed).
|
|
|
|
**Step 2b:** Confirm the two existing WriteSecured tests (`DispatchAsync_WriteSecured_ForwardsUserIds`, `..._WriteSecured2_...`) still pass unmodified — they use `NoopEventSink`, so the session falls back to a fresh cache, no completion ever arrives, and the default 1.5 s wait adds latency only; if that latency bothers the suite, switch them to the completion sink with `WriteCompletionTimeout = TimeSpan.Zero`.
|
|
|
|
**Step 3:** Commit: `git commit -m "test(worker): write-completion correlation executor coverage"`.
|
|
|
|
### Task 7: Gateway config option + launcher env var
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** Task 2, Task 3, Task 4
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/WorkerOptions.cs`
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs` (~line 224 block)
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs` (~lines 18-22 consts, ~line 175 env block)
|
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs`, the `GatewayOptionsValidator` test file (find via `grep -rl GatewayOptionsValidatorTests src/ZB.MOM.WW.MxGateway.Tests`)
|
|
|
|
**Step 1:** `WorkerOptions`:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Bounded wait, in milliseconds, the worker holds a WriteSecured/WriteSecured2
|
|
/// reply for the matching MXAccess OnWriteComplete callback so the reply's
|
|
/// statuses carry the real commit outcome. 0 disables the wait. Deployments
|
|
/// raising this above consumer write-timeout budgets (e.g. OtOpcUa's 2 s Tier A
|
|
/// write resilience timeout) must raise those in step.
|
|
/// </summary>
|
|
public int WriteCompletionWaitMilliseconds { get; init; } = 1500;
|
|
```
|
|
|
|
**Step 2:** Validator (>= 0, not the positive helper):
|
|
|
|
```csharp
|
|
if (options.WriteCompletionWaitMilliseconds < 0)
|
|
{
|
|
builder.Add("MxGateway:Worker:WriteCompletionWaitMilliseconds must be greater than or equal to zero.");
|
|
}
|
|
```
|
|
|
|
**Step 3:** Launcher — const `public const string WorkerWriteCompletionWaitEnvironmentVariableName = "MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";` and in `CreateStartInfo` next to the pipe-connect env line:
|
|
|
|
```csharp
|
|
startInfo.Environment[WorkerWriteCompletionWaitEnvironmentVariableName] =
|
|
_workerOptions.WriteCompletionWaitMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
```
|
|
|
|
**Step 4:** Tests — mirror the existing pipe-connect-timeout launcher env assertion and an existing validator negative test; add: launcher exports `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS=1500` by default / custom value when configured; validator rejects `-1`, accepts `0`.
|
|
|
|
**Step 5:** Run locally:
|
|
|
|
```bash
|
|
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~WorkerProcessLauncherTests|FullyQualifiedName~GatewayOptionsValidator"
|
|
```
|
|
Expected: PASS.
|
|
|
|
**Step 6:** Commit: `git commit -m "feat(gateway): configurable worker write-completion wait (MxGateway:Worker:WriteCompletionWaitMilliseconds)"`.
|
|
|
|
### Task 8: Docs
|
|
|
|
**Classification:** small
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** none (write after code settles)
|
|
|
|
**Files:**
|
|
- Modify: `docs/GatewayConfiguration.md` (Worker options table, ~line 113 area)
|
|
- Modify: `gateway.md` (command surface / write semantics section)
|
|
- Modify: `docs/DesignDecisions.md` (new decision entry)
|
|
|
|
Content: the option row (default 1500, 0 disables, consumer-budget pairing rule with OtOpcUa's Write resilience timeout); gateway.md note that WriteSecured/WriteSecured2 unary replies now carry correlated completion statuses (bounded wait, empty = unconfirmed, event stream unchanged); DesignDecisions entry summarizing the design doc (link it) including the best-effort per-(hItem) correlation caveat and why plain Write/Write2/bulk stay fire-and-forget. Follow `docs/style-guides/StyleGuide.md` (present tense, why not what).
|
|
|
|
Commit: `git commit -m "docs: write-completion correlation configuration and semantics"`.
|
|
|
|
### Task 9: Local + windev verification
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~10 min (mostly remote build time)
|
|
**Parallelizable with:** none
|
|
|
|
**Step 1 (local):** `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx` → succeeds; re-run the Task 7 filtered gateway tests.
|
|
|
|
**Step 2 (push):** `git push -u origin feat/write-completion-correlation`.
|
|
|
|
**Step 3 (windev):** Use the isolated `C:\build` worktree (NOT the Desktop checkout — dirty feature branch). Remote PS via base64 `-EncodedCommand`; no `$ErrorActionPreference='Stop'` around git. Sequence:
|
|
|
|
```
|
|
git fetch origin && git checkout feat/write-completion-correlation && git pull
|
|
dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86
|
|
dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~MxAccessWriteCompletionCacheTests|FullyQualifiedName~MxAccessBaseEventSinkTests|FullyQualifiedName~MxAccessCommandExecutorTests"
|
|
```
|
|
Expected: build clean (TreatWarningsAsErrors — watch xUnit1030), all filtered tests PASS. Fix-and-push iterations happen from the Mac; windev only builds/tests.
|
|
|
|
**Step 4:** Full worker test suite once green on the filter (`dotnet test ... -p:Platform=x86`, no filter) — one full pass before merge.
|
|
|
|
### Task 10: Review, merge, notify
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** none
|
|
|
|
- Run a code review over the full branch diff (code-reviewer agent or /code-review) and address findings.
|
|
- Merge: `git checkout main && git merge --no-ff feat/write-completion-correlation && git push origin main`.
|
|
- Notify the OtOpcUa session (SendMessage) that the feature is on `main` and built/tested on windev; flag that the live ArchestrA leg needs the user's Windows deployment (wonder-app-vd03 / 10.100.0.48 redeploy is a separate user-approved step).
|
|
- Surface to the user: deployed services (`MxAccessGw` on 10.100.0.48, wonder-app-vd03) do NOT pick this up until redeployed; OtOpcUa's end-to-end verification against a live gateway needs that redeploy.
|