Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91d8715c74 | |||
| 794c44246a | |||
| b0e65d4f31 | |||
| b948e6975e | |||
| 45c530da6e | |||
| c867aca36b | |||
| 436ef69f07 | |||
| 2b468bd8fc | |||
| 431a096cab | |||
| b0e2b8ba74 | |||
| 66fe063410 | |||
| 8de23086d0 | |||
| a76ecdd59c | |||
| fc23a65cca | |||
| aec95b78c9 | |||
| f9229ee44d | |||
| 5dbe93d13e | |||
| 129e47e541 | |||
| 1d6858939d | |||
| de67b45d04 | |||
| 3d991d2160 | |||
| 05667169eb | |||
| 9760497d66 | |||
| 5b153dac74 | |||
| 41e86481e2 |
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@ Repo rules that bind every entry: docs change in the same commit as the source (
|
|||||||
| SEC-33 | Low | P1 | M | — (co-locate SEC-23) | Done | Any-platform path-rooting acceptance re-opens SEC-01 on Unix; Galaxy `SnapshotCachePath` unvalidated |
|
| SEC-33 | Low | P1 | M | — (co-locate SEC-23) | Done | Any-platform path-rooting acceptance re-opens SEC-01 on Unix; Galaxy `SnapshotCachePath` unvalidated |
|
||||||
| SEC-34 | Low | P2 | S | — | Done | Verification cache: expiry outlives TTL; `Invalidate` races in-flight repopulation |
|
| SEC-34 | Low | P2 | S | — | Done | Verification cache: expiry outlives TTL; `Invalidate` races in-flight repopulation |
|
||||||
| SEC-35 | Info | — | S | — | N/A (doc-only note discharged 2026-08-07) | Production hard-stops key on the exact `Production` environment name |
|
| SEC-35 | Info | — | S | — | N/A (doc-only note discharged 2026-08-07) | Production hard-stops key on the exact `Production` environment name |
|
||||||
| SEC-36 | Low | P1 | M | cross-repo (`scadaproj/infra/glauth`) | Done (repo-side; live rotation operator-pending per runbook) | Committed dev LDAP service-account password: remove from repo and rotate |
|
| SEC-36 | Low | P1 | M | cross-repo (`scadaproj/infra/glauth`) | Done | Committed dev LDAP service-account password: remove from repo and rotate |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -220,3 +220,7 @@ dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --fil
|
|||||||
(asserts the blank-password validation still fires with the updated message). Manual: with user-secrets set on the dev box, `dotnet run --project src/ZB.MOM.WW.MxGateway.Server/...` and a dashboard `/login` as `multi-role` succeeds against the rotated GLAuth; the deployed-host login re-check from step 1 counts as the production verification. Live-LDAP integration tests (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) only where the GLAuth instance is reachable; otherwise document skipped per the testing matrix.
|
(asserts the blank-password validation still fires with the updated message). Manual: with user-secrets set on the dev box, `dotnet run --project src/ZB.MOM.WW.MxGateway.Server/...` and a dashboard `/login` as `multi-role` succeeds against the rotated GLAuth; the deployed-host login re-check from step 1 counts as the production verification. Live-LDAP integration tests (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) only where the GLAuth instance is reachable; otherwise document skipped per the testing matrix.
|
||||||
|
|
||||||
**Outcome (2026-08-07 — Done, repo-side; live rotation operator-pending).** Landed on `fix/sec-36-ldap-secret`. **The design's baseline had already shifted:** at HEAD `appsettings.json` no longer commits the literal — it ships `"ServiceAccountPassword": "${secret:ldap/mxgateway/bind}"`, a fail-closed encrypted-store reference (documented `GatewayConfiguration.md:252`, tested by `PreHostSecretExpansionTests`) introduced by the Secrets-store adoption after this remediation was written. **Deviation from Implementation step 2:** the `${secret:}` reference was **kept, not deleted** — deleting it regresses the shipped/documented/tested store channel and the committed-plaintext finding is already resolved for `appsettings.json`. The load-bearing residual — the literal value still present in `glauth.md`'s samples (`:33,65,103,136,245`), `docs/GatewayTesting.md`, and the historical `archreview/*` SEC-06 evidence — was scrubbed to `<service-account-password>` placeholders, each with a pointer to the source of truth `scadaproj/infra/glauth/` and a rotation-required note. Steps 3–6 implemented as designed: `<UserSecretsId>mxaccessgw-server</UserSecretsId>` added (step 3); the `ValidateLdap` blank-password message now names both channels — dev `dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>` and deployed `MxGateway__Ldap__ServiceAccountPassword` — plus a note on the `${secret:}` store default (step 4), asserted by the extended `Validate_Fails_WhenLdapEnabledAndServiceAccountPasswordBlank`; docs updated same commit (step 5); `git grep -i` for the old value is empty across tracked files (step 6). The cross-repo **step 1 (rotate GLAuth on `10.100.0.35`, pre-stage the NSSM env var on `10.100.0.48` and on `wonder-app-vd03` only if `Ldap.Enabled`, verify dashboard login)** is the operator's to execute, captured in the new runbook `docs/runbooks/SEC-36-ldap-credential-rotation.md`. Verification (macOS): `dotnet build …Server` 0 warnings/0 errors; `dotnet test --filter ~GatewayOptionsValidator` green.
|
**Outcome (2026-08-07 — Done, repo-side; live rotation operator-pending).** Landed on `fix/sec-36-ldap-secret`. **The design's baseline had already shifted:** at HEAD `appsettings.json` no longer commits the literal — it ships `"ServiceAccountPassword": "${secret:ldap/mxgateway/bind}"`, a fail-closed encrypted-store reference (documented `GatewayConfiguration.md:252`, tested by `PreHostSecretExpansionTests`) introduced by the Secrets-store adoption after this remediation was written. **Deviation from Implementation step 2:** the `${secret:}` reference was **kept, not deleted** — deleting it regresses the shipped/documented/tested store channel and the committed-plaintext finding is already resolved for `appsettings.json`. The load-bearing residual — the literal value still present in `glauth.md`'s samples (`:33,65,103,136,245`), `docs/GatewayTesting.md`, and the historical `archreview/*` SEC-06 evidence — was scrubbed to `<service-account-password>` placeholders, each with a pointer to the source of truth `scadaproj/infra/glauth/` and a rotation-required note. Steps 3–6 implemented as designed: `<UserSecretsId>mxaccessgw-server</UserSecretsId>` added (step 3); the `ValidateLdap` blank-password message now names both channels — dev `dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>` and deployed `MxGateway__Ldap__ServiceAccountPassword` — plus a note on the `${secret:}` store default (step 4), asserted by the extended `Validate_Fails_WhenLdapEnabledAndServiceAccountPasswordBlank`; docs updated same commit (step 5); `git grep -i` for the old value is empty across tracked files (step 6). The cross-repo **step 1 (rotate GLAuth on `10.100.0.35`, pre-stage the NSSM env var on `10.100.0.48` and on `wonder-app-vd03` only if `Ldap.Enabled`, verify dashboard login)** is the operator's to execute, captured in the new runbook `docs/runbooks/SEC-36-ldap-credential-rotation.md`. Verification (macOS): `dotnet build …Server` 0 warnings/0 errors; `dotnet test --filter ~GatewayOptionsValidator` green.
|
||||||
|
|
||||||
|
**Outcome (2026-08-07 — operator half executed; finding now fully `Done`).** The cross-repo step 1 left open above was executed per `docs/runbooks/SEC-36-ldap-credential-rotation.md`. A new service-account password was generated, the `serviceaccount` `passsha256` in `scadaproj/infra/glauth/config.toml` replaced, and the shared GLAuth recreated on `10.100.0.35` from its actual compose directory — the **load-bearing** half of the finding is now discharged: the value disclosed by this repo's git history (live in the directory since 2026-06-04; not reproduced here) no longer binds `dc=zb,dc=local`. The new value exists only in the three channels the design named — the GLAuth `passsha256` (committed in `scadaproj`, commit `aada53b`), the NSSM service environment on `10.100.0.48`, and each dev box's user-secrets — and in no file of this repo. The retired plaintext was additionally scrubbed from the `scadaproj` glauth comments (`config.toml`, `docker-compose.yml`, `README.md`) and from the docker host's live `docker-compose.yml`; the host's `*.bak-sec36` rollback copies deliberately retain it. **Three runbook facts were wrong and are corrected in a dated block at its top.** (1) Its step 3 said `cd ~/Desktop/scadaproj/infra/glauth` on the docker host; no such path exists there — the stack runs from `/home/dohertj2/zb-glauth` (container `zb-shared-glauth`), fed by the `scp` deploy documented in `scadaproj/infra/glauth/README.md`. (2) `wonder-app-vd03` is **out of scope on documentary evidence**, not merely unchecked: its gateway binds the ScadaBridge/ScadaLink local GLAuth under `dc=scadalink`/`dc=scadabridge`, a different directory that never held this credential (the host is also unreachable from the dev network); no env var was staged there. (3) Its "3-fail / 10-minute per-IP lockout" caution is **inert for this instance** — `config.toml:14` sets `LimitFailedBinds = false`. **One Done criterion is met with a caveat:** the new value **is** staged on windev (`10.100.0.48`, 10th `AppEnvironmentExtra` entry on the `MxAccessGw` NSSM service), but the runbook's primary check — dashboard `/login` as `multi-role` — **could not run**, because windev's gateway is crash-looping on an unrelated pre-existing fault: the deployed Server binary (2026-06-25) predates the 2026-07-15 auth-DB migration, so it opens a schema-version-3 database it supports only at version 2 and aborts at startup (~10k Hosting-failed events/day since at least 08-06). That is a stale-deployment problem, filed as a next-cycle candidate finding, not a rotation defect. **Verified instead by the equivalent primitive:** a direct `ldapsearch` bind as `cn=serviceaccount,dc=zb,dc=local` with the new value against `10.100.0.35:3893` succeeded and returned the `multi-role` entry — the same search bind the dashboard performs. Also surfaced and filed for next cycle: `DashboardLdapLiveTests` fixture drift leaves the suite with **no positive-proof coverage** of the service-account bind, so it could not have substituted for the dashboard check either. Tracking: both registers' SEC-36 rows, the pending-operator-actions list in `90-candidate-findings-next-cycle.md`, and the `00-tracking.md` progress log.
|
||||||
|
|
||||||
|
**Addendum (2026-08-07, later the same day — the caveat is closed).** windev was repaired under NEXT-07 (fresh publish of `origin/main` `a346d51`), and the deferred dashboard check then ran on that host: with `Dashboard:DisableLogin=false` supplied as a process-env-only override on a foreground run, `GET /login` returned 200 with an antiforgery token, `POST /auth/login` as `multi-role`/`password` returned 302 to `/` with a `MxGatewayDashboard` cookie, the authenticated `GET /` rendered the admin nav, and an anonymous control redirected to `/login?ReturnUrl=%2F`. The rotated credential is therefore proven through the real `DashboardAuthenticator` search-bind path on the deployed host, not only by the `ldapsearch` primitive. As deployed windev keeps `DisableLogin=true`, so routine operation there does not exercise LDAP; the standing regression proof is the realigned `DashboardLdapLiveTests` (NEXT-06, commit `de67b45`), 5/5 green against the shared GLAuth. `docs/runbooks/SEC-36-ldap-credential-rotation.md` Correction 3 carries the same record.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Prior-cycle open findings (TST-05..24 where still open) are tracked in the prior
|
|||||||
| TST-27 | Medium | P1 (doc batch) | S | — | Done | `ShowTagValues` config row still says "Reserved" after SEC-25 made the flag live |
|
| TST-27 | Medium | P1 (doc batch) | S | — | Done | `ShowTagValues` config row still says "Reserved" after SEC-25 made the flag live |
|
||||||
| TST-28 | Low | P2 | S | relates IPC-02 | Done | Gateway-side `max_frame_bytes` handshake field untested in the CI-run suite |
|
| TST-28 | Low | P2 | S | relates IPC-02 | Done | Gateway-side `max_frame_bytes` handshake field untested in the CI-run suite |
|
||||||
| TST-29 | Low | P2 | S | — | Done | Retire `oldtasks.md` after folding the Phase-5 governance record into DesignDecisions.md; delete root docs-review artifacts |
|
| TST-29 | Low | P2 | S | — | Done | Retire `oldtasks.md` after folding the Phase-5 governance record into DesignDecisions.md; delete root docs-review artifacts |
|
||||||
| TST-30 | Low | P2 | M | — | Done (doc half; runner registration operator-pending per runbook) | Single shared Gitea runner is a CI throughput/availability bottleneck (cross-repo contention, no run cancel/delete) |
|
| TST-30 | Low | P2 | M | — | Done | Single shared Gitea runner is a CI throughput/availability bottleneck (cross-repo contention, no run cancel/delete) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -169,6 +169,8 @@ Independent of the runner count, document the **no-cancel** reality (Gitea 1.26
|
|||||||
|
|
||||||
**Outcome (2026-08-07 — Done, doc half; runner registration operator-pending).** Landed on `fix/tst-30-runner-docs`. Implementation step 2 shipped: `docs/GatewayTesting.md`'s Continuous Integration section gained a "Runner capacity is shared and finite" subsection stating the `maxParallel=1` co-located runner is shared with `dohertj2/lmxopcua` at the instance level (not repo-scoped), the ~20–30 minute queue latency observed under cross-repo contention, and the Gitea 1.26 no-cancel/no-delete API reality; the existing "windev tier down" degraded-mode paragraph now also covers "runner contended" as a reason to use the bypass, generalized per this finding's design note. New operator runbook `docs/runbooks/TST-30-second-ci-runner.md` carries **step 1** (register a second `act_runner` on `10.100.0.35`, option (a) recommended, same `container.network: traefik` config; option (b) dedicated labelled runner as an escalation; option (c) windev-hosted runner rejected) with the verification checklist (concurrent back-to-back pushes, `GET /repos/dohertj2/mxaccessgw/actions/runners` ≥ 2) and a note that the no-cancel reality persists regardless of runner count. **Step 3 (optional workflow-level `concurrency` group)** is documented in the runbook as unverified — explicitly framed as "verify this Gitea deployment honors it before relying on it" — and left unimplemented in `ci.yml`, since it is a `ci.yml` change out of scope for this doc-only pass. **The actual runner registration (step 1) is infrastructure work outside this repo's tree and remains the operator's to execute**, tracked in the runbook. Verification performed: `grep -n 'maxParallel\|shared\|cancel' docs/GatewayTesting.md` shows the new prose; runbook file exists at the path above; no build required (doc-only change).
|
**Outcome (2026-08-07 — Done, doc half; runner registration operator-pending).** Landed on `fix/tst-30-runner-docs`. Implementation step 2 shipped: `docs/GatewayTesting.md`'s Continuous Integration section gained a "Runner capacity is shared and finite" subsection stating the `maxParallel=1` co-located runner is shared with `dohertj2/lmxopcua` at the instance level (not repo-scoped), the ~20–30 minute queue latency observed under cross-repo contention, and the Gitea 1.26 no-cancel/no-delete API reality; the existing "windev tier down" degraded-mode paragraph now also covers "runner contended" as a reason to use the bypass, generalized per this finding's design note. New operator runbook `docs/runbooks/TST-30-second-ci-runner.md` carries **step 1** (register a second `act_runner` on `10.100.0.35`, option (a) recommended, same `container.network: traefik` config; option (b) dedicated labelled runner as an escalation; option (c) windev-hosted runner rejected) with the verification checklist (concurrent back-to-back pushes, `GET /repos/dohertj2/mxaccessgw/actions/runners` ≥ 2) and a note that the no-cancel reality persists regardless of runner count. **Step 3 (optional workflow-level `concurrency` group)** is documented in the runbook as unverified — explicitly framed as "verify this Gitea deployment honors it before relying on it" — and left unimplemented in `ci.yml`, since it is a `ci.yml` change out of scope for this doc-only pass. **The actual runner registration (step 1) is infrastructure work outside this repo's tree and remains the operator's to execute**, tracked in the runbook. Verification performed: `grep -n 'maxParallel\|shared\|cancel' docs/GatewayTesting.md` shows the new prose; runbook file exists at the path above; no build required (doc-only change).
|
||||||
|
|
||||||
|
**Outcome (2026-08-07 — operator half executed; finding now fully `Done`).** The runner registration left open above was executed per `docs/runbooks/TST-30-second-ci-runner.md` option (a). A second instance-level `act_runner` container, `gitea-runner-2` (runner id 5, capacity 2, labels `ubuntu-latest`/`ubuntu-22.04`), now runs on `10.100.0.35` from the `/opt/gitea` compose stack with the same `container.network: traefik` setting as the original; its registration token is mounted from a `0600` file rather than inlined in compose. The existing `gitea-runner` (id 1, capacity 4) was **not** modified — capacity went 4 → 6 by addition, so the change is reversible by removing one container. Concurrency verified live by pushing HEAD (`a346d51`) to two scratch branches, `scratch/tst30-a` (run 661) and `scratch/tst30-b` (run 662), while an unrelated run (660) was already in flight: at 13:07:53Z jobs from **three** runs were `in_progress` simultaneously — run 660 `portable` and run 662 `portable`/`java` on runner 1, run 661 `portable`/`java` on `gitea-runner-2` — which the pre-change single-runner topology could not have produced. `gitea:3000` resolution holds on the new instance: run 661's `portable` job (task 1145, scheduled on `gitea-runner-2`) logged `git remote add origin http://gitea:3000/dohertj2/mxaccessgw` followed by a successful `fetch … From http://gitea:3000/dohertj2/mxaccessgw`, and its job container's workspace was confirmed checked out at `a346d514dd24e775640e5667aa7cd8e561fec68a`. **Runbook correction:** its verification checklist said `GET /repos/dohertj2/mxaccessgw/actions/runners` should show ≥2 — that endpoint still returns `total_count: 0` because both runners are registered at the **instance** level, exactly as this finding documented; the correct check is `GET /api/v1/admin/actions/runners`, which lists ids 1, 4 (an unrelated local macOS runner), and 5. Recorded as a dated "Executed" note at the top of the runbook. The no-cancel reality is unchanged and the `run-windev-ci.sh` bypass remains valid, so `docs/GatewayTesting.md`'s prose needed no edit.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Cross-domain dependencies
|
## Cross-domain dependencies
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Candidate Findings for the Next Review Cycle (surfaced during 2026-07-12 remediation)
|
# Candidate Findings for the Next Review Cycle (surfaced during 2026-07-12 remediation)
|
||||||
|
|
||||||
These were discovered while remediating the 2026-07-12 backlog but were **out of scope** for it — each is either pre-existing, by-design residual, or a new observation. They are recorded here (not fixed) so the next review cycle can triage them. None blocks the 2026-07-12 cycle, which is complete.
|
These were discovered while remediating the 2026-07-12 backlog but were **out of scope** for it — each is either pre-existing, by-design residual, or a new observation. They are recorded here so the next review cycle can triage them. None blocks the 2026-07-12 cycle, which is complete. Rows struck through have since been fixed ahead of that cycle; the original finding text is kept so the triage record stays readable.
|
||||||
|
|
||||||
| ID (proposed) | Area | Severity (est.) | Summary |
|
| ID (proposed) | Area | Severity (est.) | Summary |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -9,11 +9,17 @@ These were discovered while remediating the 2026-07-12 backlog but were **out of
|
|||||||
| NEXT-03 | Gateway alarms | Low | `GatewayAlarmMonitor.ApplyReconcile` feed-repair broadcasts (the new acked-delta from GWC-26 **and** the pre-existing Raise/Clear repair) are **at-least-once, not exactly-once**: a periodic reconcile can synthesize a transition whose matching live transition is still buffered in the alarm lease, so both broadcast as indistinguishable duplicates on the alarm feed (StreamAlarms + dashboard hub). Pre-existing (the Raise/Clear repair always had it); GWC-26 documented the at-least-once contract rather than closing the race. Closing it needs reconcile/live serialization or a monotonic dedup marker. |
|
| NEXT-03 | Gateway alarms | Low | `GatewayAlarmMonitor.ApplyReconcile` feed-repair broadcasts (the new acked-delta from GWC-26 **and** the pre-existing Raise/Clear repair) are **at-least-once, not exactly-once**: a periodic reconcile can synthesize a transition whose matching live transition is still buffered in the alarm lease, so both broadcast as indistinguishable duplicates on the alarm feed (StreamAlarms + dashboard hub). Pre-existing (the Raise/Clear repair always had it); GWC-26 documented the at-least-once contract rather than closing the race. Closing it needs reconcile/live serialization or a monotonic dedup marker. |
|
||||||
| NEXT-04 | Worker frame writer | Low | WRK-22/WRK-25 cancellation path: a frame `Claimed` by a concurrent lock-holder just before its caller's cancellation races in is never awaited by that caller; if the write then faults, `TrySetException` lands on a `Task` nobody observes (unobserved-task-exception). By-design residual, non-crash (no `UnobservedTaskException` handler registered), pre-existing to single-frame WRK-22 and amplified per-batch by WRK-25. Hygiene fix: attach a fault-observing continuation to abandoned/tombstoned frame completions. |
|
| NEXT-04 | Worker frame writer | Low | WRK-22/WRK-25 cancellation path: a frame `Claimed` by a concurrent lock-holder just before its caller's cancellation races in is never awaited by that caller; if the write then faults, `TrySetException` lands on a `Task` nobody observes (unobserved-task-exception). By-design residual, non-crash (no `UnobservedTaskException` handler registered), pre-existing to single-frame WRK-22 and amplified per-batch by WRK-25. Hygiene fix: attach a fault-observing continuation to abandoned/tombstoned frame completions. |
|
||||||
| NEXT-05 | Worker frame writer | Info | A batch whose remaining frames are tombstoned by cancellation leaves dead `PendingFrame` entries in `_eventFrames`/`_controlFrames` until a future `DequeueNext` pops and skips them. Same pre-existing behavior as single-frame WRK-22, amplified per-batch; in practice heartbeats purge them promptly, so not a real leak. |
|
| NEXT-05 | Worker frame writer | Info | A batch whose remaining frames are tombstoned by cancellation leaves dead `PendingFrame` entries in `_eventFrames`/`_controlFrames` until a future `DequeueNext` pops and skips them. Same pre-existing behavior as single-frame WRK-22, amplified per-batch; in practice heartbeats purge them promptly, so not a real leak. |
|
||||||
|
| ~~NEXT-06~~ | Testing / live LDAP | Medium | **Resolved 2026-08-07** — fixtures realigned to the shared directory (`admin`/`password` for the GwAdmin success path, `gw-viewer`/`password` for the bind-succeeds-but-no-role path); verified `Failed: 0, Passed: 5` live against the shared GLAuth at `10.100.0.35:3893`, so the success-path assertion (GwAdmin group claim + Admin role claim) now fails if the service-account credential is wrong. Original finding: `DashboardLdapLiveTests` fixtures have drifted from the shared GLAuth directory, leaving the suite with **no positive-proof coverage of the service-account bind**. Its only success-path test, `AuthenticateAsync_AdminInGwAdminGroup_Succeeds`, binds `admin`/`admin123`, but the directory's `admin` user carries the standard dev password (`scadaproj/infra/glauth/config.toml`), so that assertion cannot pass. `AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails` binds fixture user `readonly`, which **does not exist** in the GLAuth config at all — it passes for the wrong reason (user-not-found rather than the group-missing branch it names; the `readonly` name is in fact barred by the README's user/group case-collision rule). The three remaining tests are negative assertions that pass whether or not the service account can bind. Net effect: a green `DashboardLdapLiveTests` run proves nothing about the bind credential — surfaced during SEC-36, where the suite was considered as a substitute for the deferred dashboard-login check and rejected. Fix: realign the fixtures to real directory users (e.g. `multi-role`/`gw-viewer`) or add the missing users to the GLAuth config, and add one test that fails when the service-account credential is wrong. |
|
||||||
|
| ~~NEXT-07~~ | Deployment / windev | High | **Resolved 2026-08-07** — a fresh portable framework-dependent publish of `origin/main` (`a346d51`) was built in a clean clone at `C:\build\mxgw-redeploy`, deployed to `C:\publish\mxaccessgw\Server-20260807`, and the `MxAccessGw` NSSM service repointed at it; the service now holds a stable PID with both `5120`/`5130` listening, a worker spawned, the Galaxy snapshot restored (129 objects / 56,731 attributes) and a clean event log. Root cause confirmed as the version skew this row predicted: the deployed 2026-06-25 build carried `ZB.MOM.WW.Auth.ApiKeys` 0.1.2.0, which supports auth-DB schema 2, against a `gateway-auth.db` stamped at schema 3 on 2026-07-15 by an ephemeral run of newer code — schema 3 is the current shared-lib version (`SqliteAuthSchema.CurrentVersion=3` in Auth 0.1.5), so the redeploy is the forward fix and the DB was left alone. Rollback artifacts kept: `C:\ProgramData\MxGateway\gateway-auth.db.bak-next07` (with `-wal`/`-shm`) and the previous `C:\publish\mxaccessgw\Server` directory. Two side effects worth recording: the old deploy's `appsettings.json` held the LDAP bind password in **plaintext on disk**, while the new one keeps the repo's `${secret:ldap/mxgateway/bind}` token with the NSSM environment supplying the value, so no plaintext LDAP secret remains on that host; and the redeploy tripped the SEC-06 `Ldap:Transport=None` production hard-stop (`GatewayOptionsValidator.cs:178`), resolved by relabelling the host — windev runs `Dashboard:DisableLogin=true`, which this repo's own docs mark dev/test-only, so its `Production` label contradicted its configuration and `DOTNET_ENVIRONMENT` was changed to `Staging` (that one NSSM environment entry only; the other nine preserved byte-identical). SEC-06 is untouched for genuinely production hosts — see NEXT-08 for the posture problem that relabelling defers. Original finding: The `10.100.0.48` (windev) gateway deployment is **stale and crash-looping**, and has been since at least 2026-08-06 (~10k Hosting-failed events/day). The deployed Server binary dates to 2026-06-25 and predates the auth-DB migration of 2026-07-15: it opens a schema-version-3 `gateway-auth.db` that it supports only at version 2 and aborts at startup, so the `MxAccessGw` service never reaches a listening state. Not a code defect in the current tree — a deploy-drift/operations gap — but it means the repo's only deployed host has been dark for over a day and any host-level verification (including SEC-36's dashboard-login check) is blocked until it is repaired. Fix: deploy a current Server build to windev, or restore/downgrade the auth DB to schema 2 if the old binary must stand. Worth asking separately why a service in a permanent restart loop raised no alert. Discovered during SEC-36. |
|
||||||
|
| NEXT-08 | Security / LDAP posture | Medium | **The shared GLAuth offers no TLS, so SEC-06 makes it undeployable from a `Production`-labelled host.** `GatewayOptionsValidator` (`src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs:178`) refuses to start when `Ldap:Transport=None` in the `Production` environment, and `docs/GatewayConfiguration.md`'s `Transport` row states "Deployed hosts must set `Ldaps` or `StartTls`" — but the shared instance at `10.100.0.35:3893` has `[ldaps] enabled=false`, port `3894` closed, and answers StartTLS with `protocolError`, so neither value can work against it. That instruction is currently unsatisfiable for every host that authenticates there. windev sidestepped it on 2026-08-07 by moving to the `Staging` environment name (NEXT-07), which is honest for a dev/test rig but is not available to a real production host. Resolution needs either LDAPS/StartTLS on the shared GLAuth (certificate plus a trust story on each gateway host) or an explicit written posture decision that production gateways bind a different, TLS-capable directory. Surfaced during the NEXT-07 redeploy. |
|
||||||
|
| NEXT-09 | Build / versioning | Low | **Windows builds stamp git's error text into `InformationalVersion`.** `src/Directory.Build.props:29` runs `git -C "$(MSBuildThisFileDirectory)" …`; MSBuild's directory property ends in a backslash, which escapes the closing quote, so the command is malformed on Windows. The target carries `ContinueOnError`, so the failure is silent and git's stderr is captured as the revision — an observed stamp reads `0.1.2+fatal: cannot change to …`. Any Windows build without a preset `SourceRevisionId` therefore ships a binary that cannot be correlated back to a commit, defeating the point of TST-11. Not reproducible on macOS/Linux, where the separator is `/`. Fix sketch: append `.` to the path or trim the trailing separator before quoting. Surfaced while identifying the deployed binary during NEXT-07. |
|
||||||
|
| NEXT-10 | Docs / glauth | Medium | **`glauth.md`'s "Pre-provisioned users" table contradicts both the directory and the rest of its own file.** It documents `readonly`/`readonly123` and `admin`/`admin123`, neither of which matches `scadaproj/infra/glauth/config.toml` (`readonly` does not exist there; `admin` carries the standard dev password), and lists the `ReadOnly` gid as `5501` against an actual `5601`. Its dashboard section, by contrast, is correct — so the file is internally inconsistent and a reader cannot tell which half to trust. This table was the **root cause of the NEXT-06 fixture drift**, and it has propagated further: `docs/GatewayTesting.md`'s `MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_PASSWORD` default and the matching literal in `WorkerLiveMxAccessSmokeTests` both take `admin123` from it. Deliberately **not** fixed in the 2026-08-07 pass: the table is entangled with the OPC-UA group taxonomy (gids, role mapping, and the sister-repo consumers of the same directory), so reconciling it means sweeping that taxonomy as one unit rather than patching two rows. |
|
||||||
|
|
||||||
## Operator actions still pending (from this cycle's runbooks)
|
## Operator actions still pending (from this cycle's runbooks)
|
||||||
|
|
||||||
These are **live-infrastructure actions the operator must execute** — the repo-side work is complete and merged:
|
These are **live-infrastructure actions the operator must execute** — the repo-side work is complete and merged:
|
||||||
|
|
||||||
- **SEC-36** — rotate the dev LDAP service-account credential per `docs/runbooks/SEC-36-ldap-credential-rotation.md` (generate new secret in `scadaproj/infra/glauth`, pre-stage the NSSM env var on deployed hosts, rotate GLAuth on `10.100.0.35`, verify dashboard login). The committed literal is gone from the working tree but remains recoverable from git history until rotation completes — **rotation is the load-bearing half.**
|
- ~~**SEC-36** — rotate the dev LDAP service-account credential per `docs/runbooks/SEC-36-ldap-credential-rotation.md` (generate new secret in `scadaproj/infra/glauth`, pre-stage the NSSM env var on deployed hosts, rotate GLAuth on `10.100.0.35`, verify dashboard login). The committed literal is gone from the working tree but remains recoverable from git history until rotation completes — **rotation is the load-bearing half.**~~ **Executed 2026-08-07**: the `serviceaccount` `passsha256` was replaced in `scadaproj/infra/glauth/config.toml` (commit `aada53b`) and the shared GLAuth recreated on `10.100.0.35`, so the literal recoverable from this repo's history no longer binds `dc=zb,dc=local`. The new value lives only in the GLAuth hash, windev's NSSM environment, and dev user-secrets. `wonder-app-vd03` was out of scope (it binds a different, `dc=scadalink`/`dc=scadabridge` directory). **Caveat, since closed:** windev's dashboard `/login` verification was deferred while that host's gateway was crash-looping on the unrelated stale-deployment fault filed as NEXT-07, so the bind was verified directly by `ldapsearch` as `cn=serviceaccount,dc=zb,dc=local` instead. After the 2026-08-07 redeploy the real check ran on windev (login as `multi-role` → 302 + dashboard cookie, anonymous control → `/login`), so the rotated credential is now proven through the `DashboardAuthenticator` path itself; see `docs/runbooks/SEC-36-ldap-credential-rotation.md` Correction 3. SEC-36 is fully `Done`.
|
||||||
- **TST-30** — register a second Gitea `act_runner` on `10.100.0.35` per `docs/runbooks/TST-30-second-ci-runner.md` to relieve the single-shared-runner bottleneck.
|
- ~~**TST-30** — register a second Gitea `act_runner` on `10.100.0.35` per `docs/runbooks/TST-30-second-ci-runner.md` to relieve the single-shared-runner bottleneck.~~ **Executed 2026-08-07**: `gitea-runner-2` (id 5, capacity 2) is online on `10.100.0.35` via the `/opt/gitea` compose stack, same `container.network: traefik`, token from a `0600` file mount; the existing runner (id 1, capacity 4) was untouched. Concurrency verified — jobs from three runs ran simultaneously across both runners, and a `gitea-runner-2` job cloned successfully from `http://gitea:3000`. TST-30 is now fully `Done`.
|
||||||
|
- **TST-30 follow-up — reset the Gitea instance runner registration token.** Runner-1's compose block was moved to the same `0600` file-mount pattern as runner-2 on 2026-08-07 (compose and both backups now `0600 root:root`, runner-1 recreated with its identity intact), but hygiene alone does not retire the token: both runners share **one instance-scope registration token** that was world-readable for roughly five months and is still live — a probe registered runner id 6 with it, then deleted it. Gitea 1.26.4 exposes no rotation via CLI or API (both paths are get-or-create and hand back the same value), so the reset must be done in the admin web UI ("Reset registration token"). Afterwards, refresh `/opt/gitea/runner_token` on `10.100.0.35` and shred the two token-bearing compose backups — they are the last copies of the old value. See `docs/runbooks/TST-30-second-ci-runner.md`.
|
||||||
- **TST-25 follow-ups** — old **TST-05** (scheduled live-MXAccess smoke) is now covered by the `nightly-windev` job; old **TST-24** (client wire tests in CI) is unblocked by the working Windows tier.
|
- **TST-25 follow-ups** — old **TST-05** (scheduled live-MXAccess smoke) is now covered by the `nightly-windev` job; old **TST-24** (client wire tests in CI) is unblocked by the working Windows tier.
|
||||||
|
|||||||
@@ -2553,6 +2553,9 @@ func (x *ActivateCommand) GetItemHandle() int32 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
// outcome when it arrives within the worker's bounded wait — see
|
||||||
|
// MxCommandReply.statuses.
|
||||||
type WriteCommand struct {
|
type WriteCommand struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
||||||
@@ -2621,6 +2624,7 @@ func (x *WriteCommand) GetUserId() int32 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same statuses correlation as WriteCommand.
|
||||||
type Write2Command struct {
|
type Write2Command struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
||||||
@@ -2697,6 +2701,9 @@ func (x *Write2Command) GetUserId() int32 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
// outcome when it arrives within the worker's bounded wait — see
|
||||||
|
// MxCommandReply.statuses.
|
||||||
type WriteSecuredCommand struct {
|
type WriteSecuredCommand struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
||||||
@@ -2775,6 +2782,9 @@ func (x *WriteSecuredCommand) GetValue() *MxValue {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
// outcome when it arrives within the worker's bounded wait — see
|
||||||
|
// MxCommandReply.statuses.
|
||||||
type WriteSecured2Command struct {
|
type WriteSecured2Command struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
||||||
@@ -4575,8 +4585,21 @@ type MxCommandReply struct {
|
|||||||
// HRESULT captured from MXAccess or a COM exception. This remains separate
|
// HRESULT captured from MXAccess or a COM exception. This remains separate
|
||||||
// from gateway protocol status so MXAccess parity details are not hidden by
|
// from gateway protocol status so MXAccess parity details are not hidden by
|
||||||
// transport failures.
|
// transport failures.
|
||||||
Hresult *int32 `protobuf:"varint,5,opt,name=hresult,proto3,oneof" json:"hresult,omitempty"`
|
Hresult *int32 `protobuf:"varint,5,opt,name=hresult,proto3,oneof" json:"hresult,omitempty"`
|
||||||
ReturnValue *MxValue `protobuf:"bytes,6,opt,name=return_value,json=returnValue,proto3" json:"return_value,omitempty"`
|
ReturnValue *MxValue `protobuf:"bytes,6,opt,name=return_value,json=returnValue,proto3" json:"return_value,omitempty"`
|
||||||
|
// Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
// field as before.
|
||||||
Statuses []*MxStatusProxy `protobuf:"bytes,7,rep,name=statuses,proto3" json:"statuses,omitempty"`
|
Statuses []*MxStatusProxy `protobuf:"bytes,7,rep,name=statuses,proto3" json:"statuses,omitempty"`
|
||||||
DiagnosticMessage string `protobuf:"bytes,8,opt,name=diagnostic_message,json=diagnosticMessage,proto3" json:"diagnostic_message,omitempty"`
|
DiagnosticMessage string `protobuf:"bytes,8,opt,name=diagnostic_message,json=diagnosticMessage,proto3" json:"diagnostic_message,omitempty"`
|
||||||
// Types that are valid to be assigned to Payload:
|
// Types that are valid to be assigned to Payload:
|
||||||
|
|||||||
@@ -24235,6 +24235,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
int getUserId();
|
int getUserId();
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
* outcome when it arrives within the worker's bounded wait — see
|
||||||
|
* MxCommandReply.statuses.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* Protobuf type {@code mxaccess_gateway.v1.WriteCommand}
|
* Protobuf type {@code mxaccess_gateway.v1.WriteCommand}
|
||||||
*/
|
*/
|
||||||
public static final class WriteCommand extends
|
public static final class WriteCommand extends
|
||||||
@@ -24527,6 +24533,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
* outcome when it arrives within the worker's bounded wait — see
|
||||||
|
* MxCommandReply.statuses.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* Protobuf type {@code mxaccess_gateway.v1.WriteCommand}
|
* Protobuf type {@code mxaccess_gateway.v1.WriteCommand}
|
||||||
*/
|
*/
|
||||||
public static final class Builder extends
|
public static final class Builder extends
|
||||||
@@ -25036,6 +25048,10 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
int getUserId();
|
int getUserId();
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Same statuses correlation as WriteCommand.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* Protobuf type {@code mxaccess_gateway.v1.Write2Command}
|
* Protobuf type {@code mxaccess_gateway.v1.Write2Command}
|
||||||
*/
|
*/
|
||||||
public static final class Write2Command extends
|
public static final class Write2Command extends
|
||||||
@@ -25370,6 +25386,10 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Same statuses correlation as WriteCommand.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* Protobuf type {@code mxaccess_gateway.v1.Write2Command}
|
* Protobuf type {@code mxaccess_gateway.v1.Write2Command}
|
||||||
*/
|
*/
|
||||||
public static final class Builder extends
|
public static final class Builder extends
|
||||||
@@ -26028,6 +26048,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
mxaccess_gateway.v1.MxaccessGateway.MxValueOrBuilder getValueOrBuilder();
|
mxaccess_gateway.v1.MxaccessGateway.MxValueOrBuilder getValueOrBuilder();
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
* outcome when it arrives within the worker's bounded wait — see
|
||||||
|
* MxCommandReply.statuses.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* Protobuf type {@code mxaccess_gateway.v1.WriteSecuredCommand}
|
* Protobuf type {@code mxaccess_gateway.v1.WriteSecuredCommand}
|
||||||
*/
|
*/
|
||||||
public static final class WriteSecuredCommand extends
|
public static final class WriteSecuredCommand extends
|
||||||
@@ -26357,6 +26383,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
* outcome when it arrives within the worker's bounded wait — see
|
||||||
|
* MxCommandReply.statuses.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* Protobuf type {@code mxaccess_gateway.v1.WriteSecuredCommand}
|
* Protobuf type {@code mxaccess_gateway.v1.WriteSecuredCommand}
|
||||||
*/
|
*/
|
||||||
public static final class Builder extends
|
public static final class Builder extends
|
||||||
@@ -26976,6 +27008,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
mxaccess_gateway.v1.MxaccessGateway.MxValueOrBuilder getTimestampValueOrBuilder();
|
mxaccess_gateway.v1.MxaccessGateway.MxValueOrBuilder getTimestampValueOrBuilder();
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
* outcome when it arrives within the worker's bounded wait — see
|
||||||
|
* MxCommandReply.statuses.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* Protobuf type {@code mxaccess_gateway.v1.WriteSecured2Command}
|
* Protobuf type {@code mxaccess_gateway.v1.WriteSecured2Command}
|
||||||
*/
|
*/
|
||||||
public static final class WriteSecured2Command extends
|
public static final class WriteSecured2Command extends
|
||||||
@@ -27347,6 +27385,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
* outcome when it arrives within the worker's bounded wait — see
|
||||||
|
* MxCommandReply.statuses.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* Protobuf type {@code mxaccess_gateway.v1.WriteSecured2Command}
|
* Protobuf type {@code mxaccess_gateway.v1.WriteSecured2Command}
|
||||||
*/
|
*/
|
||||||
public static final class Builder extends
|
public static final class Builder extends
|
||||||
@@ -50488,24 +50532,104 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
mxaccess_gateway.v1.MxaccessGateway.MxValueOrBuilder getReturnValueOrBuilder();
|
mxaccess_gateway.v1.MxaccessGateway.MxValueOrBuilder getReturnValueOrBuilder();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy>
|
java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy>
|
||||||
getStatusesList();
|
getStatusesList();
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy getStatuses(int index);
|
mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy getStatuses(int index);
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
int getStatusesCount();
|
int getStatusesCount();
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
java.util.List<? extends mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder>
|
java.util.List<? extends mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder>
|
||||||
getStatusesOrBuilderList();
|
getStatusesOrBuilderList();
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder getStatusesOrBuilder(
|
mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder getStatusesOrBuilder(
|
||||||
@@ -51233,6 +51357,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
@SuppressWarnings("serial")
|
@SuppressWarnings("serial")
|
||||||
private java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy> statuses_;
|
private java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy> statuses_;
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
@java.lang.Override
|
@java.lang.Override
|
||||||
@@ -51240,6 +51380,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return statuses_;
|
return statuses_;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
@java.lang.Override
|
@java.lang.Override
|
||||||
@@ -51248,6 +51404,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return statuses_;
|
return statuses_;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
@java.lang.Override
|
@java.lang.Override
|
||||||
@@ -51255,6 +51427,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return statuses_.size();
|
return statuses_.size();
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
@java.lang.Override
|
@java.lang.Override
|
||||||
@@ -51262,6 +51450,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return statuses_.get(index);
|
return statuses_.get(index);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
@java.lang.Override
|
@java.lang.Override
|
||||||
@@ -53981,6 +54185,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy, mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder, mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder> statusesBuilder_;
|
mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy, mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder, mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder> statusesBuilder_;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy> getStatusesList() {
|
public java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy> getStatusesList() {
|
||||||
@@ -53991,6 +54211,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public int getStatusesCount() {
|
public int getStatusesCount() {
|
||||||
@@ -54001,6 +54237,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy getStatuses(int index) {
|
public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy getStatuses(int index) {
|
||||||
@@ -54011,6 +54263,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public Builder setStatuses(
|
public Builder setStatuses(
|
||||||
@@ -54028,6 +54296,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public Builder setStatuses(
|
public Builder setStatuses(
|
||||||
@@ -54042,6 +54326,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public Builder addStatuses(mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy value) {
|
public Builder addStatuses(mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy value) {
|
||||||
@@ -54058,6 +54358,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public Builder addStatuses(
|
public Builder addStatuses(
|
||||||
@@ -54075,6 +54391,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public Builder addStatuses(
|
public Builder addStatuses(
|
||||||
@@ -54089,6 +54421,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public Builder addStatuses(
|
public Builder addStatuses(
|
||||||
@@ -54103,6 +54451,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public Builder addAllStatuses(
|
public Builder addAllStatuses(
|
||||||
@@ -54118,6 +54482,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public Builder clearStatuses() {
|
public Builder clearStatuses() {
|
||||||
@@ -54131,6 +54511,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public Builder removeStatuses(int index) {
|
public Builder removeStatuses(int index) {
|
||||||
@@ -54144,6 +54540,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder getStatusesBuilder(
|
public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder getStatusesBuilder(
|
||||||
@@ -54151,6 +54563,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
return internalGetStatusesFieldBuilder().getBuilder(index);
|
return internalGetStatusesFieldBuilder().getBuilder(index);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder getStatusesOrBuilder(
|
public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder getStatusesOrBuilder(
|
||||||
@@ -54161,6 +54589,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public java.util.List<? extends mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder>
|
public java.util.List<? extends mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder>
|
||||||
@@ -54172,6 +54616,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder addStatusesBuilder() {
|
public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder addStatusesBuilder() {
|
||||||
@@ -54179,6 +54639,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.getDefaultInstance());
|
mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.getDefaultInstance());
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder addStatusesBuilder(
|
public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder addStatusesBuilder(
|
||||||
@@ -54187,6 +54663,22 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
|
|||||||
index, mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.getDefaultInstance());
|
index, mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.getDefaultInstance());
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
* field as before.
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
* <code>repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7;</code>
|
||||||
*/
|
*/
|
||||||
public java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder>
|
public java.util.List<mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder>
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ message ActivateCommand {
|
|||||||
int32 item_handle = 2;
|
int32 item_handle = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
// outcome when it arrives within the worker's bounded wait — see
|
||||||
|
// MxCommandReply.statuses.
|
||||||
message WriteCommand {
|
message WriteCommand {
|
||||||
int32 server_handle = 1;
|
int32 server_handle = 1;
|
||||||
int32 item_handle = 2;
|
int32 item_handle = 2;
|
||||||
@@ -248,6 +251,7 @@ message WriteCommand {
|
|||||||
int32 user_id = 4;
|
int32 user_id = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same statuses correlation as WriteCommand.
|
||||||
message Write2Command {
|
message Write2Command {
|
||||||
int32 server_handle = 1;
|
int32 server_handle = 1;
|
||||||
int32 item_handle = 2;
|
int32 item_handle = 2;
|
||||||
@@ -256,6 +260,9 @@ message Write2Command {
|
|||||||
int32 user_id = 5;
|
int32 user_id = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
// outcome when it arrives within the worker's bounded wait — see
|
||||||
|
// MxCommandReply.statuses.
|
||||||
message WriteSecuredCommand {
|
message WriteSecuredCommand {
|
||||||
int32 server_handle = 1;
|
int32 server_handle = 1;
|
||||||
int32 item_handle = 2;
|
int32 item_handle = 2;
|
||||||
@@ -266,6 +273,9 @@ message WriteSecuredCommand {
|
|||||||
MxValue value = 5;
|
MxValue value = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
// outcome when it arrives within the worker's bounded wait — see
|
||||||
|
// MxCommandReply.statuses.
|
||||||
message WriteSecured2Command {
|
message WriteSecured2Command {
|
||||||
int32 server_handle = 1;
|
int32 server_handle = 1;
|
||||||
int32 item_handle = 2;
|
int32 item_handle = 2;
|
||||||
@@ -525,6 +535,19 @@ message MxCommandReply {
|
|||||||
// transport failures.
|
// transport failures.
|
||||||
optional int32 hresult = 5;
|
optional int32 hresult = 5;
|
||||||
MxValue return_value = 6;
|
MxValue return_value = 6;
|
||||||
|
// Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
// field as before.
|
||||||
repeated MxStatusProxy statuses = 7;
|
repeated MxStatusProxy statuses = 7;
|
||||||
string diagnostic_message = 8;
|
string diagnostic_message = 8;
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,18 @@ numbers are still unclaimed — the guards above do this automatically at
|
|||||||
publish time, but a version bump in the source is still a manual step per
|
publish time, but a version bump in the source is still a manual step per
|
||||||
client.
|
client.
|
||||||
|
|
||||||
|
On 2026-08-07 that release shipped. Published coordinates on
|
||||||
|
`gitea.dohertylan.com`: `nuget` `ZB.MOM.WW.MxGateway.Client` **0.2.0** and
|
||||||
|
`ZB.MOM.WW.MxGateway.Contracts` **0.2.0**, `pypi`
|
||||||
|
`zb-mom-ww-mxaccess-gateway-client` **0.2.0**, `cargo`
|
||||||
|
`zb-mom-ww-mxgateway-client` **0.2.0**, `maven`
|
||||||
|
`com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client` **0.2.1** (the Java
|
||||||
|
exception described above). Go publishes no artifact — it ships as the module
|
||||||
|
tag `clients/go/v0.2.0`, created at commit `a346d51`. Each coordinate was
|
||||||
|
confirmed present through the Gitea package API after the push, and
|
||||||
|
`go list -m` resolves the Go tag. These are the numbers a future release
|
||||||
|
bumps off.
|
||||||
|
|
||||||
## .NET
|
## .NET
|
||||||
|
|
||||||
The .NET client uses .NET 10 and references
|
The .NET client uses .NET 10 and references
|
||||||
@@ -200,6 +212,12 @@ a `cargo package` that cannot build from the vendored tree alone would mean
|
|||||||
the vendored copies are stale, and verification is what catches that before
|
the vendored copies are stale, and verification is what catches that before
|
||||||
publish.
|
publish.
|
||||||
|
|
||||||
|
Publishing to the `dohertj2-gitea` alternative registry reads the token from
|
||||||
|
`CARGO_REGISTRIES_DOHERTJ2_GITEA_TOKEN`, and that variable must hold
|
||||||
|
`Bearer <token>` — cargo sends the value as the `Authorization` header
|
||||||
|
verbatim and Gitea's cargo registry rejects a bare token with `401`, unlike
|
||||||
|
the other feeds, which authenticate with a username/token basic-auth pair.
|
||||||
|
|
||||||
Regenerate and compile Rust bindings:
|
Regenerate and compile Rust bindings:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -534,6 +534,53 @@ against the live MXAccess attribute set.
|
|||||||
- [Alarm Client Discovery — Subtag provider](./AlarmClientDiscovery.md)
|
- [Alarm Client Discovery — Subtag provider](./AlarmClientDiscovery.md)
|
||||||
- [gRPC Contract — provider_status and degraded fields](./Grpc.md)
|
- [gRPC Contract — provider_status and degraded fields](./Grpc.md)
|
||||||
|
|
||||||
|
## Write Completion Correlation
|
||||||
|
|
||||||
|
MXAccess writes are fire-and-forget: the toolkit call returns before the
|
||||||
|
Galaxy commit, and the per-item outcome only exists in the later
|
||||||
|
`OnWriteComplete` COM callback. The original unary write reply therefore
|
||||||
|
proved worker-side command acceptance only, forcing consumers (OtOpcUa's
|
||||||
|
GalaxyDriver) to report every write as provisionally good.
|
||||||
|
|
||||||
|
For the unary write kinds (`Write`/`Write2`/`WriteSecured`/`WriteSecured2`)
|
||||||
|
the worker now holds the unary reply for a
|
||||||
|
bounded window (`MxGateway:Worker:WriteCompletionWaitMilliseconds`, default
|
||||||
|
1.5 s, `0` disables; conveyed to the worker via
|
||||||
|
`MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS`) and copies the matching
|
||||||
|
callback's status rows onto `MxCommandReply.statuses`. Key choices, argued in
|
||||||
|
[the design doc](./plans/2026-08-09-write-completion-correlation-design.md):
|
||||||
|
|
||||||
|
- **Pump-wait on the STA, not a parked reply.** The executor holds the STA
|
||||||
|
thread but pumps Windows messages each poll — the shipped ReadBulk pattern —
|
||||||
|
because commands serialize per session anyway, so freeing the STA during the
|
||||||
|
wait buys nothing and a parked reply would change the dispatcher/pipe
|
||||||
|
contracts.
|
||||||
|
- **Version baseline before the COM call** closes the fast-completion edge: a
|
||||||
|
callback that dispatches while `WriteSecured` is still on the stack still
|
||||||
|
correlates.
|
||||||
|
- **Timeout returns today's shape** (protocol OK, empty statuses):
|
||||||
|
unconfirmed is honest; a synthesized failure row would trigger consumer-side
|
||||||
|
write-revert logic on slow-but-successful commits. The 1.5 s default stays
|
||||||
|
inside OtOpcUa's 2 s Tier A write-resilience budget.
|
||||||
|
- **Parity preserved.** `protocol_status`/`hresult` keep describing
|
||||||
|
acceptance; the MXAccess outcome (success or failure) rides only in
|
||||||
|
`statuses[0]`; the `OnWriteComplete` event still streams unchanged (nothing
|
||||||
|
swallowed, nothing synthesized).
|
||||||
|
- **Scope: all four unary write kinds; bulk writes stay fire-and-forget.**
|
||||||
|
The first cut correlated `WriteSecured`/`WriteSecured2` only, but OtOpcUa's
|
||||||
|
dominant FreeAccess write path goes out as plain `Write` (2026-08-09 live
|
||||||
|
verification, 06/S-1) — a refused plain write was invisible on the reply.
|
||||||
|
Plain `Write`/`Write2` now correlate identically. Bulk writes keep
|
||||||
|
fire-and-forget replies: waiting per entry would add a device round-trip per
|
||||||
|
item to high-rate supervisory loops.
|
||||||
|
- **Best-effort correlation.** The callback carries only
|
||||||
|
`(hItem, statuses)` — no transaction id — so concurrent writes to the same
|
||||||
|
item within the window can swap rows; benign for the serialized single-write
|
||||||
|
consumer contract.
|
||||||
|
- **Client cancellation needs no special path**: a caller abandoning the RPC
|
||||||
|
mid-wait leaves the worker to finish its bounded wait and reply; the gateway
|
||||||
|
discards the reply, the session is never faulted.
|
||||||
|
|
||||||
## Later Revisit Items
|
## Later Revisit Items
|
||||||
|
|
||||||
These are explicit post-v1 revisit items, not open blockers:
|
These are explicit post-v1 revisit items, not open blockers:
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ launch CWD (SEC-01, SEC-33).
|
|||||||
| `MxGateway:Worker:StartupProbeRetryAttempts` | `3` | Number of retry attempts for transient worker startup probe failures before pipe connection and handshake continue. |
|
| `MxGateway:Worker:StartupProbeRetryAttempts` | `3` | Number of retry attempts for transient worker startup probe failures before pipe connection and handshake continue. |
|
||||||
| `MxGateway:Worker:StartupProbeRetryDelayMilliseconds` | `250` | Delay between transient startup probe retry attempts. |
|
| `MxGateway:Worker:StartupProbeRetryDelayMilliseconds` | `250` | Delay between transient startup probe retry attempts. |
|
||||||
| `MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds` | `2000` | Per-attempt timeout used by the worker named-pipe connect retry path. The overall pipe connection still stays under the startup budget. |
|
| `MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds` | `2000` | Per-attempt timeout used by the worker named-pipe connect retry path. The overall pipe connection still stays under the startup budget. |
|
||||||
|
| `MxGateway:Worker:WriteCompletionWaitMilliseconds` | `1500` | Bounded wait the worker holds a unary write reply (`Write`/`Write2`/`WriteSecured`/`WriteSecured2`; bulk writes excluded) for the matching MXAccess `OnWriteComplete` callback, so the reply's `statuses` carry the real commit outcome. `0` disables the wait (pure fire-and-forget replies). Must be `>= 0`. The gateway conveys the value to the worker via the `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` environment variable. Consumers that time their own writes must budget above this wait: OtOpcUa's GalaxyDriver wraps gateway writes in a 2 s Tier A resilience timeout, so a deployment raising this option past ~2000 must raise that driver `ResilienceConfig` write timeout in step or slow-but-successful commits surface as consumer-side failures. |
|
||||||
| `MxGateway:Worker:ShutdownTimeoutSeconds` | `10` | Grace period for worker shutdown before the gateway treats shutdown as failed and may kill the worker process tree. |
|
| `MxGateway:Worker:ShutdownTimeoutSeconds` | `10` | Grace period for worker shutdown before the gateway treats shutdown as failed and may kill the worker process tree. |
|
||||||
| `MxGateway:Worker:HeartbeatIntervalSeconds` | `5` | Worker heartbeat send interval and gateway heartbeat check cadence input. |
|
| `MxGateway:Worker:HeartbeatIntervalSeconds` | `5` | Worker heartbeat send interval and gateway heartbeat check cadence input. |
|
||||||
| `MxGateway:Worker:HeartbeatGraceSeconds` | `15` | Maximum age of the last worker heartbeat before the gateway faults the worker. This must be greater than or equal to `HeartbeatIntervalSeconds`. |
|
| `MxGateway:Worker:HeartbeatGraceSeconds` | `15` | Maximum age of the last worker heartbeat before the gateway faults the worker. This must be greater than or equal to `HeartbeatIntervalSeconds`. |
|
||||||
@@ -249,7 +250,7 @@ dev/test GLAuth posture (`glauth.md`), not a production posture.
|
|||||||
| `MxGateway:Ldap:AllowInsecure` | `true` | Permits a plaintext bind. Must be `true` when `Transport` is `None`; set `false` (with `Ldaps`/`StartTls`) in production. |
|
| `MxGateway:Ldap:AllowInsecure` | `true` | Permits a plaintext bind. Must be `true` when `Transport` is `None`; set `false` (with `Ldaps`/`StartTls`) in production. |
|
||||||
| `MxGateway:Ldap:SearchBase` | `dc=zb,dc=local` | Search base DN. |
|
| `MxGateway:Ldap:SearchBase` | `dc=zb,dc=local` | Search base DN. |
|
||||||
| `MxGateway:Ldap:ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | Bind DN for the search account. |
|
| `MxGateway:Ldap:ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | Bind DN for the search account. |
|
||||||
| `MxGateway:Ldap:ServiceAccountPassword` | `${secret:ldap/mxgateway/bind}` | Search-account password. **Never a committed plaintext value (SEC-36):** the shared GLAuth bind credential is supplied out-of-band through one of three channels, all binding to this key. **(1) Encrypted secrets store (shipped default):** `appsettings.json` ships the reference `${secret:ldap/mxgateway/bind}`, which the pre-host `${secret:}` expander resolves at startup from the encrypted secrets store (the code-side design default is blank, so a missing/unresolved value fails closed rather than falling back to a leaked credential). Seed it once with `secret set ldap/mxgateway/bind <value>` (the store's master key must be present via `ZB_SECRETS_MASTER_KEY`); startup aborts with `SecretNotFoundException` if the secret is absent. **(2) Deployed hosts — env var:** override directly with `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form) in the NSSM service environment — a plain literal there is used as-is and the store lookup is skipped. **(3) Dev boxes — user-secrets:** `dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>` (the server carries `<UserSecretsId>mxaccessgw-server</UserSecretsId>`; user-secrets load automatically in the Development environment and live under the user profile, outside the tree). The value comes from the GLAuth source of truth `scadaproj/infra/glauth/`, never from a repo file. **Rotation:** because the credential was historically committed, rotating it in `scadaproj/infra/glauth/` (and redeploying the shared GLAuth on `10.100.0.35`) is required — see `docs/runbooks/SEC-36-ldap-credential-rotation.md` for the cutover order. A blank/unresolved value fails startup validation with a message naming the two supported channels. |
|
| `MxGateway:Ldap:ServiceAccountPassword` | `${secret:ldap/mxgateway/bind}` | Search-account password. **Never a committed plaintext value (SEC-36):** the shared GLAuth bind credential is supplied out-of-band through one of three channels, all binding to this key. **(1) Encrypted secrets store (shipped default):** `appsettings.json` ships the reference `${secret:ldap/mxgateway/bind}`, which the pre-host `${secret:}` expander resolves at startup from the encrypted secrets store (the code-side design default is blank, so a missing/unresolved value fails closed rather than falling back to a leaked credential). Seed it once with `secret set ldap/mxgateway/bind <value>` (the store's master key must be present via `ZB_SECRETS_MASTER_KEY`); startup aborts with `SecretNotFoundException` if the secret is absent. **(2) Deployed hosts — env var:** override directly with `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form) in the NSSM service environment — a plain literal there is used as-is and the store lookup is skipped. **(3) Dev boxes — user-secrets:** `dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>` (the server carries `<UserSecretsId>mxaccessgw-server</UserSecretsId>`; user-secrets load automatically in the Development environment and live under the user profile, outside the tree). The value comes from the GLAuth source of truth `scadaproj/infra/glauth/`, never from a repo file. **Rotation:** because the credential was historically committed, it was rotated in `scadaproj/infra/glauth/` (and the shared GLAuth on `10.100.0.35` redeployed) on 2026-08-07 (SEC-36) — see `docs/runbooks/SEC-36-ldap-credential-rotation.md` for the cutover procedure used, and for future rotations. A blank/unresolved value fails startup validation with a message naming the two supported channels. |
|
||||||
| `MxGateway:Ldap:UserNameAttribute` | `cn` | LDAP attribute holding the login user name. |
|
| `MxGateway:Ldap:UserNameAttribute` | `cn` | LDAP attribute holding the login user name. |
|
||||||
| `MxGateway:Ldap:DisplayNameAttribute` | `cn` | LDAP attribute holding the display name. |
|
| `MxGateway:Ldap:DisplayNameAttribute` | `cn` | LDAP attribute holding the display name. |
|
||||||
| `MxGateway:Ldap:GroupAttribute` | `memberOf` | LDAP attribute enumerating group membership (mapped to dashboard roles via `MxGateway:Dashboard:GroupToRole`). |
|
| `MxGateway:Ldap:GroupAttribute` | `memberOf` | LDAP attribute enumerating group membership (mapped to dashboard roles via `MxGateway:Dashboard:GroupToRole`). |
|
||||||
@@ -273,6 +274,14 @@ staging rig, e.g. one pointed at the plaintext shared GLAuth). A production-like
|
|||||||
deployment must therefore run with the literal `Production` environment name for
|
deployment must therefore run with the literal `Production` environment name for
|
||||||
the hard-stops to apply.
|
the hard-stops to apply.
|
||||||
|
|
||||||
|
`windev` (`10.100.0.48`) is deliberately labelled `Staging` (its NSSM
|
||||||
|
`DOTNET_ENVIRONMENT` entry, set 2026-08-07) rather than left at the `Production`
|
||||||
|
default. It is the permissive rig the parenthesis above describes: it runs
|
||||||
|
`Dashboard:DisableLogin=true` and binds the shared GLAuth, which offers no TLS,
|
||||||
|
so a `Production` label would contradict its own configuration and both
|
||||||
|
hard-stops would refuse the boot. Label a host `Production` only when its
|
||||||
|
configuration can satisfy them.
|
||||||
|
|
||||||
## Secrets Master Key
|
## Secrets Master Key
|
||||||
|
|
||||||
`${secret:...}` tokens in configuration — currently just
|
`${secret:...}` tokens in configuration — currently just
|
||||||
|
|||||||
+40
-24
@@ -215,13 +215,21 @@ service described in `glauth.md`.
|
|||||||
|
|
||||||
The suite builds the authenticator with `GatewayOptions.Dashboard.GroupToRole`
|
The suite builds the authenticator with `GatewayOptions.Dashboard.GroupToRole`
|
||||||
set to `{ GwAdmin: Admin }`. `GwAdmin` is the gateway-specific
|
set to `{ GwAdmin: Admin }`. `GwAdmin` is the gateway-specific
|
||||||
dashboard-admin role and is **not** part of the five baseline GLAuth role
|
dashboard-admin role and is **not** part of the baseline GLAuth role
|
||||||
groups — it must be provisioned before the LDAP live tests pass.
|
groups — it must be provisioned before the LDAP live tests pass.
|
||||||
`AuthenticateAsync_AdminInGwAdminGroup_Succeeds` fails (rather than skips)
|
`AuthenticateAsync_AdminInGwAdminGroup_Succeeds` fails (rather than skips)
|
||||||
when GLAuth has only the baseline groups, so this is a hard prerequisite
|
when GLAuth has only the baseline groups, so this is a hard prerequisite
|
||||||
beyond "LDAP is up." See the "Adding a gw-specific group" section of
|
beyond "LDAP is up." The shared directory
|
||||||
`glauth.md` for the provisioning step that adds `GwAdmin` and grants it to
|
(`scadaproj/infra/glauth/config.toml`) already provisions `GwAdmin` (gid 5610)
|
||||||
`admin`.
|
and `GwReader` (gid 5611); see the "Adding a gw-specific group" section of
|
||||||
|
`glauth.md` for the per-box equivalent.
|
||||||
|
|
||||||
|
The fixtures name real users from that shared config, so a run only proves the
|
||||||
|
service-account bind when it targets the shared directory. `appsettings.json`
|
||||||
|
ships `Server=localhost` for the local-forward case, so point the suite at the
|
||||||
|
shared GLAuth with `MxGateway__Ldap__Server=10.100.0.35`; the suite's
|
||||||
|
`AddEnvironmentVariables()` layer applies the override to the same
|
||||||
|
`MxGateway:Ldap` section production binds.
|
||||||
|
|
||||||
`DashboardAuthenticator` delegates the LDAP bind and group search to the shared
|
`DashboardAuthenticator` delegates the LDAP bind and group search to the shared
|
||||||
`ZB.MOM.WW.Auth.Ldap` provider (`LdapAuthService`) and only maps the resulting
|
`ZB.MOM.WW.Auth.Ldap` provider (`LdapAuthService`) and only maps the resulting
|
||||||
@@ -229,12 +237,17 @@ groups to dashboard roles via `DashboardGroupRoleMapper`; the bind/search
|
|||||||
mechanics that decide each outcome live in that shared provider, not in
|
mechanics that decide each outcome live in that shared provider, not in
|
||||||
`DashboardAuthenticator`.
|
`DashboardAuthenticator`.
|
||||||
|
|
||||||
The suite covers both the success path and the failure outcomes: `admin` whose
|
The suite covers both the success path and the failure outcomes: `admin`, whose
|
||||||
LDAP groups resolve to the `Admin` role succeeds and emits the role claim;
|
`othergroups` include `GwAdmin`, succeeds and emits the role claim — this is the
|
||||||
`readonly` is denied because no group in their `memberOf` appears in
|
one test that proves the service-account bind, because every other outcome below
|
||||||
`GroupToRole`; `admin` with a wrong password fails authentication without leaking
|
fails identically whether or not the bind credential is right; `gw-viewer` is
|
||||||
the password into `FailureMessage`; an unknown username fails authentication; and
|
denied because its only group (`GwReader`) is absent from `GroupToRole`, and its
|
||||||
an unreachable LDAP server is absorbed into a failed result rather than throwing.
|
denial message must match the unknown-user denial so an authorization failure
|
||||||
|
cannot be used to enumerate valid accounts; `admin` with a wrong password fails
|
||||||
|
authentication without leaking the password into `FailureMessage`; an unknown
|
||||||
|
username fails authentication; and an unreachable LDAP server is absorbed into a
|
||||||
|
failed result rather than throwing. Both live users bind with the shared dev
|
||||||
|
password documented in `glauth.md`.
|
||||||
|
|
||||||
`appsettings.json` now ships the LDAP bind password as the unexpanded
|
`appsettings.json` now ships the LDAP bind password as the unexpanded
|
||||||
`${secret:ldap/mxgateway/bind}` token (resolved at gateway startup by the
|
`${secret:ldap/mxgateway/bind}` token (resolved at gateway startup by the
|
||||||
@@ -249,6 +262,7 @@ Run the LDAP live tests explicitly:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
$env:MXGATEWAY_RUN_LIVE_LDAP_TESTS = "1"
|
$env:MXGATEWAY_RUN_LIVE_LDAP_TESTS = "1"
|
||||||
|
$env:MxGateway__Ldap__Server = "10.100.0.35"
|
||||||
$env:MxGateway__Ldap__ServiceAccountPassword = "<service-account-password>"
|
$env:MxGateway__Ldap__ServiceAccountPassword = "<service-account-password>"
|
||||||
dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~DashboardLdapLiveTests
|
dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~DashboardLdapLiveTests
|
||||||
```
|
```
|
||||||
@@ -455,20 +469,22 @@ runtime because the x86 Worker cannot build on Linux:
|
|||||||
|
|
||||||
### Runner capacity is shared and finite
|
### Runner capacity is shared and finite
|
||||||
|
|
||||||
All CI runs on a co-located `gitea-runner` container on docker host `10.100.0.35` with
|
CI runs on two co-located runner containers on docker host `10.100.0.35` — `gitea-runner`
|
||||||
`maxParallel=1`, and that runner instance is **shared across repos** — it interleaves
|
(capacity 4) and `gitea-runner-2` (capacity 2, registered 2026-08-07 per
|
||||||
`dohertj2/mxaccessgw` and `dohertj2/lmxopcua` jobs on the single slot rather than being
|
`docs/runbooks/TST-30-second-ci-runner.md`) — and both runner instances are **shared across
|
||||||
scoped to this repo (`GET /repos/dohertj2/mxaccessgw/actions/runners` returns
|
repos**: they interleave `dohertj2/mxaccessgw` and `dohertj2/lmxopcua` jobs across the
|
||||||
`total_count: 0`; the runner is registered at the instance level). Every job in a run
|
combined slots rather than being scoped to this repo (`GET
|
||||||
(`portable`, `java`, `windows-x86`) executes serially, so queue latency is additive within
|
/repos/dohertj2/mxaccessgw/actions/runners` returns `total_count: 0`; both runners are
|
||||||
a run and an active `lmxopcua` run blocks `mxaccessgw` entirely — expect ~20–30 minutes of
|
registered at the instance level). Every job in a run (`portable`, `java`, `windows-x86`)
|
||||||
queue depth under cross-repo contention, not a stuck pipeline. This Gitea version (1.26)
|
still executes serially within that run, so queue latency is additive within a run, but an
|
||||||
also exposes **no run cancel or delete via the API** (`POST .../actions/runs/{id}/cancel`
|
active `lmxopcua` run no longer blocks `mxaccessgw` entirely the way a single shared slot
|
||||||
returns 404, `DELETE .../actions/runs/{id}` returns 400), so a superseded or hung run cannot
|
did — the two runners relieve cross-repo contention. This Gitea version (1.26) also exposes
|
||||||
be cleared and holds the slot until it finishes or times out. See
|
**no run cancel or delete via the API** (`POST .../actions/runs/{id}/cancel` returns 404,
|
||||||
`docs/runbooks/TST-30-second-ci-runner.md` for the operator runbook that registers a second
|
`DELETE .../actions/runs/{id}` returns 400), so a superseded or hung run cannot be cleared
|
||||||
runner to relieve this; until that lands, treat single-runner contention as expected, not a
|
and holds the slot until it finishes or times out — with two runners this means a single
|
||||||
CI outage.
|
wedged run can still hold slots, because the no-cancel reality is unchanged. See
|
||||||
|
`docs/runbooks/TST-30-second-ci-runner.md` for the operator runbook that registered the
|
||||||
|
second runner.
|
||||||
|
|
||||||
When queue depth (or the missing-cancel reality) makes waiting impractical, verify a
|
When queue depth (or the missing-cancel reality) makes waiting impractical, verify a
|
||||||
specific commit out of band instead of waiting behind the queue: run
|
specific commit out of band instead of waiting behind the queue: run
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# WriteSecured Completion Correlation — Design
|
||||||
|
|
||||||
|
Date: 2026-08-09
|
||||||
|
Requested by: OtOpcUa (archreview finding 06/S-1, cross-repo residual — gateway half)
|
||||||
|
Status: approved for implementation (peer contract confirmed over cross-session message)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The unary `Invoke` reply for a `WriteSecured` / `WriteSecured2` command proves worker-side
|
||||||
|
command *acceptance* only. MXAccess writes are fire-and-forget at the toolkit level: the
|
||||||
|
real per-item outcome arrives later in the `OnWriteComplete` COM callback. Today the worker
|
||||||
|
returns `CreateOkReply` immediately after the COM call, so `MxCommandReply.statuses` is
|
||||||
|
always empty and consumers (OtOpcUa's `GatewayGalaxyDataWriter.TranslateReply`) must treat
|
||||||
|
every write as provisionally good (`galaxy.writes.unconfirmed` meter).
|
||||||
|
|
||||||
|
Consumer contract (already shipped OtOpcUa-side): `statuses.Count > 0` → map `statuses[0]`
|
||||||
|
through the MX status map to a real OPC UA StatusCode; empty → provisional Good +
|
||||||
|
unconfirmed meter. The `statuses` field already exists on `MxCommandReply` (field 7); no
|
||||||
|
proto shape change is needed.
|
||||||
|
|
||||||
|
## Approaches Considered
|
||||||
|
|
||||||
|
1. **Executor pump-wait + versioned completion cache (chosen).** After the `WriteSecured`
|
||||||
|
COM call, the executor holds the STA thread but explicitly pumps Windows messages each
|
||||||
|
poll iteration until the matching completion is recorded or a bounded deadline passes.
|
||||||
|
This is exactly the shipped `ReadBulk` pattern (`MxAccessValueCache.TryWaitForUpdate` +
|
||||||
|
`pumpStep` → `StaRuntime.PumpPendingMessages()`), so it adds no new threading model.
|
||||||
|
2. **Parked asynchronous reply.** Executor returns a "pending" marker; the dispatcher parks
|
||||||
|
the correlation and the pipe reply is written later from the event dispatch. Rejected:
|
||||||
|
changes the `IStaCommandExecutor`/dispatcher/pipe contracts for no real gain — commands
|
||||||
|
serialize per session anyway, so freeing the STA during the wait buys nothing.
|
||||||
|
3. **Gateway-side correlation.** Gateway watches the session event stream for the
|
||||||
|
`OnWriteComplete` after the worker reply. Rejected: races the event drain cadence,
|
||||||
|
couples the gateway to event semantics, still holds the unary RPC, and spreads the
|
||||||
|
feature across two processes.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
All changes are worker-side (`ZB.MOM.WW.MxGateway.Worker`, net48 x86). The gateway's
|
||||||
|
`Invoke` already forwards the worker `MxCommandReply` (statuses included) verbatim.
|
||||||
|
|
||||||
|
### New: `MxAccessWriteCompletionCache`
|
||||||
|
|
||||||
|
Mirror of `MxAccessValueCache`, keyed by `(serverHandle, itemHandle)` (packed long), one
|
||||||
|
entry per key holding the most recent completion's `RepeatedField<MxStatusProxy>` (cloned)
|
||||||
|
plus a monotonically increasing per-key `Version`. API:
|
||||||
|
|
||||||
|
- `Record(int serverHandle, int itemHandle, RepeatedField<MxStatusProxy> statuses)`
|
||||||
|
- `ulong CurrentVersion(int serverHandle, int itemHandle)` — 0 when absent
|
||||||
|
- `bool TryWaitForCompletion(int serverHandle, int itemHandle, ulong sinceVersion,
|
||||||
|
DateTime deadlineUtc, Action pumpStep, out RepeatedField<MxStatusProxy> statuses,
|
||||||
|
int pollIntervalMs = 5)` — pump/poll loop identical in shape to
|
||||||
|
`MxAccessValueCache.TryWaitForUpdate`.
|
||||||
|
|
||||||
|
Same locking posture as the value cache: everything runs on the STA thread; a sync root
|
||||||
|
keeps it nominally thread-safe for tests.
|
||||||
|
|
||||||
|
### Sink: record completions
|
||||||
|
|
||||||
|
`MxAccessBaseEventSink` owns a `MxAccessWriteCompletionCache` (new optional ctor param,
|
||||||
|
exposed as a property) and its `OnWriteComplete` handler records into it via the existing
|
||||||
|
`EnqueueEvent` post-publish hook (same pattern as the value cache on `OnDataChange`):
|
||||||
|
the streamed `MxEvent` is built exactly once by the mapper, enqueued unchanged for the
|
||||||
|
event stream, and its `Statuses` are then recorded into the cache. The event stream is
|
||||||
|
not altered — nothing is swallowed or synthesized.
|
||||||
|
|
||||||
|
A new seam interface `IWriteCompletionCacheProvider { MxAccessWriteCompletionCache
|
||||||
|
WriteCompletionCache { get; } }` is implemented by `MxAccessBaseEventSink` and by test
|
||||||
|
sinks. `MxAccessSession.Create` pulls the cache from the sink through that interface
|
||||||
|
(fallback: fresh instance), mirroring the existing `ValueCache` sharing, and exposes it
|
||||||
|
on the session.
|
||||||
|
|
||||||
|
### Executor: bounded pump-wait
|
||||||
|
|
||||||
|
In `ExecuteWriteSecured` / `ExecuteWriteSecured2`:
|
||||||
|
|
||||||
|
1. Capture `baseline = cache.CurrentVersion(serverHandle, itemHandle)` **before** the COM
|
||||||
|
call — this closes the fast-completion ordering edge: a callback that dispatches during
|
||||||
|
or immediately after the COM call bumps the version past the baseline and still
|
||||||
|
correlates.
|
||||||
|
2. Call `session.WriteSecured(...)` as today.
|
||||||
|
3. `cache.TryWaitForCompletion(..., baseline, deadline, pumpStep, out statuses)`; on
|
||||||
|
success, `reply.Statuses.Add(statuses)`; on timeout, return the reply exactly as today
|
||||||
|
(protocol OK, empty statuses) — the consumer's honest-unconfirmed path. No invented
|
||||||
|
failure rows.
|
||||||
|
|
||||||
|
The reply's `ProtocolStatus`/`Hresult` stay untouched by the completion outcome: the
|
||||||
|
command was accepted; the MX outcome (success *or* failure) is carried only in
|
||||||
|
`statuses[0]`. That preserves MXAccess parity (the native API returns void; the outcome
|
||||||
|
exists only in the callback) while enriching the reply with information that was already
|
||||||
|
on the wire.
|
||||||
|
|
||||||
|
Timeout default: **1.5 s** (`MxAccessCommandExecutor.DefaultWriteCompletionTimeout`).
|
||||||
|
The consumer-side budget drives this: OtOpcUa wraps the driver write in a Tier A
|
||||||
|
resilience policy with a 2 s timeout and a 5-failure breaker, so a worker wait longer
|
||||||
|
than 2 s would convert slow-but-successful commits into consumer-side false failures
|
||||||
|
(node revert + breaker pressure). 1.5 s covers the common fast-commit case and degrades
|
||||||
|
a slow commit to the honest-unconfirmed path instead. It also sits well under the
|
||||||
|
gateway's 30 s `DefaultCommandTimeoutSeconds` IPC wait and the STA watchdog's 75 s
|
||||||
|
dispatched-command ceiling.
|
||||||
|
|
||||||
|
The wait is deployment-configurable end to end, following the existing
|
||||||
|
pipe-connect-timeout pattern: a new gateway option
|
||||||
|
`MxGateway:Worker:WriteCompletionWaitMilliseconds` (default `1500`, validated `>= 0`;
|
||||||
|
`0` disables the wait and restores pure fire-and-forget replies) is exported by
|
||||||
|
`WorkerProcessLauncher` as the `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` environment
|
||||||
|
variable, which the worker reads at session construction. A deployment that raises the
|
||||||
|
gateway wait must raise the OtOpcUa driver's Write `ResilienceConfig` timeout in step
|
||||||
|
(per-instance operator config on the OtOpcUa side) — documented in
|
||||||
|
`docs/GatewayConfiguration.md`. `MxAccessStaSession` additionally gets an internal
|
||||||
|
`WriteCompletionTimeout` seam (read when it constructs the executor in `StartAsync`) so
|
||||||
|
tests can shorten it without env plumbing.
|
||||||
|
|
||||||
|
Client cancellation mid-wait needs no new code path, only verification: when the caller
|
||||||
|
cancels the unary RPC, the gateway abandons its IPC reply wait, but the worker command
|
||||||
|
is already in flight — `CancelCommand` only dequeues *queued* commands. The executor
|
||||||
|
simply finishes its bounded wait and replies; the gateway discards the reply. The
|
||||||
|
session is never faulted and the `OnWriteComplete` event still flows on the stream.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- **In**: `WriteSecured`, `WriteSecured2` (single-item secured writes — inherently
|
||||||
|
low-rate operator actions, and exactly the OtOpcUa single-write contract). Default-on.
|
||||||
|
- **Out**: plain `Write`/`Write2` and all bulk write commands stay fire-and-forget —
|
||||||
|
waiting would add a device round-trip of latency to high-rate supervisory write loops.
|
||||||
|
- **Correlation fidelity is best-effort**: the MXAccess callback carries only
|
||||||
|
`(hItem, statuses)` — no transaction id — so a concurrent write to the same item within
|
||||||
|
the wait window can be attributed to the wrong writer (worst case two writes to the
|
||||||
|
same item swap status rows — benign for the serialized single-write consumer
|
||||||
|
contract). Documented on the proto field.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Completion never arrives (device down): bounded 1.5 s wait, then today's reply shape.
|
||||||
|
- Event queue overflow during completion: the queue records a fault and the fail-fast
|
||||||
|
design tears the session down; the post-publish hook not firing in that case is moot.
|
||||||
|
- COM call throws: unchanged — the dispatcher's existing exception path replies with the
|
||||||
|
native HResult; no wait is entered.
|
||||||
|
|
||||||
|
## Testing (Worker.Tests, x86 — verified on windev)
|
||||||
|
|
||||||
|
- `MxAccessWriteCompletionCacheTests`: record/version monotonicity, wait success,
|
||||||
|
deadline expiry, baseline-before-record fast-completion ordering.
|
||||||
|
- `MxAccessBaseEventSinkTests`: `OnWriteComplete` both enqueues the event *and* records
|
||||||
|
the completion; cache instance is the sink-bound one.
|
||||||
|
- `MxAccessCommandExecutorTests` (via `MxAccessStaSession.DispatchAsync` + fake COM
|
||||||
|
object + test sink implementing `IWriteCompletionCacheProvider`):
|
||||||
|
- completion recorded synchronously inside the fake's `WriteSecured` (fast edge) →
|
||||||
|
reply carries `statuses[0]`;
|
||||||
|
- completion recorded from the test thread while the executor pump-waits → reply
|
||||||
|
carries `statuses[0]`;
|
||||||
|
- no completion + shortened timeout → protocol OK, empty statuses;
|
||||||
|
- `WriteSecured2` mirrors; plain `Write` does not wait.
|
||||||
|
- Gateway tests (macOS-runnable): `GatewayOptionsValidator` accepts `>= 0` and rejects
|
||||||
|
negative `WriteCompletionWaitMilliseconds`; `WorkerProcessLauncher` exports
|
||||||
|
`MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` from the option (mirroring the existing
|
||||||
|
pipe-connect-timeout env assertion).
|
||||||
|
|
||||||
|
## Docs in the same change
|
||||||
|
|
||||||
|
- `mxaccess_gateway.proto`: comment on `MxCommandReply.statuses` and the
|
||||||
|
`WriteSecuredCommand`/`WriteSecured2Command` messages describing the correlated
|
||||||
|
completion contract (populated within the wait window; empty = unconfirmed;
|
||||||
|
best-effort correlation). Comment-only → wire-identical; regenerate + commit
|
||||||
|
`Contracts/Generated` (required for the C# build); other clients' generated code is
|
||||||
|
functionally unchanged.
|
||||||
|
- `docs/GatewayConfiguration.md`: the new `MxGateway:Worker:WriteCompletionWaitMilliseconds`
|
||||||
|
option, including the pairing rule with the OtOpcUa driver's Write resilience timeout.
|
||||||
|
- `gateway.md` command/event surface note; `docs/DesignDecisions.md` entry.
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-08-09-write-completion-correlation.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 0, "subject": "Task 0: Create feature branch", "status": "pending"},
|
||||||
|
{"id": 1, "subject": "Task 1: Proto contract comments + regen", "status": "pending", "blockedBy": [0]},
|
||||||
|
{"id": 2, "subject": "Task 2: MxAccessWriteCompletionCache + tests", "status": "pending", "blockedBy": [1]},
|
||||||
|
{"id": 3, "subject": "Task 3: Sink records completions (+ provider seam)", "status": "pending", "blockedBy": [2]},
|
||||||
|
{"id": 4, "subject": "Task 4: MxAccessSession plumbing", "status": "pending", "blockedBy": [3]},
|
||||||
|
{"id": 5, "subject": "Task 5: Executor bounded wait + StaSession env plumbing", "status": "pending", "blockedBy": [4]},
|
||||||
|
{"id": 6, "subject": "Task 6: Executor tests", "status": "pending", "blockedBy": [5]},
|
||||||
|
{"id": 7, "subject": "Task 7: Gateway config option + launcher env var", "status": "pending", "blockedBy": [0]},
|
||||||
|
{"id": 8, "subject": "Task 8: Docs", "status": "pending", "blockedBy": [6, 7]},
|
||||||
|
{"id": 9, "subject": "Task 9: Local + windev verification", "status": "pending", "blockedBy": [8]},
|
||||||
|
{"id": 10, "subject": "Task 10: Review, merge, notify", "status": "pending", "blockedBy": [9]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-08-09"
|
||||||
|
}
|
||||||
@@ -1,5 +1,57 @@
|
|||||||
# SEC-36 — LDAP Service-Account Credential Rotation (Operator Runbook)
|
# SEC-36 — LDAP Service-Account Credential Rotation (Operator Runbook)
|
||||||
|
|
||||||
|
> **Executed 2026-08-07 — the rotation is done; this runbook is now history plus the four
|
||||||
|
> corrections below.** A new service-account password was generated, `scadaproj/infra/glauth/config.toml`'s
|
||||||
|
> `serviceaccount` `passsha256` was replaced and the shared GLAuth recreated, and the old value
|
||||||
|
> (the literal this repo committed, live in the directory since 2026-06-04) no longer binds. The new
|
||||||
|
> value now exists only in the
|
||||||
|
> three channels this runbook names: the GLAuth `passsha256` (committed in `scadaproj`), the NSSM
|
||||||
|
> service environment on `10.100.0.48`, and this dev Mac's user-secrets. The retired plaintext was
|
||||||
|
> also scrubbed from `scadaproj/infra/glauth/`'s `config.toml`/`docker-compose.yml`/`README.md`
|
||||||
|
> comments and from the host's live `docker-compose.yml` (the `*.bak-sec36` backups on the host still
|
||||||
|
> carry it, deliberately — they are the rollback artifacts).
|
||||||
|
>
|
||||||
|
> **Correction 1 — host paths in step 3 were stale.** The runbook says
|
||||||
|
> `cd ~/Desktop/scadaproj/infra/glauth` on `10.100.0.35`. That directory does not exist there:
|
||||||
|
> `scadaproj` is a dev-workstation checkout, and the docker host runs the stack from
|
||||||
|
> **`/home/dohertj2/zb-glauth`** (container **`zb-shared-glauth`**, project name `zb-shared-glauth`).
|
||||||
|
> The repo remains the source of truth; deployment is the `scp` of `config.toml`/`docker-compose.yml`
|
||||||
|
> into `~/zb-glauth` documented in `scadaproj/infra/glauth/README.md`, followed by
|
||||||
|
> `docker compose up -d --force-recreate` there.
|
||||||
|
>
|
||||||
|
> **Correction 2 — `wonder-app-vd03` is out of scope, on documentary evidence.** The precondition
|
||||||
|
> above says to check `MxGateway:Ldap:Enabled` on that host. It could not be checked directly (the
|
||||||
|
> host is unreachable from the dev network), but it is out of scope regardless: its gateway binds a
|
||||||
|
> **different directory** — the ScadaBridge/ScadaLink local GLAuth under `dc=scadalink`/`dc=scadabridge`,
|
||||||
|
> not `dc=zb,dc=local` — so this credential is not one it can hold. No env var was staged there and
|
||||||
|
> none is needed.
|
||||||
|
>
|
||||||
|
> **Correction 3 — step 4's dashboard verification is deferred on `10.100.0.48`; a direct bind was
|
||||||
|
> used instead.** The NEW value **is** staged on windev (added as the 10th `AppEnvironmentExtra`
|
||||||
|
> entry on the `MxAccessGw` NSSM service), but dashboard `/login` could not exercise it at rotation
|
||||||
|
> time: windev's gateway was **crash-looping on a pre-existing, unrelated fault** — the deployed Server binary
|
||||||
|
> (2026-06-25) predates the auth-DB migration of 2026-07-15, so it opens a schema-version-3 database
|
||||||
|
> it only supports at version 2 and aborts at startup (~10k Hosting-failed events/day since at least
|
||||||
|
> 08-06). That was a stale-deployment problem, not a rotation problem; it was filed as next-cycle
|
||||||
|
> finding NEXT-07 and resolved by redeploy on 2026-08-07. **Verification used instead:** a direct `ldapsearch` bind as
|
||||||
|
> `cn=serviceaccount,dc=zb,dc=local` with the new value against `10.100.0.35:3893` succeeded and
|
||||||
|
> returned the `multi-role` entry — which is precisely the search bind the dashboard performs, minus
|
||||||
|
> the HTTP shell. **The deferred check was completed 2026-08-07**, once windev was repaired by the
|
||||||
|
> redeploy filed under NEXT-07. With `Dashboard:DisableLogin=false` supplied as a process-env-only
|
||||||
|
> override on a foreground run of the new build, `GET /login` returned 200 with an antiforgery token,
|
||||||
|
> `POST /auth/login` as `multi-role`/`password` returned 302 to `/` with a `MxGatewayDashboard`
|
||||||
|
> cookie, the authenticated `GET /` rendered the admin nav, and an anonymous control redirected to
|
||||||
|
> `/login?ReturnUrl=%2F` — so the rotated credential is proven through the real
|
||||||
|
> `DashboardAuthenticator` search-bind path on the deployed host. As deployed, windev keeps
|
||||||
|
> `DisableLogin=true`, so routine operation there does not exercise LDAP; the standing regression
|
||||||
|
> proof is `DashboardLdapLiveTests` (5/5 green against `10.100.0.35` since commit `de67b45`).
|
||||||
|
>
|
||||||
|
> **Correction 4 — the lockout caution under "Verifying the rotation" is inert for this instance.**
|
||||||
|
> It warns that GLAuth's 3-fail / 10-minute per-IP lockout can lock the whole office when testing
|
||||||
|
> that the old value is dead. This GLAuth runs `LimitFailedBinds = false` (`config.toml:14`), so no
|
||||||
|
> failed-bind limiter is active and the caution does not apply here. Keep the caution for any
|
||||||
|
> instance that enables the limiter.
|
||||||
|
|
||||||
Operator steps to rotate the shared GLAuth service-account password after the repo-side
|
Operator steps to rotate the shared GLAuth service-account password after the repo-side
|
||||||
removal landed (SEC-36). The repo change (removal of the committed value, the two supported
|
removal landed (SEC-36). The repo change (removal of the committed value, the two supported
|
||||||
secret channels, and this runbook) is already merged; the live rotation below is the
|
secret channels, and this runbook) is already merged; the live rotation below is the
|
||||||
|
|||||||
@@ -1,5 +1,40 @@
|
|||||||
# TST-30 — Register A Second CI Runner (Operator Runbook)
|
# TST-30 — Register A Second CI Runner (Operator Runbook)
|
||||||
|
|
||||||
|
> **Executed 2026-08-07 — option (a) shipped; this runbook is now history plus the one
|
||||||
|
> correction below.** `gitea-runner-2` (runner id 5, capacity 2, labels `ubuntu-latest`/
|
||||||
|
> `ubuntu-22.04`) runs on `10.100.0.35` from the `/opt/gitea` compose stack with the same
|
||||||
|
> `container.network: traefik` setting as the original; its registration token is mounted from a
|
||||||
|
> `0600` file rather than inlined in compose. The existing `gitea-runner` (id 1, capacity 4) was
|
||||||
|
> left untouched, so capacity went 4 → 6 by addition and the change reverts by removing one
|
||||||
|
> container. Concurrency was verified by pushing HEAD to two scratch branches while an unrelated
|
||||||
|
> run was in flight: jobs from three runs ran simultaneously across both runners, and a
|
||||||
|
> `gitea-runner-2` job cloned successfully from `http://gitea:3000` (the property option (c) was
|
||||||
|
> rejected for losing).
|
||||||
|
>
|
||||||
|
> **Correction to the Verification and Done-criteria sections below:** they expect
|
||||||
|
> `GET /repos/dohertj2/mxaccessgw/actions/runners` to show ≥2 runners. It does not — it still
|
||||||
|
> returns `total_count: 0`, correctly, because both runners are registered at the **instance**
|
||||||
|
> level, which is the very condition the "Why" section describes. Use
|
||||||
|
> `GET /api/v1/admin/actions/runners` instead (it now lists only id 1 and id 5; id 4 was removed
|
||||||
|
> 2026-08-07 — a local macOS `act_runner` mislabelled `ubuntu-latest`/`ubuntu-22.04`/`ubuntu-20.04`,
|
||||||
|
> so it captured Linux-labelled jobs it had no Docker daemon to run and failed them. Its config and
|
||||||
|
> registration are kept disabled at `~/gitea-act-runner.disabled-2026-08-07` (launchd plist at
|
||||||
|
> `~/Library/LaunchAgents/com.dohertj2.gitea-act-runner.plist.disabled-2026-08-07`) so it can be
|
||||||
|
> re-registered with mac-specific labels if a mac-only job ever needs one). For per-job runner
|
||||||
|
> attribution, `GET /repos/{owner}/{repo}/actions/runs/{id}/jobs`
|
||||||
|
> exposes `runner_id`/`runner_name` on each job; the `actions/tasks` listing does not.
|
||||||
|
>
|
||||||
|
> **Follow-up 2026-08-07 — token hygiene on the host.** `gitea-runner` (id 1) now takes its
|
||||||
|
> registration token from the same `0600` file mount runner-2 uses instead of an inline plaintext
|
||||||
|
> value in compose, and `/opt/gitea/docker-compose.yml` plus both `.bak` copies are `0600 root:root`;
|
||||||
|
> runner-1 was recreated alone and kept its identity (`.runner` byte-identical). Both runners share
|
||||||
|
> **one instance-scope registration token**, which was world-readable for roughly five months and is
|
||||||
|
> still live — a probe registered runner id 6 with it, then deleted it. Gitea 1.26.4 cannot rotate
|
||||||
|
> that token from the CLI or the API (both endpoints are get-or-create and return the same value),
|
||||||
|
> so **the reset is a pending operator action in the admin web UI** ("Reset registration token").
|
||||||
|
> After the reset, refresh `/opt/gitea/runner_token` with the new value and shred the two
|
||||||
|
> token-bearing compose backups, which are the last copies of the old one.
|
||||||
|
|
||||||
Operator steps to relieve the single shared Gitea Actions runner that CI depends on. The
|
Operator steps to relieve the single shared Gitea Actions runner that CI depends on. The
|
||||||
repo-side half of TST-30 (documenting the shared-runner/no-cancel reality and the
|
repo-side half of TST-30 (documenting the shared-runner/no-cancel reality and the
|
||||||
`run-windev-ci.sh` bypass) is already landed in `docs/GatewayTesting.md`; registering the
|
`run-windev-ci.sh` bypass) is already landed in `docs/GatewayTesting.md`; registering the
|
||||||
|
|||||||
+18
@@ -431,6 +431,24 @@ Core commands:
|
|||||||
- `AuthenticateUser`
|
- `AuthenticateUser`
|
||||||
- `ArchestrAUserToId`
|
- `ArchestrAUserToId`
|
||||||
|
|
||||||
|
**Write completion correlation.** MXAccess writes are fire-and-forget
|
||||||
|
at the toolkit level — the per-item outcome only exists in the later
|
||||||
|
`OnWriteComplete` callback. For the unary write kinds — `Write`, `Write2`,
|
||||||
|
`WriteSecured`, `WriteSecured2` — the worker
|
||||||
|
therefore holds the unary reply for a bounded window
|
||||||
|
(`MxGateway:Worker:WriteCompletionWaitMilliseconds`, default 1.5 s, `0`
|
||||||
|
disables) and, when the matching callback arrives, copies its status rows onto
|
||||||
|
`MxCommandReply.statuses` — the reply then proves the MXAccess commit, not just
|
||||||
|
command acceptance. `protocol_status`/`hresult` keep describing acceptance
|
||||||
|
only; a real MXAccess write failure surfaces in `statuses[0]`, and a reply with
|
||||||
|
empty `statuses` means unconfirmed (the callback missed the window), never
|
||||||
|
failed. The `OnWriteComplete` event still flows on the event stream unchanged.
|
||||||
|
Correlation is best-effort per `(server_handle, item_handle)` — the callback
|
||||||
|
carries no transaction id, so concurrent writes to the same item within the
|
||||||
|
window can swap rows. The bulk write commands stay
|
||||||
|
fire-and-forget: waiting per entry would add a device round-trip of latency to
|
||||||
|
high-rate supervisory write loops.
|
||||||
|
|
||||||
Bulk variants (single gRPC round-trip carries the full list, the worker
|
Bulk variants (single gRPC round-trip carries the full list, the worker
|
||||||
runs the per-item MXAccess calls sequentially on its STA, and the reply
|
runs the per-item MXAccess calls sequentially on its STA, and the reply
|
||||||
returns one result per requested entry — per-entry failures populate
|
returns one result per requested entry — per-entry failures populate
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ gw-specific role.
|
|||||||
> it out-of-band (encrypted secrets store reference `${secret:ldap/mxgateway/bind}`, the
|
> it out-of-band (encrypted secrets store reference `${secret:ldap/mxgateway/bind}`, the
|
||||||
> `MxGateway__Ldap__ServiceAccountPassword` env var on deployed hosts, or `dotnet user-secrets`
|
> `MxGateway__Ldap__ServiceAccountPassword` env var on deployed hosts, or `dotnet user-secrets`
|
||||||
> on dev boxes — see `docs/GatewayConfiguration.md`). The credential was historically committed
|
> on dev boxes — see `docs/GatewayConfiguration.md`). The credential was historically committed
|
||||||
> to this repo (and remains recoverable from git history), so **rotation is required**; the
|
> to this repo (and remains recoverable from git history); it **was rotated on 2026-08-07**
|
||||||
> operator runbook is `docs/runbooks/SEC-36-ldap-credential-rotation.md`.
|
> (SEC-36, executed per `docs/runbooks/SEC-36-ldap-credential-rotation.md`) and the old
|
||||||
|
> committed value no longer binds.
|
||||||
|
|
||||||
## Pre-existing groups (LmxOpcUa role taxonomy)
|
## Pre-existing groups (LmxOpcUa role taxonomy)
|
||||||
|
|
||||||
|
|||||||
@@ -8012,6 +8012,11 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
/// outcome when it arrives within the worker's bounded wait — see
|
||||||
|
/// MxCommandReply.statuses.
|
||||||
|
/// </summary>
|
||||||
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
|
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
|
||||||
public sealed partial class WriteCommand : pb::IMessage<WriteCommand>
|
public sealed partial class WriteCommand : pb::IMessage<WriteCommand>
|
||||||
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
||||||
@@ -8330,6 +8335,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same statuses correlation as WriteCommand.
|
||||||
|
/// </summary>
|
||||||
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
|
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
|
||||||
public sealed partial class Write2Command : pb::IMessage<Write2Command>
|
public sealed partial class Write2Command : pb::IMessage<Write2Command>
|
||||||
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
||||||
@@ -8694,6 +8702,11 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
/// outcome when it arrives within the worker's bounded wait — see
|
||||||
|
/// MxCommandReply.statuses.
|
||||||
|
/// </summary>
|
||||||
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
|
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
|
||||||
public sealed partial class WriteSecuredCommand : pb::IMessage<WriteSecuredCommand>
|
public sealed partial class WriteSecuredCommand : pb::IMessage<WriteSecuredCommand>
|
||||||
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
||||||
@@ -9053,6 +9066,11 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
/// outcome when it arrives within the worker's bounded wait — see
|
||||||
|
/// MxCommandReply.statuses.
|
||||||
|
/// </summary>
|
||||||
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
|
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
|
||||||
public sealed partial class WriteSecured2Command : pb::IMessage<WriteSecured2Command>
|
public sealed partial class WriteSecured2Command : pb::IMessage<WriteSecured2Command>
|
||||||
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
||||||
@@ -17204,6 +17222,21 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
|||||||
private static readonly pb::FieldCodec<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy> _repeated_statuses_codec
|
private static readonly pb::FieldCodec<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy> _repeated_statuses_codec
|
||||||
= pb::FieldCodec.ForMessage(58, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy.Parser);
|
= pb::FieldCodec.ForMessage(58, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy.Parser);
|
||||||
private readonly pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy> statuses_ = new pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy>();
|
private readonly pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy> statuses_ = new pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy>();
|
||||||
|
/// <summary>
|
||||||
|
/// Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
/// field as before.
|
||||||
|
/// </summary>
|
||||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||||
public pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy> Statuses {
|
public pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy> Statuses {
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ message ActivateCommand {
|
|||||||
int32 item_handle = 2;
|
int32 item_handle = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
// outcome when it arrives within the worker's bounded wait — see
|
||||||
|
// MxCommandReply.statuses.
|
||||||
message WriteCommand {
|
message WriteCommand {
|
||||||
int32 server_handle = 1;
|
int32 server_handle = 1;
|
||||||
int32 item_handle = 2;
|
int32 item_handle = 2;
|
||||||
@@ -248,6 +251,7 @@ message WriteCommand {
|
|||||||
int32 user_id = 4;
|
int32 user_id = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same statuses correlation as WriteCommand.
|
||||||
message Write2Command {
|
message Write2Command {
|
||||||
int32 server_handle = 1;
|
int32 server_handle = 1;
|
||||||
int32 item_handle = 2;
|
int32 item_handle = 2;
|
||||||
@@ -256,6 +260,9 @@ message Write2Command {
|
|||||||
int32 user_id = 5;
|
int32 user_id = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
// outcome when it arrives within the worker's bounded wait — see
|
||||||
|
// MxCommandReply.statuses.
|
||||||
message WriteSecuredCommand {
|
message WriteSecuredCommand {
|
||||||
int32 server_handle = 1;
|
int32 server_handle = 1;
|
||||||
int32 item_handle = 2;
|
int32 item_handle = 2;
|
||||||
@@ -266,6 +273,9 @@ message WriteSecuredCommand {
|
|||||||
MxValue value = 5;
|
MxValue value = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||||
|
// outcome when it arrives within the worker's bounded wait — see
|
||||||
|
// MxCommandReply.statuses.
|
||||||
message WriteSecured2Command {
|
message WriteSecured2Command {
|
||||||
int32 server_handle = 1;
|
int32 server_handle = 1;
|
||||||
int32 item_handle = 2;
|
int32 item_handle = 2;
|
||||||
@@ -525,6 +535,19 @@ message MxCommandReply {
|
|||||||
// transport failures.
|
// transport failures.
|
||||||
optional int32 hresult = 5;
|
optional int32 hresult = 5;
|
||||||
MxValue return_value = 6;
|
MxValue return_value = 6;
|
||||||
|
// Correlated per-item outcome rows. For WRITE / WRITE2 / 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. Bulk write kinds and all non-write kinds leave this
|
||||||
|
// field as before.
|
||||||
repeated MxStatusProxy statuses = 7;
|
repeated MxStatusProxy statuses = 7;
|
||||||
string diagnostic_message = 8;
|
string diagnostic_message = 8;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,19 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests;
|
|||||||
[Trait("Category", "LiveLdap")]
|
[Trait("Category", "LiveLdap")]
|
||||||
public sealed class DashboardLdapLiveTests
|
public sealed class DashboardLdapLiveTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary>
|
/// <summary>
|
||||||
|
/// The shared dev/test directory issues every human tester the same well-known password, so
|
||||||
|
/// the fixtures name it once rather than repeating a literal that drifts per test. This is a
|
||||||
|
/// published dev credential (see <c>glauth.md</c> and <c>scadaproj/infra/glauth/config.toml</c>),
|
||||||
|
/// not a secret — unlike the service-account bind password, which is never in source and must
|
||||||
|
/// arrive via <c>MxGateway__Ldap__ServiceAccountPassword</c>.
|
||||||
|
/// </summary>
|
||||||
|
private const string SharedDirectoryPassword = "password";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that <c>admin</c> — a shared-directory user whose <c>othergroups</c> include
|
||||||
|
/// GwAdmin (gid 5610) — authenticates successfully and is granted the Admin dashboard role.
|
||||||
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
|
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
|
||||||
@@ -23,7 +35,7 @@ public sealed class DashboardLdapLiveTests
|
|||||||
|
|
||||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
"admin",
|
"admin",
|
||||||
"admin123",
|
SharedDirectoryPassword,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.True(result.Succeeded);
|
Assert.True(result.Succeeded);
|
||||||
@@ -38,21 +50,43 @@ public sealed class DashboardLdapLiveTests
|
|||||||
&& claim.Value == DashboardRoles.Admin);
|
&& claim.Value == DashboardRoles.Admin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary>
|
/// <summary>
|
||||||
|
/// Verifies that <c>gw-viewer</c> — a shared-directory user whose only group is GwReader
|
||||||
|
/// (gid 5611), which this suite's GroupToRole map deliberately leaves unmapped — is denied
|
||||||
|
/// even though its bind succeeds, and that the denial is indistinguishable from the
|
||||||
|
/// unknown-user denial.
|
||||||
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails()
|
public async Task AuthenticateAsync_ViewerMissingGwAdminGroup_FailsIndistinguishably()
|
||||||
{
|
{
|
||||||
DashboardAuthenticator authenticator = CreateAuthenticator();
|
DashboardAuthenticator authenticator = CreateAuthenticator();
|
||||||
|
|
||||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
"readonly",
|
"gw-viewer",
|
||||||
"readonly123",
|
SharedDirectoryPassword,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(result.Succeeded);
|
Assert.False(result.Succeeded);
|
||||||
Assert.Null(result.Principal);
|
Assert.Null(result.Principal);
|
||||||
Assert.DoesNotContain("readonly123", result.FailureMessage, StringComparison.Ordinal);
|
|
||||||
|
// This test used to assert the failure message did not echo the credential literal.
|
||||||
|
// That check cannot survive the move to the shared directory: the real password is the
|
||||||
|
// word "password", which legitimately occurs in the generic denial text ("The username
|
||||||
|
// or password is invalid, ..."), so the assertion would fail for the wrong reason. The
|
||||||
|
// no-leak property is still covered — with a distinctive literal — by
|
||||||
|
// AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword below. What is
|
||||||
|
// asserted here instead is the property this fixture is actually uniquely able to prove:
|
||||||
|
// an authorization failure (valid credentials, no mapped role) must be reported with the
|
||||||
|
// same message as an authentication failure, so the response cannot be used to enumerate
|
||||||
|
// valid accounts.
|
||||||
|
DashboardAuthenticationResult unknownUserResult = await authenticator.AuthenticateAsync(
|
||||||
|
"no-such-user-9f3c1",
|
||||||
|
"irrelevant-password",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(result.FailureMessage));
|
||||||
|
Assert.Equal(unknownUserResult.FailureMessage, result.FailureMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
|
/// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
|
||||||
@@ -98,9 +132,11 @@ public sealed class DashboardLdapLiveTests
|
|||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
||||||
{
|
{
|
||||||
// Exercises the connect-failure path: a closed loopback port produces a
|
// Exercises the connect-failure path: overriding only the port keeps whatever host
|
||||||
// connection error that the shared LdapAuthService must absorb into a Fail
|
// the run targets (localhost by default, the shared GLAuth under the
|
||||||
// result rather than propagating an exception to the dashboard.
|
// MxGateway__Ldap__Server override) while pointing at a port nothing listens on, so
|
||||||
|
// the connection error the shared LdapAuthService must absorb into a Fail result —
|
||||||
|
// rather than propagate as an exception to the dashboard — is reproduced either way.
|
||||||
DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with
|
DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with
|
||||||
{
|
{
|
||||||
// 1 is a reserved port number that no LDAP server listens on.
|
// 1 is a reserved port number that no LDAP server listens on.
|
||||||
@@ -109,7 +145,7 @@ public sealed class DashboardLdapLiveTests
|
|||||||
|
|
||||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
"admin",
|
"admin",
|
||||||
"admin123",
|
SharedDirectoryPassword,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(result.Succeeded);
|
Assert.False(result.Succeeded);
|
||||||
@@ -147,8 +183,10 @@ public sealed class DashboardLdapLiveTests
|
|||||||
/// <see cref="LibraryLdapOptions.ConnectionTimeoutMs"/>, which governs the
|
/// <see cref="LibraryLdapOptions.ConnectionTimeoutMs"/>, which governs the
|
||||||
/// unreachable-server test's timing) at whatever value the operator configured, and
|
/// unreachable-server test's timing) at whatever value the operator configured, and
|
||||||
/// cannot silently drop a field added to the shared type. The gateway's
|
/// cannot silently drop a field added to the shared type. The gateway's
|
||||||
/// <c>appsettings.json</c> seeds the dev directory connection (localhost:3893,
|
/// <c>appsettings.json</c> seeds the dev directory connection (port 3893, plaintext,
|
||||||
/// plaintext, AllowInsecure).
|
/// AllowInsecure) but ships <c>Server=localhost</c>, so a run against the shared GLAuth
|
||||||
|
/// needs the <c>MxGateway__Ldap__Server=10.100.0.35</c> environment override that the
|
||||||
|
/// <c>AddEnvironmentVariables()</c> layer below applies.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static LibraryLdapOptions LibraryOptions()
|
private static LibraryLdapOptions LibraryOptions()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -225,6 +225,10 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
|||||||
options.PipeConnectAttemptTimeoutMilliseconds,
|
options.PipeConnectAttemptTimeoutMilliseconds,
|
||||||
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
|
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
|
||||||
builder);
|
builder);
|
||||||
|
if (options.WriteCompletionWaitMilliseconds < 0)
|
||||||
|
{
|
||||||
|
builder.Add("MxGateway:Worker:WriteCompletionWaitMilliseconds must be greater than or equal to zero.");
|
||||||
|
}
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.ShutdownTimeoutSeconds,
|
options.ShutdownTimeoutSeconds,
|
||||||
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
|
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ public sealed class WorkerOptions
|
|||||||
/// <summary>The timeout in milliseconds for connecting to the worker pipe.</summary>
|
/// <summary>The timeout in milliseconds for connecting to the worker pipe.</summary>
|
||||||
public int PipeConnectAttemptTimeoutMilliseconds { get; init; } = 2000;
|
public int PipeConnectAttemptTimeoutMilliseconds { get; init; } = 2000;
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
|
||||||
/// <summary>The maximum time in seconds for graceful shutdown.</summary>
|
/// <summary>The maximum time in seconds for graceful shutdown.</summary>
|
||||||
public int ShutdownTimeoutSeconds { get; init; } = 10;
|
public int ShutdownTimeoutSeconds { get; init; } = 10;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
|
|||||||
public const string WorkerPipeConnectAttemptTimeoutEnvironmentVariableName =
|
public const string WorkerPipeConnectAttemptTimeoutEnvironmentVariableName =
|
||||||
"MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS";
|
"MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Conveys MxGateway:Worker:WriteCompletionWaitMilliseconds to the worker:
|
||||||
|
/// the bounded wait for the OnWriteComplete callback on
|
||||||
|
/// WriteSecured/WriteSecured2 replies. 0 disables the wait.
|
||||||
|
/// </summary>
|
||||||
|
public const string WorkerWriteCompletionWaitEnvironmentVariableName =
|
||||||
|
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
|
||||||
|
|
||||||
private readonly IWorkerProcessFactory _processFactory;
|
private readonly IWorkerProcessFactory _processFactory;
|
||||||
private readonly IWorkerStartupProbe _startupProbe;
|
private readonly IWorkerStartupProbe _startupProbe;
|
||||||
private readonly GatewayMetrics _metrics;
|
private readonly GatewayMetrics _metrics;
|
||||||
@@ -175,6 +183,8 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
|
|||||||
startInfo.Environment[WorkerNonceEnvironmentVariableName] = request.Nonce;
|
startInfo.Environment[WorkerNonceEnvironmentVariableName] = request.Nonce;
|
||||||
startInfo.Environment[WorkerPipeConnectAttemptTimeoutEnvironmentVariableName] =
|
startInfo.Environment[WorkerPipeConnectAttemptTimeoutEnvironmentVariableName] =
|
||||||
_workerOptions.PipeConnectAttemptTimeoutMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
_workerOptions.PipeConnectAttemptTimeoutMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
startInfo.Environment[WorkerWriteCompletionWaitEnvironmentVariableName] =
|
||||||
|
_workerOptions.WriteCompletionWaitMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
commandLine = new WorkerProcessCommandLine(executablePath, arguments);
|
commandLine = new WorkerProcessCommandLine(executablePath, arguments);
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public sealed class GatewayOptionsTests
|
|||||||
Assert.Equal(3, options.Worker.StartupProbeRetryAttempts);
|
Assert.Equal(3, options.Worker.StartupProbeRetryAttempts);
|
||||||
Assert.Equal(250, options.Worker.StartupProbeRetryDelayMilliseconds);
|
Assert.Equal(250, options.Worker.StartupProbeRetryDelayMilliseconds);
|
||||||
Assert.Equal(2000, options.Worker.PipeConnectAttemptTimeoutMilliseconds);
|
Assert.Equal(2000, options.Worker.PipeConnectAttemptTimeoutMilliseconds);
|
||||||
|
Assert.Equal(1500, options.Worker.WriteCompletionWaitMilliseconds);
|
||||||
Assert.Equal(10, options.Worker.ShutdownTimeoutSeconds);
|
Assert.Equal(10, options.Worker.ShutdownTimeoutSeconds);
|
||||||
Assert.Equal(5, options.Worker.HeartbeatIntervalSeconds);
|
Assert.Equal(5, options.Worker.HeartbeatIntervalSeconds);
|
||||||
Assert.Equal(15, options.Worker.HeartbeatGraceSeconds);
|
Assert.Equal(15, options.Worker.HeartbeatGraceSeconds);
|
||||||
@@ -106,6 +107,7 @@ public sealed class GatewayOptionsTests
|
|||||||
[InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")]
|
[InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")]
|
||||||
[InlineData("MxGateway:Worker:StartupProbeRetryAttempts", "0", "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.")]
|
[InlineData("MxGateway:Worker:StartupProbeRetryAttempts", "0", "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.")]
|
||||||
[InlineData("MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds", "0", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.")]
|
[InlineData("MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds", "0", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.")]
|
||||||
|
[InlineData("MxGateway:Worker:WriteCompletionWaitMilliseconds", "-1", "MxGateway:Worker:WriteCompletionWaitMilliseconds must be greater than or equal to zero.")]
|
||||||
[InlineData("MxGateway:Sessions:DefaultLeaseSeconds", "0", "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.")]
|
[InlineData("MxGateway:Sessions:DefaultLeaseSeconds", "0", "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.")]
|
||||||
[InlineData("MxGateway:Sessions:LeaseSweepIntervalSeconds", "0", "MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.")]
|
[InlineData("MxGateway:Sessions:LeaseSweepIntervalSeconds", "0", "MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.")]
|
||||||
[InlineData("MxGateway:Sessions:DetachGraceSeconds", "-1", "MxGateway:Sessions:DetachGraceSeconds must be zero or greater (0 disables detach-grace retention).")]
|
[InlineData("MxGateway:Sessions:DetachGraceSeconds", "-1", "MxGateway:Sessions:DetachGraceSeconds must be zero or greater (0 disables detach-grace retention).")]
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ public sealed class WorkerProcessLauncherTests
|
|||||||
"2000",
|
"2000",
|
||||||
processFactory.LastStartInfo.Environment[
|
processFactory.LastStartInfo.Environment[
|
||||||
WorkerProcessLauncher.WorkerPipeConnectAttemptTimeoutEnvironmentVariableName]);
|
WorkerProcessLauncher.WorkerPipeConnectAttemptTimeoutEnvironmentVariableName]);
|
||||||
|
Assert.Equal(
|
||||||
|
"1500",
|
||||||
|
processFactory.LastStartInfo.Environment[
|
||||||
|
WorkerProcessLauncher.WorkerWriteCompletionWaitEnvironmentVariableName]);
|
||||||
Assert.DoesNotContain(Nonce, handle.CommandLine.ToString(), StringComparison.Ordinal);
|
Assert.DoesNotContain(Nonce, handle.CommandLine.ToString(), StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain(Nonce, string.Join(" ", handle.CommandLine.Arguments), StringComparison.Ordinal);
|
Assert.DoesNotContain(Nonce, string.Join(" ", handle.CommandLine.Arguments), StringComparison.Ordinal);
|
||||||
Assert.False(pipeReservation.DisposeCalled);
|
Assert.False(pipeReservation.DisposeCalled);
|
||||||
|
|||||||
@@ -110,6 +110,47 @@ public sealed class MxAccessBaseEventSinkTests
|
|||||||
Assert.Same(cache, sink.ValueCache);
|
Assert.Same(cache, sink.ValueCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that an OnWriteComplete COM callback records the completion into
|
||||||
|
/// the per-session write-completion cache for the executor's bounded reply
|
||||||
|
/// wait AND still enqueues the event unchanged for the outbound stream —
|
||||||
|
/// correlation observes the event, it never consumes it. The cache update
|
||||||
|
/// fires only after the event has cleared the queue (post-publish rule).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void OnWriteComplete_ComCallback_RecordsCompletionAndStillEnqueuesEvent()
|
||||||
|
{
|
||||||
|
MxAccessEventQueue queue = new();
|
||||||
|
MxAccessWriteCompletionCache completionCache = new();
|
||||||
|
MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper(), new MxAccessValueCache(), completionCache);
|
||||||
|
MXSTATUS_PROXY[] statuses = Array.Empty<MXSTATUS_PROXY>();
|
||||||
|
|
||||||
|
sink.OnWriteComplete(hLMXServerHandle: 7, phItemHandle: 21, ref statuses);
|
||||||
|
|
||||||
|
Assert.Equal(1, queue.Count);
|
||||||
|
Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent));
|
||||||
|
MxEvent mxEvent = workerEvent!.Event;
|
||||||
|
Assert.Equal(MxEventFamily.OnWriteComplete, mxEvent.Family);
|
||||||
|
Assert.Equal(7, mxEvent.ServerHandle);
|
||||||
|
Assert.Equal(21, mxEvent.ItemHandle);
|
||||||
|
Assert.Equal(1UL, completionCache.CurrentVersion(7, 21));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that the sink-bound write-completion cache is exposed for sharing
|
||||||
|
/// with the owning <see cref="MxAccessSession"/> so the sink's recordings and
|
||||||
|
/// the write executor's waits see the same instance.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void WriteCompletionCache_ReturnsTheInstanceBoundAtConstruction()
|
||||||
|
{
|
||||||
|
MxAccessEventQueue queue = new();
|
||||||
|
MxAccessWriteCompletionCache completionCache = new();
|
||||||
|
MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper(), new MxAccessValueCache(), completionCache);
|
||||||
|
|
||||||
|
Assert.Same(completionCache, sink.WriteCompletionCache);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that consecutive OnDataChange callbacks land in the queue with monotonic sequences.
|
/// Verifies that consecutive OnDataChange callbacks land in the queue with monotonic sequences.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -851,6 +851,9 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||||
using StaRuntime runtime = CreateRuntime();
|
using StaRuntime runtime = CreateRuntime();
|
||||||
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||||
|
// No completion source in this test — disable the bounded reply wait
|
||||||
|
// so the forwarding assertions don't pay the default 1.5 s timeout.
|
||||||
|
session.WriteCompletionTimeout = TimeSpan.Zero;
|
||||||
await session.StartAsync(workerProcessId: 1234);
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
MxCommandReply reply = await session.DispatchAsync(CreateWriteCommand(
|
MxCommandReply reply = await session.DispatchAsync(CreateWriteCommand(
|
||||||
@@ -874,6 +877,8 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||||
using StaRuntime runtime = CreateRuntime();
|
using StaRuntime runtime = CreateRuntime();
|
||||||
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||||
|
// Same rationale as the Write forwarding test above.
|
||||||
|
session.WriteCompletionTimeout = TimeSpan.Zero;
|
||||||
await session.StartAsync(workerProcessId: 1234);
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
DateTime timestamp = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc);
|
DateTime timestamp = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
@@ -897,6 +902,9 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||||
using StaRuntime runtime = CreateRuntime();
|
using StaRuntime runtime = CreateRuntime();
|
||||||
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||||
|
// No completion source in this test — disable the bounded reply wait
|
||||||
|
// so the forwarding assertions don't pay the default 1.5 s timeout.
|
||||||
|
session.WriteCompletionTimeout = TimeSpan.Zero;
|
||||||
await session.StartAsync(workerProcessId: 1234);
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
|
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
|
||||||
@@ -919,6 +927,8 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||||
using StaRuntime runtime = CreateRuntime();
|
using StaRuntime runtime = CreateRuntime();
|
||||||
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||||
|
// Same rationale as the WriteSecured forwarding test above.
|
||||||
|
session.WriteCompletionTimeout = TimeSpan.Zero;
|
||||||
await session.StartAsync(workerProcessId: 1234);
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
DateTime timestamp = new(2026, 5, 19, 13, 30, 0, DateTimeKind.Utc);
|
DateTime timestamp = new(2026, 5, 19, 13, 30, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
@@ -934,6 +944,277 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
Assert.Equal(44, fakeComObject.WriteVerifierUserId);
|
Assert.Equal(44, fakeComObject.WriteVerifierUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the fast-completion ordering edge: a completion recorded while
|
||||||
|
/// the WriteSecured COM call is still on the stack (MXAccess committing
|
||||||
|
/// synchronously) is newer than the pre-call baseline and lands on the
|
||||||
|
/// reply — the wait never misses a callback that beat it.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_WriteSecured_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
|
||||||
|
{
|
||||||
|
FakeMxAccessComObject fakeComObject = new(registerHandle: 82);
|
||||||
|
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||||
|
CompletionCacheEventSink sink = new();
|
||||||
|
fakeComObject.OnWriteCallback = () =>
|
||||||
|
sink.WriteCompletionCache.Record(82, 820, CreateCompletionRows(detail: 4321));
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
using MxAccessStaSession session = new(runtime, factory, sink);
|
||||||
|
// Hermetic: don't inherit MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS
|
||||||
|
// from the test runner's environment.
|
||||||
|
session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
|
||||||
|
"write-secured-fast", serverHandle: 82, itemHandle: 820, value: 1, currentUserId: 11, verifierUserId: 22));
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
Assert.True(reply.HasHresult);
|
||||||
|
Assert.Equal(0, reply.Hresult);
|
||||||
|
MxStatusProxy row = Assert.Single(reply.Statuses);
|
||||||
|
Assert.Equal(4321, row.Detail);
|
||||||
|
Assert.Equal(MxStatusCategory.Ok, row.Category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the pump-wait path: the completion arrives after the COM call
|
||||||
|
/// returned, while the executor is pump-waiting, and still lands on the
|
||||||
|
/// reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_WriteSecured_WhenCompletionArrivesWhileWaiting_ReturnsStatuses()
|
||||||
|
{
|
||||||
|
FakeMxAccessComObject fakeComObject = new(registerHandle: 83);
|
||||||
|
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||||
|
CompletionCacheEventSink sink = new();
|
||||||
|
// Deterministic ordering: the executor captures its version baseline
|
||||||
|
// BEFORE the COM call, so once the fake's WriteSecured has run the
|
||||||
|
// baseline is committed and a Record from the test thread is
|
||||||
|
// guaranteed to be "newer" — no fixed sleep racing the STA thread.
|
||||||
|
using System.Threading.ManualResetEventSlim comCallReached = new(initialState: false);
|
||||||
|
fakeComObject.OnWriteCallback = () => comCallReached.Set();
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
using MxAccessStaSession session = new(runtime, factory, sink);
|
||||||
|
session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
|
Task<MxCommandReply> pending = session.DispatchAsync(CreateWriteSecuredCommand(
|
||||||
|
"write-secured-waiting", serverHandle: 83, itemHandle: 830, value: 1, currentUserId: 11, verifierUserId: 22));
|
||||||
|
Assert.True(comCallReached.Wait(TimeSpan.FromSeconds(5)));
|
||||||
|
sink.WriteCompletionCache.Record(83, 830, CreateCompletionRows(detail: 99));
|
||||||
|
|
||||||
|
MxCommandReply reply = await pending;
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
Assert.Equal(99, Assert.Single(reply.Statuses).Detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the timeout fallback: no completion within the bounded wait
|
||||||
|
/// returns today's reply shape — protocol OK with EMPTY statuses (the
|
||||||
|
/// consumer's honest-unconfirmed path), never a synthesized failure row.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_WriteSecured_WhenNoCompletion_TimesOutWithEmptyStatusesAndOkProtocol()
|
||||||
|
{
|
||||||
|
FakeMxAccessComObject fakeComObject = new(registerHandle: 84);
|
||||||
|
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||||
|
CompletionCacheEventSink sink = new();
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
using MxAccessStaSession session = new(runtime, factory, sink);
|
||||||
|
session.WriteCompletionTimeout = TimeSpan.FromMilliseconds(100);
|
||||||
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
|
||||||
|
"write-secured-timeout", serverHandle: 84, itemHandle: 840, value: 1, currentUserId: 11, verifierUserId: 22));
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
Assert.True(reply.HasHresult);
|
||||||
|
Assert.Equal(0, reply.Hresult);
|
||||||
|
Assert.Empty(reply.Statuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the version-baseline rule end to end: a completion recorded
|
||||||
|
/// BEFORE the write was dispatched is stale and must not be misattributed
|
||||||
|
/// to this write — the reply times out empty instead.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_WriteSecured_IgnoresStaleCompletionFromBeforeTheCall()
|
||||||
|
{
|
||||||
|
FakeMxAccessComObject fakeComObject = new(registerHandle: 85);
|
||||||
|
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||||
|
CompletionCacheEventSink sink = new();
|
||||||
|
sink.WriteCompletionCache.Record(85, 850, CreateCompletionRows(detail: 1111));
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
using MxAccessStaSession session = new(runtime, factory, sink);
|
||||||
|
session.WriteCompletionTimeout = TimeSpan.FromMilliseconds(100);
|
||||||
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
|
||||||
|
"write-secured-stale", serverHandle: 85, itemHandle: 850, value: 1, currentUserId: 11, verifierUserId: 22));
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
Assert.Empty(reply.Statuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that WriteSecured2 correlates the same way as WriteSecured
|
||||||
|
/// (fast-completion edge).
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_WriteSecured2_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
|
||||||
|
{
|
||||||
|
FakeMxAccessComObject fakeComObject = new(registerHandle: 86);
|
||||||
|
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||||
|
CompletionCacheEventSink sink = new();
|
||||||
|
fakeComObject.OnWriteCallback = () =>
|
||||||
|
sink.WriteCompletionCache.Record(86, 860, CreateCompletionRows(detail: 2222));
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
using MxAccessStaSession session = new(runtime, factory, sink);
|
||||||
|
// Hermetic: same rationale as the WriteSecured fast-completion test.
|
||||||
|
session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecured2Command(
|
||||||
|
"write-secured2-fast", serverHandle: 86, itemHandle: 860, value: 1,
|
||||||
|
timestamp: new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc), currentUserId: 33, verifierUserId: 44));
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
Assert.Equal(2222, Assert.Single(reply.Statuses).Detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies plain Write correlates the same way as WriteSecured
|
||||||
|
/// (fast-completion edge): OtOpcUa's dominant FreeAccess path goes out
|
||||||
|
/// as MX_COMMAND_KIND_WRITE, so its reply must carry the OnWriteComplete
|
||||||
|
/// rows too.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_Write_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
|
||||||
|
{
|
||||||
|
FakeMxAccessComObject fakeComObject = new(registerHandle: 87);
|
||||||
|
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||||
|
CompletionCacheEventSink sink = new();
|
||||||
|
fakeComObject.OnWriteCallback = () =>
|
||||||
|
sink.WriteCompletionCache.Record(87, 870, CreateCompletionRows(detail: 5555));
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
using MxAccessStaSession session = new(runtime, factory, sink);
|
||||||
|
// Hermetic: don't inherit MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS
|
||||||
|
// from the test runner's environment.
|
||||||
|
session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.DispatchAsync(CreateWriteCommand(
|
||||||
|
"plain-write-fast", serverHandle: 87, itemHandle: 870, value: 1, userId: 5));
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
Assert.True(reply.HasHresult);
|
||||||
|
Assert.Equal(0, reply.Hresult);
|
||||||
|
MxStatusProxy row = Assert.Single(reply.Statuses);
|
||||||
|
Assert.Equal(5555, row.Detail);
|
||||||
|
Assert.Equal(MxStatusCategory.Ok, row.Category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the plain-Write timeout fallback mirrors the secured one:
|
||||||
|
/// no completion within the bounded wait returns protocol OK with EMPTY
|
||||||
|
/// statuses — unconfirmed, never a synthesized failure row.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_Write_WhenNoCompletion_TimesOutWithEmptyStatusesAndOkProtocol()
|
||||||
|
{
|
||||||
|
FakeMxAccessComObject fakeComObject = new(registerHandle: 88);
|
||||||
|
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||||
|
CompletionCacheEventSink sink = new();
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
using MxAccessStaSession session = new(runtime, factory, sink);
|
||||||
|
session.WriteCompletionTimeout = TimeSpan.FromMilliseconds(100);
|
||||||
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.DispatchAsync(CreateWriteCommand(
|
||||||
|
"plain-write-timeout", serverHandle: 88, itemHandle: 880, value: 1, userId: 5));
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
Assert.True(reply.HasHresult);
|
||||||
|
Assert.Equal(0, reply.Hresult);
|
||||||
|
Assert.Empty(reply.Statuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies Write2 correlates the same way as Write (fast-completion
|
||||||
|
/// edge).
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_Write2_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
|
||||||
|
{
|
||||||
|
FakeMxAccessComObject fakeComObject = new(registerHandle: 89);
|
||||||
|
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||||
|
CompletionCacheEventSink sink = new();
|
||||||
|
fakeComObject.OnWriteCallback = () =>
|
||||||
|
sink.WriteCompletionCache.Record(89, 890, CreateCompletionRows(detail: 6666));
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
using MxAccessStaSession session = new(runtime, factory, sink);
|
||||||
|
session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.DispatchAsync(CreateWrite2Command(
|
||||||
|
"plain-write2-fast", serverHandle: 89, itemHandle: 890, value: 1,
|
||||||
|
timestamp: new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc), userId: 6));
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
Assert.Equal(6666, Assert.Single(reply.Statuses).Detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies WriteBulk stays fire-and-forget: even with a huge completion
|
||||||
|
/// timeout configured and no completion source, the reply returns
|
||||||
|
/// immediately with per-entry results — bulk writes never enter the
|
||||||
|
/// bounded wait (latency for high-rate loops).
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_WriteBulk_DoesNotWaitForCompletion()
|
||||||
|
{
|
||||||
|
FakeMxAccessComObject fakeComObject = new(registerHandle: 90);
|
||||||
|
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||||
|
CompletionCacheEventSink sink = new();
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
using MxAccessStaSession session = new(runtime, factory, sink);
|
||||||
|
session.WriteCompletionTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
|
Task<MxCommandReply> pending = session.DispatchAsync(CreateWriteBulkCommand(
|
||||||
|
"bulk-write-no-wait", serverHandle: 90, entries: new[] { (itemHandle: 900, value: 1, userId: 5) }));
|
||||||
|
Task completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(5)));
|
||||||
|
|
||||||
|
Assert.Same(pending, completed);
|
||||||
|
MxCommandReply reply = await pending;
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
Assert.Empty(reply.Statuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Google.Protobuf.Collections.RepeatedField<MxStatusProxy> CreateCompletionRows(int detail)
|
||||||
|
{
|
||||||
|
return new Google.Protobuf.Collections.RepeatedField<MxStatusProxy>
|
||||||
|
{
|
||||||
|
new MxStatusProxy
|
||||||
|
{
|
||||||
|
Success = 1,
|
||||||
|
Category = MxStatusCategory.Ok,
|
||||||
|
Detail = detail,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that Write without a payload returns an invalid request error.</summary>
|
/// <summary>Verifies that Write without a payload returns an invalid request error.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -1775,6 +2056,28 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
TimeSpan.FromMilliseconds(25));
|
TimeSpan.FromMilliseconds(25));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test sink that owns a real write-completion cache without touching the
|
||||||
|
/// MXAccess COM RCW (Attach is a no-op). Implements the provider seam so
|
||||||
|
/// <see cref="MxAccessSession.Create"/> shares this cache with the write
|
||||||
|
/// executor, letting tests record completions the executor's bounded
|
||||||
|
/// wait then observes.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class CompletionCacheEventSink : IMxAccessEventSink, IWriteCompletionCacheProvider
|
||||||
|
{
|
||||||
|
public MxAccessWriteCompletionCache WriteCompletionCache { get; } = new MxAccessWriteCompletionCache();
|
||||||
|
|
||||||
|
public void Attach(
|
||||||
|
object mxAccessComObject,
|
||||||
|
string sessionId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Detach()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeMxAccessComObject : IMxAccessServer
|
private sealed class FakeMxAccessComObject : IMxAccessServer
|
||||||
{
|
{
|
||||||
private readonly int registerHandle;
|
private readonly int registerHandle;
|
||||||
@@ -1790,6 +2093,15 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
private readonly IReadOnlyDictionary<int, Exception> writeExceptionByItemHandle;
|
private readonly IReadOnlyDictionary<int, Exception> writeExceptionByItemHandle;
|
||||||
private readonly List<string> operationNames = new();
|
private readonly List<string> operationNames = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invoked at the end of a successful Write/Write2/WriteSecured/
|
||||||
|
/// WriteSecured2 — stands in for MXAccess committing synchronously
|
||||||
|
/// and delivering OnWriteComplete while the COM call is still on
|
||||||
|
/// the stack, so tests can exercise the fast-completion ordering
|
||||||
|
/// edge on every correlated write kind.
|
||||||
|
/// </summary>
|
||||||
|
public Action? OnWriteCallback { get; set; }
|
||||||
|
|
||||||
/// <summary>Initializes a fake MXAccess COM object with the given handles and optional exceptions.</summary>
|
/// <summary>Initializes a fake MXAccess COM object with the given handles and optional exceptions.</summary>
|
||||||
/// <param name="registerHandle">Return value for Register method.</param>
|
/// <param name="registerHandle">Return value for Register method.</param>
|
||||||
/// <param name="addItemHandle">Return value for AddItem method.</param>
|
/// <param name="addItemHandle">Return value for AddItem method.</param>
|
||||||
@@ -2064,6 +2376,7 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
WriteUserId = userId;
|
WriteUserId = userId;
|
||||||
WriteThreadId = Environment.CurrentManagedThreadId;
|
WriteThreadId = Environment.CurrentManagedThreadId;
|
||||||
ThrowIfWriteFailureConfigured(itemHandle);
|
ThrowIfWriteFailureConfigured(itemHandle);
|
||||||
|
OnWriteCallback?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -2082,6 +2395,7 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
WriteUserId = userId;
|
WriteUserId = userId;
|
||||||
WriteThreadId = Environment.CurrentManagedThreadId;
|
WriteThreadId = Environment.CurrentManagedThreadId;
|
||||||
ThrowIfWriteFailureConfigured(itemHandle);
|
ThrowIfWriteFailureConfigured(itemHandle);
|
||||||
|
OnWriteCallback?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -2100,6 +2414,7 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
WriteValue = value;
|
WriteValue = value;
|
||||||
WriteThreadId = Environment.CurrentManagedThreadId;
|
WriteThreadId = Environment.CurrentManagedThreadId;
|
||||||
ThrowIfWriteFailureConfigured(itemHandle);
|
ThrowIfWriteFailureConfigured(itemHandle);
|
||||||
|
OnWriteCallback?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -2120,6 +2435,7 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
WriteTimestamp = timestamp;
|
WriteTimestamp = timestamp;
|
||||||
WriteThreadId = Environment.CurrentManagedThreadId;
|
WriteThreadId = Environment.CurrentManagedThreadId;
|
||||||
ThrowIfWriteFailureConfigured(itemHandle);
|
ThrowIfWriteFailureConfigured(itemHandle);
|
||||||
|
OnWriteCallback?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ThrowIfWriteFailureConfigured(int itemHandle)
|
private void ThrowIfWriteFailureConfigured(int itemHandle)
|
||||||
|
|||||||
@@ -15,6 +15,48 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MxAccessStaSessionTests
|
public sealed class MxAccessStaSessionTests
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the launcher-env-var parse branches of
|
||||||
|
/// <see cref="MxAccessStaSession.ResolveWriteCompletionTimeout"/>:
|
||||||
|
/// a valid non-negative value is honored (0 = disabled), while a
|
||||||
|
/// missing, malformed, or negative value falls back to the executor
|
||||||
|
/// default. Env mutation is restored in a finally so parallel tests
|
||||||
|
/// never observe the temporary value.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rawValue">Raw env-var value, or null for unset.</param>
|
||||||
|
/// <param name="expectedMilliseconds">Expected resolved wait, or null for the executor default.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, null)]
|
||||||
|
[InlineData("", null)]
|
||||||
|
[InlineData("junk", null)]
|
||||||
|
[InlineData("-5", null)]
|
||||||
|
[InlineData("0", 0)]
|
||||||
|
[InlineData("250", 250)]
|
||||||
|
public void ResolveWriteCompletionTimeout_ParsesEnvironmentValue(string? rawValue, int? expectedMilliseconds)
|
||||||
|
{
|
||||||
|
string? original = Environment.GetEnvironmentVariable(
|
||||||
|
MxAccessStaSession.WriteCompletionWaitEnvironmentVariableName);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(
|
||||||
|
MxAccessStaSession.WriteCompletionWaitEnvironmentVariableName,
|
||||||
|
rawValue);
|
||||||
|
|
||||||
|
TimeSpan resolved = MxAccessStaSession.ResolveWriteCompletionTimeout();
|
||||||
|
|
||||||
|
TimeSpan expected = expectedMilliseconds is null
|
||||||
|
? MxAccessCommandExecutor.DefaultWriteCompletionTimeout
|
||||||
|
: TimeSpan.FromMilliseconds(expectedMilliseconds.Value);
|
||||||
|
Assert.Equal(expected, resolved);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(
|
||||||
|
MxAccessStaSession.WriteCompletionWaitEnvironmentVariableName,
|
||||||
|
original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that StartAsync creates the MXAccess COM object and attaches the event sink on the STA thread.
|
/// Verifies that StartAsync creates the MXAccess COM object and attaches the event sink on the STA thread.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using System;
|
||||||
|
using Google.Protobuf.Collections;
|
||||||
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="MxAccessWriteCompletionCache"/>. The cache is
|
||||||
|
/// consumed by the write command executor's bounded pump-wait so a
|
||||||
|
/// WriteSecured/WriteSecured2 reply can carry the correlated
|
||||||
|
/// OnWriteComplete outcome; its version-baseline contract is exercised in
|
||||||
|
/// isolation here before the STA / COM plumbing gets layered on top.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MxAccessWriteCompletionCacheTests
|
||||||
|
{
|
||||||
|
/// <summary>Verifies that Record bumps the version per key and keys stay isolated.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Record_IncrementsVersionPerKey()
|
||||||
|
{
|
||||||
|
MxAccessWriteCompletionCache cache = new();
|
||||||
|
|
||||||
|
Assert.Equal(0UL, cache.CurrentVersion(7, 21));
|
||||||
|
|
||||||
|
cache.Record(7, 21, BuildStatuses(detail: 100));
|
||||||
|
Assert.Equal(1UL, cache.CurrentVersion(7, 21));
|
||||||
|
|
||||||
|
cache.Record(7, 21, BuildStatuses(detail: 200));
|
||||||
|
Assert.Equal(2UL, cache.CurrentVersion(7, 21));
|
||||||
|
|
||||||
|
cache.Record(7, 22, BuildStatuses(detail: 300));
|
||||||
|
Assert.Equal(1UL, cache.CurrentVersion(7, 22));
|
||||||
|
Assert.Equal(2UL, cache.CurrentVersion(7, 21));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a completion newer than the baseline is returned with its status rows.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void TryWaitForCompletion_WhenCompletionNewerThanBaseline_ReturnsStatuses()
|
||||||
|
{
|
||||||
|
MxAccessWriteCompletionCache cache = new();
|
||||||
|
cache.Record(7, 21, BuildStatuses(detail: 4321));
|
||||||
|
|
||||||
|
bool found = cache.TryWaitForCompletion(
|
||||||
|
7,
|
||||||
|
21,
|
||||||
|
sinceVersion: 0UL,
|
||||||
|
deadlineUtc: DateTime.UtcNow.AddSeconds(5),
|
||||||
|
pumpStep: static () => { },
|
||||||
|
out RepeatedField<MxStatusProxy> statuses);
|
||||||
|
|
||||||
|
Assert.True(found);
|
||||||
|
MxStatusProxy row = Assert.Single(statuses);
|
||||||
|
Assert.Equal(4321, row.Detail);
|
||||||
|
Assert.Equal(MxStatusCategory.Ok, row.Category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that a completion recorded before the baseline was captured is
|
||||||
|
/// never misattributed to the waiting write: only a strictly newer version
|
||||||
|
/// satisfies the wait, so a stale row times the wait out.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void TryWaitForCompletion_WhenOnlyStaleCompletion_TimesOut()
|
||||||
|
{
|
||||||
|
MxAccessWriteCompletionCache cache = new();
|
||||||
|
cache.Record(7, 21, BuildStatuses(detail: 4321));
|
||||||
|
ulong baseline = cache.CurrentVersion(7, 21);
|
||||||
|
|
||||||
|
bool found = cache.TryWaitForCompletion(
|
||||||
|
7,
|
||||||
|
21,
|
||||||
|
sinceVersion: baseline,
|
||||||
|
deadlineUtc: DateTime.UtcNow.AddMilliseconds(50),
|
||||||
|
pumpStep: static () => { },
|
||||||
|
out RepeatedField<MxStatusProxy> statuses);
|
||||||
|
|
||||||
|
Assert.False(found);
|
||||||
|
Assert.Empty(statuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the pump loop is what lets a completion land: the completion is
|
||||||
|
/// recorded from inside a later pump step (standing in for the STA
|
||||||
|
/// dispatching the OnWriteComplete message) and the wait then succeeds.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void TryWaitForCompletion_InvokesPumpStepEachIteration()
|
||||||
|
{
|
||||||
|
MxAccessWriteCompletionCache cache = new();
|
||||||
|
int pumpCalls = 0;
|
||||||
|
|
||||||
|
bool found = cache.TryWaitForCompletion(
|
||||||
|
7,
|
||||||
|
21,
|
||||||
|
sinceVersion: 0UL,
|
||||||
|
deadlineUtc: DateTime.UtcNow.AddSeconds(5),
|
||||||
|
pumpStep: () =>
|
||||||
|
{
|
||||||
|
pumpCalls++;
|
||||||
|
if (pumpCalls == 2)
|
||||||
|
{
|
||||||
|
cache.Record(7, 21, BuildStatuses(detail: 55));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
out RepeatedField<MxStatusProxy> statuses);
|
||||||
|
|
||||||
|
Assert.True(found);
|
||||||
|
Assert.True(pumpCalls >= 2);
|
||||||
|
Assert.Equal(55, Assert.Single(statuses).Detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that Record stores an independent clone of the caller's rows.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Record_ClonesStatuses()
|
||||||
|
{
|
||||||
|
MxAccessWriteCompletionCache cache = new();
|
||||||
|
RepeatedField<MxStatusProxy> callerRows = BuildStatuses(detail: 77);
|
||||||
|
|
||||||
|
cache.Record(7, 21, callerRows);
|
||||||
|
callerRows[0].Detail = 999;
|
||||||
|
callerRows.Add(new MxStatusProxy());
|
||||||
|
|
||||||
|
Assert.True(cache.TryWaitForCompletion(
|
||||||
|
7,
|
||||||
|
21,
|
||||||
|
sinceVersion: 0UL,
|
||||||
|
deadlineUtc: DateTime.UtcNow.AddSeconds(5),
|
||||||
|
pumpStep: static () => { },
|
||||||
|
out RepeatedField<MxStatusProxy> statuses));
|
||||||
|
Assert.Equal(77, Assert.Single(statuses).Detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RepeatedField<MxStatusProxy> BuildStatuses(int detail)
|
||||||
|
{
|
||||||
|
return new RepeatedField<MxStatusProxy>
|
||||||
|
{
|
||||||
|
new MxStatusProxy
|
||||||
|
{
|
||||||
|
Success = 1,
|
||||||
|
Category = MxStatusCategory.Ok,
|
||||||
|
Detail = detail,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -5,11 +5,12 @@ using Proto = ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|||||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
/// <summary>Sink for MXAccess COM events that converts them to protobuf format.</summary>
|
/// <summary>Sink for MXAccess COM events that converts them to protobuf format.</summary>
|
||||||
public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
public sealed class MxAccessBaseEventSink : IMxAccessEventSink, IWriteCompletionCacheProvider
|
||||||
{
|
{
|
||||||
private readonly MxAccessEventMapper eventMapper;
|
private readonly MxAccessEventMapper eventMapper;
|
||||||
private readonly MxAccessEventQueue eventQueue;
|
private readonly MxAccessEventQueue eventQueue;
|
||||||
private readonly MxAccessValueCache valueCache;
|
private readonly MxAccessValueCache valueCache;
|
||||||
|
private readonly MxAccessWriteCompletionCache writeCompletionCache;
|
||||||
private LMXProxyServerClass? server;
|
private LMXProxyServerClass? server;
|
||||||
private string sessionId = string.Empty;
|
private string sessionId = string.Empty;
|
||||||
|
|
||||||
@@ -50,10 +51,32 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
|||||||
MxAccessEventQueue eventQueue,
|
MxAccessEventQueue eventQueue,
|
||||||
MxAccessEventMapper eventMapper,
|
MxAccessEventMapper eventMapper,
|
||||||
MxAccessValueCache valueCache)
|
MxAccessValueCache valueCache)
|
||||||
|
: this(eventQueue, eventMapper, valueCache, new MxAccessWriteCompletionCache())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the MxAccessBaseEventSink class with
|
||||||
|
/// provided queue, mapper, value cache, and a shared write-completion
|
||||||
|
/// cache. The completion cache is populated from every successful
|
||||||
|
/// <c>OnWriteComplete</c> dispatch so the worker's write executor can
|
||||||
|
/// correlate a WriteSecured/WriteSecured2 reply with the MXAccess
|
||||||
|
/// completion outcome.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="eventQueue">Queue for buffering converted MXAccess events.</param>
|
||||||
|
/// <param name="eventMapper">Converter for MXAccess events to protobuf format.</param>
|
||||||
|
/// <param name="valueCache">Per-session last-value cache shared with the MxAccessSession.</param>
|
||||||
|
/// <param name="writeCompletionCache">Per-session OnWriteComplete cache shared with the MxAccessSession.</param>
|
||||||
|
public MxAccessBaseEventSink(
|
||||||
|
MxAccessEventQueue eventQueue,
|
||||||
|
MxAccessEventMapper eventMapper,
|
||||||
|
MxAccessValueCache valueCache,
|
||||||
|
MxAccessWriteCompletionCache writeCompletionCache)
|
||||||
{
|
{
|
||||||
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
||||||
this.eventMapper = eventMapper ?? throw new ArgumentNullException(nameof(eventMapper));
|
this.eventMapper = eventMapper ?? throw new ArgumentNullException(nameof(eventMapper));
|
||||||
this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache));
|
this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache));
|
||||||
|
this.writeCompletionCache = writeCompletionCache ?? throw new ArgumentNullException(nameof(writeCompletionCache));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -62,6 +85,14 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public MxAccessValueCache ValueCache => valueCache;
|
public MxAccessValueCache ValueCache => valueCache;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The OnWriteComplete completion cache populated by this sink. Exposed
|
||||||
|
/// via <see cref="IWriteCompletionCacheProvider"/> so the
|
||||||
|
/// MxAccessSession can share the same instance with the write command
|
||||||
|
/// executor's bounded completion wait.
|
||||||
|
/// </summary>
|
||||||
|
public MxAccessWriteCompletionCache WriteCompletionCache => writeCompletionCache;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Attach(
|
public void Attach(
|
||||||
object mxAccessComObject,
|
object mxAccessComObject,
|
||||||
@@ -143,11 +174,18 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
|||||||
ref MXSTATUS_PROXY[] pVars)
|
ref MXSTATUS_PROXY[] pVars)
|
||||||
{
|
{
|
||||||
MXSTATUS_PROXY[] statuses = pVars;
|
MXSTATUS_PROXY[] statuses = pVars;
|
||||||
EnqueueEvent(() => eventMapper.CreateOnWriteComplete(
|
// Record the completion for the write executor's bounded reply wait
|
||||||
sessionId,
|
// only after the event has cleared the queue (same post-publish rule
|
||||||
hLMXServerHandle,
|
// as the OnDataChange value cache) — an overflow faults the session,
|
||||||
phItemHandle,
|
// so a dropped event never leaves a "fresher" completion behind than
|
||||||
statuses));
|
// what shipped to the gateway.
|
||||||
|
EnqueueEvent(
|
||||||
|
() => eventMapper.CreateOnWriteComplete(
|
||||||
|
sessionId,
|
||||||
|
hLMXServerHandle,
|
||||||
|
phItemHandle,
|
||||||
|
statuses),
|
||||||
|
mxEvent => writeCompletionCache.Record(hLMXServerHandle, phItemHandle, mxEvent.Statuses));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -14,11 +14,22 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
|||||||
/// <summary>Default per-tag timeout used when <c>ReadBulkCommand.timeout_ms</c> is zero.</summary>
|
/// <summary>Default per-tag timeout used when <c>ReadBulkCommand.timeout_ms</c> is zero.</summary>
|
||||||
internal static readonly TimeSpan DefaultReadBulkTimeout = TimeSpan.FromMilliseconds(1000);
|
internal static readonly TimeSpan DefaultReadBulkTimeout = TimeSpan.FromMilliseconds(1000);
|
||||||
|
|
||||||
|
/// <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 MxAccessSession session;
|
private readonly MxAccessSession session;
|
||||||
private readonly VariantConverter variantConverter;
|
private readonly VariantConverter variantConverter;
|
||||||
private readonly MxStatusProxyConverter statusProxyConverter;
|
private readonly MxStatusProxyConverter statusProxyConverter;
|
||||||
private readonly IAlarmCommandHandler? alarmCommandHandler;
|
private readonly IAlarmCommandHandler? alarmCommandHandler;
|
||||||
private readonly Action pumpStep;
|
private readonly Action pumpStep;
|
||||||
|
private readonly TimeSpan writeCompletionTimeout;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a command executor with an MXAccess session.
|
/// Initializes a command executor with an MXAccess session.
|
||||||
@@ -71,17 +82,26 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
|||||||
/// <param name="variantConverter">Converter for MXAccess variant values to MxValue protobuf messages.</param>
|
/// <param name="variantConverter">Converter for MXAccess variant values to MxValue protobuf messages.</param>
|
||||||
/// <param name="alarmCommandHandler">Optional handler for alarm-side commands.</param>
|
/// <param name="alarmCommandHandler">Optional handler for alarm-side commands.</param>
|
||||||
/// <param name="pumpStep">Action to pump Windows messages, or null for tests.</param>
|
/// <param name="pumpStep">Action to pump Windows messages, or null for tests.</param>
|
||||||
|
/// <param name="writeCompletionTimeout">
|
||||||
|
/// Bounded wait for the OnWriteComplete callback after a
|
||||||
|
/// WriteSecured/WriteSecured2 COM call, or null for
|
||||||
|
/// <see cref="DefaultWriteCompletionTimeout"/>. Zero (or negative)
|
||||||
|
/// disables the wait entirely — replies keep the pure fire-and-forget
|
||||||
|
/// shape.
|
||||||
|
/// </param>
|
||||||
public MxAccessCommandExecutor(
|
public MxAccessCommandExecutor(
|
||||||
MxAccessSession session,
|
MxAccessSession session,
|
||||||
VariantConverter variantConverter,
|
VariantConverter variantConverter,
|
||||||
IAlarmCommandHandler? alarmCommandHandler,
|
IAlarmCommandHandler? alarmCommandHandler,
|
||||||
Action? pumpStep)
|
Action? pumpStep,
|
||||||
|
TimeSpan? writeCompletionTimeout = null)
|
||||||
{
|
{
|
||||||
this.session = session ?? throw new ArgumentNullException(nameof(session));
|
this.session = session ?? throw new ArgumentNullException(nameof(session));
|
||||||
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
|
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
|
||||||
this.statusProxyConverter = new MxStatusProxyConverter();
|
this.statusProxyConverter = new MxStatusProxyConverter();
|
||||||
this.alarmCommandHandler = alarmCommandHandler;
|
this.alarmCommandHandler = alarmCommandHandler;
|
||||||
this.pumpStep = pumpStep ?? (static () => { });
|
this.pumpStep = pumpStep ?? (static () => { });
|
||||||
|
this.writeCompletionTimeout = writeCompletionTimeout ?? DefaultWriteCompletionTimeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -407,13 +427,28 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
|||||||
return CreateInvalidRequestReply(command, "Write command value is required.");
|
return CreateInvalidRequestReply(command, "Write command value is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same pre-call baseline rule as ExecuteWriteSecured: plain Write is
|
||||||
|
// also fire-and-forget in MXAccess, and its commit outcome only exists
|
||||||
|
// in the later OnWriteComplete callback.
|
||||||
|
MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache;
|
||||||
|
ulong completionBaseline = completionCache.CurrentVersion(
|
||||||
|
writeCommand.ServerHandle,
|
||||||
|
writeCommand.ItemHandle);
|
||||||
|
|
||||||
session.Write(
|
session.Write(
|
||||||
writeCommand.ServerHandle,
|
writeCommand.ServerHandle,
|
||||||
writeCommand.ItemHandle,
|
writeCommand.ItemHandle,
|
||||||
variantConverter.ConvertToComValue(writeCommand.Value),
|
variantConverter.ConvertToComValue(writeCommand.Value),
|
||||||
writeCommand.UserId);
|
writeCommand.UserId);
|
||||||
|
|
||||||
return CreateOkReply(command);
|
MxCommandReply reply = CreateOkReply(command);
|
||||||
|
AwaitWriteCompletion(
|
||||||
|
reply,
|
||||||
|
completionCache,
|
||||||
|
writeCommand.ServerHandle,
|
||||||
|
writeCommand.ItemHandle,
|
||||||
|
completionBaseline);
|
||||||
|
return reply;
|
||||||
}
|
}
|
||||||
|
|
||||||
private MxCommandReply ExecuteWrite2(StaCommand command)
|
private MxCommandReply ExecuteWrite2(StaCommand command)
|
||||||
@@ -434,6 +469,12 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
|||||||
return CreateInvalidRequestReply(command, "Write2 command timestamp value is required.");
|
return CreateInvalidRequestReply(command, "Write2 command timestamp value is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same pre-call baseline rule as ExecuteWriteSecured.
|
||||||
|
MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache;
|
||||||
|
ulong completionBaseline = completionCache.CurrentVersion(
|
||||||
|
write2Command.ServerHandle,
|
||||||
|
write2Command.ItemHandle);
|
||||||
|
|
||||||
session.Write2(
|
session.Write2(
|
||||||
write2Command.ServerHandle,
|
write2Command.ServerHandle,
|
||||||
write2Command.ItemHandle,
|
write2Command.ItemHandle,
|
||||||
@@ -441,7 +482,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
|||||||
variantConverter.ConvertToComValue(write2Command.TimestampValue),
|
variantConverter.ConvertToComValue(write2Command.TimestampValue),
|
||||||
write2Command.UserId);
|
write2Command.UserId);
|
||||||
|
|
||||||
return CreateOkReply(command);
|
MxCommandReply reply = CreateOkReply(command);
|
||||||
|
AwaitWriteCompletion(
|
||||||
|
reply,
|
||||||
|
completionCache,
|
||||||
|
write2Command.ServerHandle,
|
||||||
|
write2Command.ItemHandle,
|
||||||
|
completionBaseline);
|
||||||
|
return reply;
|
||||||
}
|
}
|
||||||
|
|
||||||
private MxCommandReply ExecuteWriteSecured(StaCommand command)
|
private MxCommandReply ExecuteWriteSecured(StaCommand command)
|
||||||
@@ -457,6 +505,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
|||||||
return CreateInvalidRequestReply(command, "WriteSecured command value is required.");
|
return CreateInvalidRequestReply(command, "WriteSecured command value is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache;
|
||||||
|
ulong completionBaseline = completionCache.CurrentVersion(
|
||||||
|
writeSecuredCommand.ServerHandle,
|
||||||
|
writeSecuredCommand.ItemHandle);
|
||||||
|
|
||||||
session.WriteSecured(
|
session.WriteSecured(
|
||||||
writeSecuredCommand.ServerHandle,
|
writeSecuredCommand.ServerHandle,
|
||||||
writeSecuredCommand.ItemHandle,
|
writeSecuredCommand.ItemHandle,
|
||||||
@@ -464,7 +520,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
|||||||
writeSecuredCommand.VerifierUserId,
|
writeSecuredCommand.VerifierUserId,
|
||||||
variantConverter.ConvertToComValue(writeSecuredCommand.Value));
|
variantConverter.ConvertToComValue(writeSecuredCommand.Value));
|
||||||
|
|
||||||
return CreateOkReply(command);
|
MxCommandReply reply = CreateOkReply(command);
|
||||||
|
AwaitWriteCompletion(
|
||||||
|
reply,
|
||||||
|
completionCache,
|
||||||
|
writeSecuredCommand.ServerHandle,
|
||||||
|
writeSecuredCommand.ItemHandle,
|
||||||
|
completionBaseline);
|
||||||
|
return reply;
|
||||||
}
|
}
|
||||||
|
|
||||||
private MxCommandReply ExecuteWriteSecured2(StaCommand command)
|
private MxCommandReply ExecuteWriteSecured2(StaCommand command)
|
||||||
@@ -485,6 +548,12 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
|||||||
return CreateInvalidRequestReply(command, "WriteSecured2 command timestamp value is required.");
|
return CreateInvalidRequestReply(command, "WriteSecured2 command timestamp value is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same pre-call baseline rule as ExecuteWriteSecured.
|
||||||
|
MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache;
|
||||||
|
ulong completionBaseline = completionCache.CurrentVersion(
|
||||||
|
writeSecured2Command.ServerHandle,
|
||||||
|
writeSecured2Command.ItemHandle);
|
||||||
|
|
||||||
session.WriteSecured2(
|
session.WriteSecured2(
|
||||||
writeSecured2Command.ServerHandle,
|
writeSecured2Command.ServerHandle,
|
||||||
writeSecured2Command.ItemHandle,
|
writeSecured2Command.ItemHandle,
|
||||||
@@ -493,7 +562,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
|||||||
variantConverter.ConvertToComValue(writeSecured2Command.Value),
|
variantConverter.ConvertToComValue(writeSecured2Command.Value),
|
||||||
variantConverter.ConvertToComValue(writeSecured2Command.TimestampValue));
|
variantConverter.ConvertToComValue(writeSecured2Command.TimestampValue));
|
||||||
|
|
||||||
return CreateOkReply(command);
|
MxCommandReply reply = CreateOkReply(command);
|
||||||
|
AwaitWriteCompletion(
|
||||||
|
reply,
|
||||||
|
completionCache,
|
||||||
|
writeSecured2Command.ServerHandle,
|
||||||
|
writeSecured2Command.ItemHandle,
|
||||||
|
completionBaseline);
|
||||||
|
return reply;
|
||||||
}
|
}
|
||||||
|
|
||||||
private MxCommandReply ExecuteAddItemBulk(StaCommand command)
|
private MxCommandReply ExecuteAddItemBulk(StaCommand command)
|
||||||
@@ -897,6 +973,39 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static MxCommandReply CreateAlarmFailureReply(StaCommand command, Exception exception)
|
private static MxCommandReply CreateAlarmFailureReply(StaCommand command, Exception exception)
|
||||||
{
|
{
|
||||||
return new MxCommandReply
|
return new MxCommandReply
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public sealed class MxAccessSession : IDisposable
|
|||||||
private readonly IMxAccessEventSink eventSink;
|
private readonly IMxAccessEventSink eventSink;
|
||||||
private readonly MxAccessHandleRegistry handleRegistry;
|
private readonly MxAccessHandleRegistry handleRegistry;
|
||||||
private readonly MxAccessValueCache valueCache;
|
private readonly MxAccessValueCache valueCache;
|
||||||
|
private readonly MxAccessWriteCompletionCache writeCompletionCache;
|
||||||
private bool disposed;
|
private bool disposed;
|
||||||
|
|
||||||
private MxAccessSession(
|
private MxAccessSession(
|
||||||
@@ -21,6 +22,7 @@ public sealed class MxAccessSession : IDisposable
|
|||||||
IMxAccessEventSink eventSink,
|
IMxAccessEventSink eventSink,
|
||||||
MxAccessHandleRegistry handleRegistry,
|
MxAccessHandleRegistry handleRegistry,
|
||||||
MxAccessValueCache valueCache,
|
MxAccessValueCache valueCache,
|
||||||
|
MxAccessWriteCompletionCache writeCompletionCache,
|
||||||
int creationThreadId)
|
int creationThreadId)
|
||||||
{
|
{
|
||||||
this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject));
|
this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject));
|
||||||
@@ -28,6 +30,7 @@ public sealed class MxAccessSession : IDisposable
|
|||||||
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
|
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
|
||||||
this.handleRegistry = handleRegistry ?? throw new ArgumentNullException(nameof(handleRegistry));
|
this.handleRegistry = handleRegistry ?? throw new ArgumentNullException(nameof(handleRegistry));
|
||||||
this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache));
|
this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache));
|
||||||
|
this.writeCompletionCache = writeCompletionCache ?? throw new ArgumentNullException(nameof(writeCompletionCache));
|
||||||
CreationThreadId = creationThreadId;
|
CreationThreadId = creationThreadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +48,14 @@ public sealed class MxAccessSession : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public MxAccessValueCache ValueCache => valueCache;
|
public MxAccessValueCache ValueCache => valueCache;
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
|
||||||
/// <summary>Creates a WorkerReady message with session metadata.</summary>
|
/// <summary>Creates a WorkerReady message with session metadata.</summary>
|
||||||
/// <param name="workerProcessId">Process ID of the worker.</param>
|
/// <param name="workerProcessId">Process ID of the worker.</param>
|
||||||
/// <returns>The populated <see cref="WorkerReady"/> message.</returns>
|
/// <returns>The populated <see cref="WorkerReady"/> message.</returns>
|
||||||
@@ -105,6 +116,9 @@ public sealed class MxAccessSession : IDisposable
|
|||||||
eventSink,
|
eventSink,
|
||||||
handleRegistry ?? new MxAccessHandleRegistry(),
|
handleRegistry ?? new MxAccessHandleRegistry(),
|
||||||
valueCache ?? new MxAccessValueCache(),
|
valueCache ?? new MxAccessValueCache(),
|
||||||
|
eventSink is IWriteCompletionCacheProvider provider
|
||||||
|
? provider.WriteCompletionCache
|
||||||
|
: new MxAccessWriteCompletionCache(),
|
||||||
creationThreadId ?? Environment.CurrentManagedThreadId);
|
creationThreadId ?? Environment.CurrentManagedThreadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +163,22 @@ public sealed class MxAccessSession : IDisposable
|
|||||||
? baseSink.ValueCache
|
? baseSink.ValueCache
|
||||||
: new MxAccessValueCache();
|
: new MxAccessValueCache();
|
||||||
|
|
||||||
|
// Share the sink's completion cache the same way (the 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();
|
||||||
|
|
||||||
return new MxAccessSession(
|
return new MxAccessSession(
|
||||||
mxAccessComObject,
|
mxAccessComObject,
|
||||||
new MxAccessComServer(mxAccessComObject),
|
new MxAccessComServer(mxAccessComObject),
|
||||||
eventSink,
|
eventSink,
|
||||||
new MxAccessHandleRegistry(),
|
new MxAccessHandleRegistry(),
|
||||||
valueCache,
|
valueCache,
|
||||||
|
writeCompletionCache,
|
||||||
Environment.CurrentManagedThreadId);
|
Environment.CurrentManagedThreadId);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|||||||
|
|
||||||
public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||||
{
|
{
|
||||||
|
/// <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";
|
||||||
|
|
||||||
private static readonly TimeSpan AlarmPollInterval = TimeSpan.FromMilliseconds(500);
|
private static readonly TimeSpan AlarmPollInterval = TimeSpan.FromMilliseconds(500);
|
||||||
|
|
||||||
private readonly IMxAccessComObjectFactory factory;
|
private readonly IMxAccessComObjectFactory factory;
|
||||||
@@ -157,6 +165,32 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public MxAccessEventQueue EventQueue => eventQueue;
|
public MxAccessEventQueue EventQueue => eventQueue;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bounded WriteSecured/WriteSecured2 completion wait handed to the
|
||||||
|
/// command executor at <see cref="StartAsync(string, int, CancellationToken)"/>.
|
||||||
|
/// Internal-settable as a test seam so Worker.Tests can shorten it
|
||||||
|
/// without env-var plumbing.
|
||||||
|
/// </summary>
|
||||||
|
internal TimeSpan WriteCompletionTimeout { get; set; } = ResolveWriteCompletionTimeout();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the write-completion wait from the launcher-provided
|
||||||
|
/// environment variable; a missing or invalid value falls back to
|
||||||
|
/// <see cref="MxAccessCommandExecutor.DefaultWriteCompletionTimeout"/>.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts the MXAccess COM session asynchronously.
|
/// Starts the MXAccess COM session asynchronously.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -208,12 +242,14 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
|||||||
session,
|
session,
|
||||||
new VariantConverter(),
|
new VariantConverter(),
|
||||||
alarmCommandHandler,
|
alarmCommandHandler,
|
||||||
// ReadBulk needs to pump Windows messages while it waits
|
// ReadBulk and the write-completion wait need to pump
|
||||||
// for the first OnDataChange callback so the inbound COM
|
// Windows messages while they wait for the inbound COM
|
||||||
// event can dispatch on this same STA thread. The pump
|
// callback (OnDataChange / OnWriteComplete) so it can
|
||||||
// step closes over staRuntime so it always pumps the
|
// dispatch on this same STA thread. The pump step
|
||||||
// pump tied to the apartment that owns this session.
|
// closes over staRuntime so it always pumps the pump
|
||||||
pumpStep: () => staRuntime.PumpPendingMessages()));
|
// tied to the apartment that owns this session.
|
||||||
|
pumpStep: () => staRuntime.PumpPendingMessages(),
|
||||||
|
writeCompletionTimeout: WriteCompletionTimeout));
|
||||||
|
|
||||||
return session.CreateWorkerReady(workerProcessId);
|
return session.CreateWorkerReady(workerProcessId);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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; empty otherwise.</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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user