fix(calculation): read cache carries real input status + timestamps

This commit is contained in:
Joseph Doherty
2026-08-20 10:40:15 -04:00
parent 48efdac600
commit 60e5b40228
6 changed files with 163 additions and 25 deletions
+35 -2
View File
@@ -81,10 +81,43 @@ Two deploy-time gates run over the result:
- **Nothing publishes until every declared dependency has arrived at least once.** Before that a read
returns `BadWaitingForInitialData`. This is the same gate `VirtualTagActor` uses, and it exists so a
calculation cannot publish a confident-looking value derived from defaults.
- **Equal results are deduped** — an evaluation producing the same value as last time does not
re-publish.
- **Equal results are deduped on (value, *status*)** — an evaluation producing the same value *and*
the same status as last time does not re-publish. The status half is load-bearing: a device that
comes back after an outage usually comes back holding the reading it had before, so a value-only
dedup would swallow the recovery and leave the node Bad forever.
- **Timers are grouped by interval**, one timer per distinct interval, not one per tag.
## Input quality
The contract is stated once, in
[`docs/DriverLifecycle.md` §"The quality contract"](../DriverLifecycle.md#the-quality-contract): a
value consumer never reports a quality better than its source's. A calc tag's inputs are other tags'
live values, so its output inherits their quality:
| Worst input status | What the driver does | What the calc tag publishes |
|---|---|---|
| Good | evaluates | the computed value, `Good` |
| Uncertain (any sub-code) | **evaluates** — an Uncertain value is present and usable | the computed value, stamped with that Uncertain sub-code |
| Bad (any sub-code) | **holds** — the script does not run | the **last known value**, stamped with that input's Bad sub-code, once per change of status |
| a dependency never pushed | holds (the pre-existing warm-up gate) | nothing — the node stays `BadWaitingForInitialData` |
Notes worth knowing before authoring against this:
- **The sub-code propagates**, so the output says *why*: `BadNoCommunication` means go look at the
device feeding the input; `BadInternalError` means this tag's own script failed.
- **Holding on Bad is what removes the NRE storm.** A comms-lost input used to arrive as *Good, null*,
so `(double)ctx.GetTag("x").Value` threw on every trigger and filled `/script-log` with identical
"script threw" entries. Now the script simply is not run, and **no script-log entry is written** — an
input dying is not a script fault.
- ⚠️ **A script that relied on receiving `null` for a dead input no longer runs at all** while that
input is Bad. Rewrite such a script to assume its inputs are usable; the driver has already decided
they are.
- **`ctx.GetTag(path).StatusCode` and `.SourceTimestampUtc` are real.** The read cache used to stamp
every input `Good` at evaluation time; it now passes the input's own snapshot through. Since Bad
inputs never reach a script, what a script can observe is Good or Uncertain.
- **`ReadAsync` reports the same status the subscription carried**, so a polled read and a subscribed
client never disagree about the same node.
## Error semantics
An evaluator failure publishes **Bad quality carrying the last-known value** — the value is not