feat(raw): hot-rebind a renamed raw tag's live value without a node recreate #489

Closed
opened 2026-07-21 03:36:41 -04:00 by dohertj2 · 2 comments
Owner

Deferred follow-up from v3 Batch 4 (docs/plans/2026-07-16-v3-batch4-PR.md).

Renaming a raw tag correctly rewrites the address space on deploy — the old raw NodeId goes, the new one appears, and the planner re-points the referencing UNS node. This was live-verified during the Batch 4 gate (HR200 → HR200X).

But the renamed tag's live value does not rebind until the node is recreated/restarted. Current operational workaround is deploy-THEN-recreate, which is documented but is a sharp edge: an operator who renames a tag and does not restart sees a materialised node that never receives data.

Related sharp edge recorded in the same gate: an absolute RawPath script literal pointing at the old name correctly fails Bad 0x80000000 after the rename — that part is working as intended and is a separate concern from the value rebind.

Deferred follow-up from v3 Batch 4 (`docs/plans/2026-07-16-v3-batch4-PR.md`). Renaming a raw tag correctly rewrites the address space on deploy — the old raw NodeId goes, the new one appears, and the planner re-points the referencing UNS node. This was live-verified during the Batch 4 gate (`HR200` → `HR200X`). **But the renamed tag's live value does not rebind until the node is recreated/restarted.** Current operational workaround is deploy-THEN-recreate, which is documented but is a sharp edge: an operator who renames a tag and does not restart sees a materialised node that never receives data. Related sharp edge recorded in the same gate: an **absolute** RawPath script literal pointing at the old name correctly fails `Bad 0x80000000` after the rename — that part is working as intended and is a separate concern from the value rebind.
Author
Owner

Code-trace only — NOT verified live, and deliberately not "fixed" on that basis. Recording it so the next attempt starts from evidence rather than re-deriving it.

Tracing the rename path on current master (123ddc3f), the rebind plumbing appears to be complete, which means the symptom recorded at the Batch 4 gate is either already fixed by later work or has a different cause than "the maps are stale":

  1. Tag.Name is part of the RawPath, and the RawPath is inside the driver's merged config — DeploymentArtifact folds DriverConfig + per-device DeviceConfig + the driver's RawTags into one blob via DriverDeviceConfigMerger.Merge. So a rename changes DriverInstanceSpec.DriverConfig.
  2. DriverSpawnPlan.Compute compares snap.LastConfigJson to spec.DriverConfig (Ordinal) → the driver lands in ToApplyDelta.
  3. ApplyChildDelta Tells ApplyDelta; DriverInstanceActor.HandleApplyDeltaAsync is a ReceiveAsync, so the child's mailbox is suspended until ReinitializeAsync completes. For ModbusDriver that is Shutdown + Initialize, which clears and rebuilds _tagsByRawPath from the new RawTags.
  4. ReconcileDriversFromBlob runs before PushDesiredSubscriptionsFromArtifact in the same apply, and both Tell the same child. Mailbox ordering therefore guarantees SetDesiredSubscriptions (carrying the new RawPaths) is processed after reinit — and its Connected handler self-Tells Subscribe, which drops the prior handle and re-subscribes.
  5. PushDesiredSubscriptions also Clear()s and rebuilds _nodeIdByDriverRef / _driverRefByNodeId from the new composition, so the value fan-out is re-pointed too.

If the value still fails to rebind, the interesting suspects are therefore not the maps but: the re-subscribe racing the driver's poll-engine rebuild, or the OPC UA node being re-materialised while the old subscription's last value is still latched.

Why I stopped rather than shipping a change: the whole chain above says a redeploy already rebinds, so any "fix" written now would be aimed at a mechanism I have not observed failing. Repro first — that was the lesson from #503, where the plausible hypothesis in the issue was wrong and the real cause was somewhere else entirely.

Repro recipe for the next attempt (docker-dev; the rig has pymodbus/plc/HR200X on driver MAIN-modbus):

  1. read ns=2;s=pymodbus/plc/HR200X -> confirm Good with a live value.
  2. Rename the tag (/raw UI or a Tag.Name UPDATE) and deploy — without restarting any node.
  3. read ns=2;s=pymodbus/plc/<NewName>. Bug reproduces iff this stays BadWaitingForInitialData/no data while the node keeps running.
  4. Restart the node and re-read to confirm the documented deploy-THEN-recreate workaround still fixes it.

Blocked here only because Docker Desktop on the dev machine went down mid-session (daemon reports no server version; open -a Docker did not bring it back), so the live step could not run.

Worth noting for whoever picks this up: #488's new periodic subscription reconcile does NOT cover this case. It re-subscribes only when the actor holds no handle; after a rename the handle is present (just pointing at the old ref set), which is the "handle present but dead" case explicitly deferred as option B in that issue.

**Code-trace only — NOT verified live, and deliberately not "fixed" on that basis.** Recording it so the next attempt starts from evidence rather than re-deriving it. Tracing the rename path on current master (`123ddc3f`), the rebind plumbing **appears to be complete**, which means the symptom recorded at the Batch 4 gate is either already fixed by later work or has a different cause than "the maps are stale": 1. `Tag.Name` is part of the RawPath, and the RawPath is inside the driver's **merged** config — `DeploymentArtifact` folds `DriverConfig` + per-device `DeviceConfig` + the driver's `RawTags` into one blob via `DriverDeviceConfigMerger.Merge`. So a rename **changes `DriverInstanceSpec.DriverConfig`**. 2. `DriverSpawnPlan.Compute` compares `snap.LastConfigJson` to `spec.DriverConfig` (Ordinal) → the driver lands in `ToApplyDelta`. 3. `ApplyChildDelta` Tells `ApplyDelta`; `DriverInstanceActor.HandleApplyDeltaAsync` is a **`ReceiveAsync`**, so the child's mailbox is suspended until `ReinitializeAsync` completes. For `ModbusDriver` that is `Shutdown` + `Initialize`, which clears and **rebuilds `_tagsByRawPath`** from the new `RawTags`. 4. `ReconcileDriversFromBlob` runs **before** `PushDesiredSubscriptionsFromArtifact` in the same apply, and both Tell the same child. Mailbox ordering therefore guarantees `SetDesiredSubscriptions` (carrying the **new** RawPaths) is processed *after* reinit — and its `Connected` handler self-Tells `Subscribe`, which drops the prior handle and re-subscribes. 5. `PushDesiredSubscriptions` also `Clear()`s and rebuilds `_nodeIdByDriverRef` / `_driverRefByNodeId` from the new composition, so the value fan-out is re-pointed too. If the value still fails to rebind, the interesting suspects are therefore **not** the maps but: the re-subscribe racing the driver's poll-engine rebuild, or the OPC UA node being re-materialised while the old subscription's last value is still latched. **Why I stopped rather than shipping a change:** the whole chain above says a redeploy already rebinds, so any "fix" written now would be aimed at a mechanism I have not observed failing. Repro first — that was the lesson from #503, where the plausible hypothesis in the issue was wrong and the real cause was somewhere else entirely. **Repro recipe for the next attempt** (docker-dev; the rig has `pymodbus/plc/HR200X` on driver `MAIN-modbus`): 1. `read ns=2;s=pymodbus/plc/HR200X` -> confirm Good with a live value. 2. Rename the tag (`/raw` UI or a `Tag.Name` UPDATE) and deploy — **without** restarting any node. 3. `read ns=2;s=pymodbus/plc/<NewName>`. Bug reproduces iff this stays `BadWaitingForInitialData`/no data while the node keeps running. 4. Restart the node and re-read to confirm the documented deploy-THEN-recreate workaround still fixes it. Blocked here only because Docker Desktop on the dev machine went down mid-session (daemon reports no server version; `open -a Docker` did not bring it back), so the live step could not run. Worth noting for whoever picks this up: **#488's new periodic subscription reconcile does NOT cover this case.** It re-subscribes only when the actor holds *no* handle; after a rename the handle is present (just pointing at the old ref set), which is the "handle present but dead" case explicitly deferred as option B in that issue.
Author
Owner

Closed as already fixed — by #516, not by new work here. Re-ran the repro recipe from the 2026-07-26 comment on docker-dev today. It does not reproduce.

Live evidence (docker-dev, central-1, 0 restarts, up since 14:58Z)

  1. Baseline read ns=2;s=pymodbus/plc/HR200X -> 3395, Good, live timestamp.
  2. UPDATE Tag SET Name='HR200Z' + POST /api/deployments. Sealed in 155 ms, all nodes acked. No node touched.
  3. read ns=2;s=pymodbus/plc/HR200Z -> 3485, Good, fresh source timestamp. Old NodeId HR200X -> 0x80340000 BadNodeIdUnknown (correct — it is gone). UNS projection ns=3;s=gate481/line1/press1/hr200 -> Good.
  4. Positive control against a single latched publish: a 20 s subscribe showed continuous ~1 Hz updates (3557, 3559, 3561 …), so data is flowing to the new node, not stuck at one materialisation-time value.
  5. docker inspect confirms 0 restarts, started 14:58:10Z — the rename+deploy at 15:56Z rebound with the node running throughout.

The mechanism is not what the code-trace predicted

The 2026-07-26 comment traced this to ApplyDelta -> ReinitializeAsync rebuilding _tagsByRawPath. That is not what happens. The node log shows a full child respawn:

15:56:06 DriverHost central-1:4053: stopped driver child MAIN-modbus
15:56:06 DriverHost central-1:4053: spawned Modbus driver MAIN-modbus (stub=False)
15:56:06 DriverInstance MAIN-modbus: connected
15:56:06 DriverInstance MAIN-modbus: subscribed to 2 refs (poll-sub-1)
15:56:06 AddressSpaceApplier: applied plan (kind=Rebuild, added=1, removed=1, changed=3, rebuild=True)

#516 is the fix. DriverSpawnPlan.Compute now moves a changed DriverConfig out of toDelta and into toStop + toSpawn, so the factory is the single parse authority. A rename changes Tag.Name, which is inside the RawPath, which DriverDeviceConfigMerger.Merge folds into the merged DriverConfig — so a rename always trips the respawn branch and the driver is rebuilt against the new RawPaths. The toDelta path the comment traced is now effectively unreachable for a config edit.

This is worth recording because it means the fix was a side effect of a change made for an unrelated reason (#516 was about five drivers silently discarding in-place config edits). Nobody would have found it by looking for it.

Also gated the sibling case the issue did not cover

RawPath has more segments than the tag name, so I renamed the device too (plc -> plcz, the middle segment). Identical result: ns=2;s=pymodbus/plcz/HR200Z served live ~1 Hz data after the deploy, same stop/spawn/subscribe log, no restart. So the whole rename family rebinds, not just the leaf.

One flip side, now documented

A rename reconnects the driver — briefly dropping every tag on it, not just the renamed one. That is the cost #516 explicitly accepts ("a correct reconnect beats a silent no-op"); it is not specific to renames, but an operator renaming one tag would not expect it, so it is now called out in docs/Raw.md.

Changes

Docs only, no code — 112f62e5 on master. docs/Raw.md gains the operator-facing fact + the reconnect caveat; the Batch-4 PR record's caveat at 2026-07-16-v3-batch4-PR.md:82 is marked superseded rather than rewritten. Rig restored to HR200X / plc (which was itself a third successful rename-rebind).

**Closed as already fixed — by #516, not by new work here.** Re-ran the repro recipe from the 2026-07-26 comment on docker-dev today. It does not reproduce. ## Live evidence (docker-dev, `central-1`, `0 restarts`, up since 14:58Z) 1. Baseline `read ns=2;s=pymodbus/plc/HR200X` -> `3395`, Good, live timestamp. 2. `UPDATE Tag SET Name='HR200Z'` + `POST /api/deployments`. Sealed in **155 ms**, all nodes acked. **No node touched.** 3. `read ns=2;s=pymodbus/plc/HR200Z` -> `3485`, **Good**, fresh source timestamp. Old NodeId `HR200X` -> `0x80340000` BadNodeIdUnknown (correct — it is gone). UNS projection `ns=3;s=gate481/line1/press1/hr200` -> Good. 4. Positive control against a single latched publish: a 20 s subscribe showed **continuous ~1 Hz updates** (3557, 3559, 3561 …), so data is *flowing* to the new node, not stuck at one materialisation-time value. 5. `docker inspect` confirms **`0 restarts`, started 14:58:10Z** — the rename+deploy at 15:56Z rebound with the node running throughout. ## The mechanism is not what the code-trace predicted The 2026-07-26 comment traced this to `ApplyDelta` -> `ReinitializeAsync` rebuilding `_tagsByRawPath`. That is **not** what happens. The node log shows a full child respawn: ``` 15:56:06 DriverHost central-1:4053: stopped driver child MAIN-modbus 15:56:06 DriverHost central-1:4053: spawned Modbus driver MAIN-modbus (stub=False) 15:56:06 DriverInstance MAIN-modbus: connected 15:56:06 DriverInstance MAIN-modbus: subscribed to 2 refs (poll-sub-1) 15:56:06 AddressSpaceApplier: applied plan (kind=Rebuild, added=1, removed=1, changed=3, rebuild=True) ``` **#516 is the fix.** `DriverSpawnPlan.Compute` now moves a changed `DriverConfig` out of `toDelta` and into `toStop` + `toSpawn`, so the factory is the single parse authority. A rename changes `Tag.Name`, which is inside the RawPath, which `DriverDeviceConfigMerger.Merge` folds into the merged `DriverConfig` — so a rename *always* trips the respawn branch and the driver is rebuilt against the new RawPaths. The `toDelta` path the comment traced is now effectively unreachable for a config edit. This is worth recording because it means the fix was a **side effect** of a change made for an unrelated reason (#516 was about five drivers silently discarding in-place config edits). Nobody would have found it by looking for it. ## Also gated the sibling case the issue did not cover RawPath has more segments than the tag name, so I renamed the **device** too (`plc` -> `plcz`, the middle segment). Identical result: `ns=2;s=pymodbus/plcz/HR200Z` served live ~1 Hz data after the deploy, same stop/spawn/subscribe log, no restart. So the whole rename family rebinds, not just the leaf. ## One flip side, now documented A rename **reconnects the driver** — briefly dropping every tag on it, not just the renamed one. That is the cost #516 explicitly accepts ("a correct reconnect beats a silent no-op"); it is not specific to renames, but an operator renaming one tag would not expect it, so it is now called out in `docs/Raw.md`. ## Changes Docs only, no code — `112f62e5` on `master`. `docs/Raw.md` gains the operator-facing fact + the reconnect caveat; the Batch-4 PR record's caveat at `2026-07-16-v3-batch4-PR.md:82` is marked superseded rather than rewritten. Rig restored to `HR200X` / `plc` (which was itself a third successful rename-rebind).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/lmxopcua#489