d14a75dfefddd7734cc16d968ff4ae7d2f32b9e6
6 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
acea65eb96 | merge: MQTT browser+contracts review findings resolved (Driver.Mqtt.Browser-002..-005, Driver.Mqtt.Contracts-004..-007) | ||
|
|
93f70504bd |
fix(mqtt-contracts): strict retainSeed, real bounds instead of dead [Range]s, DateTime edges, closed topic formatting
Driver.Mqtt.Contracts-004/-005/-006/-007. -004: retainSeed was the last behavioural key read leniently, so "retainSeed":"false" — what a CSV import produces — silently became the default TRUE, the INVERSE of the authored intent. Both mappers now read it through TryReadBoolStrict (the qos absent/valid/present-but-invalid split) and DescribeRejection names it. The authoring side needed no hand-mirrored rule: MqttTagConfigModel.Validate already consults this same parse through TagConfigParseGate, so the save is blocked with the inspector's wording — pinned by a new AdminUI parity theory pair. -005: the seven [Range] attributes on MqttDriverOptions/MqttPlainOptions never ran — the options are bound by JsonSerializer, and nothing in the four MQTT projects calls Validator.TryValidateObject. Two of them CONTRADICTED the real authority (KeepAlive's [Range(1,…)] forbade the zero Normalize admits as legal MQTT; MaxPayloadBytes' forbade the non-positive value its own remarks document as falling back to 1 MiB), so wiring them up would have broken deliberate behaviour. Deleted, with each property naming where its bound actually lives. The one escape they papered over is closed: an out-of-range defaultQos rode into the SUBSCRIBE verbatim, and MqttSubscriptionManager's constructor now clamps it exactly as the AdminUI driver form already persists. -006: the DateTime write arm cast a negative long to ulong, so 1969-12-31 published a timestamp ~584 million years in the future; it is now refused as ValueNotRepresentable. An Unspecified kind was handed to ToUniversalTime(), which reads it as LOCAL — the same authored instant encoded differently on the two nodes of a redundant pair; it is now read as UTC. -007: Format/FormatState enforced only null/empty, and nothing at all on deviceId, so the type was not round-trip-closed. They now apply the parser's IsValidSegment (plus '/', which only the format side can see). MqttWriteEncoder's own guards stay — TryEncode returns an error rather than throwing, and that contract does not change. |
||
|
|
2298485bda |
fix(mqtt): a reconnect that re-establishes nothing tears the session down
Driver.Mqtt-007 (Low, error handling). OnReconnectedAsync throws when a re-subscribe is granted zero filters — MqttConnection's contract is that a throwing Reconnected subscriber tears the fresh session down and retries under backoff, because a connected-but-deaf driver is the one outcome this path must never serve. But when the live subscribed topics resolved to zero filters in the first place it logged a warning and returned, reaching the same "nothing was re-established" state by a different route and giving it the opposite answer. Throw there too, and say so in the remarks so the type states ONE rule. It costs no other tag — every live topic is in the set that resolved to nothing, so there is nothing left flowing to protect, which is the whole reason the partial- rejection case is exempt — and it is not permanent: the retry runs against whatever Register has published by then. Test reaches the state the only way production can: Register prunes _subscribedTopics of the topics its new table no longer carries, but a SUBSCRIBE already in flight records its own topic AFTER that prune ran, so a deploy landing inside the round-trip leaves a live topic no authored tag resolves to. Paired with the control that a reconnect with no live subscriptions at all stays a no-op — throwing there would tear down the session of every driver whose tags have not been subscribed yet. |
||
|
|
2b4b3fcbbf |
fix(mqtt): session-rebind subscriptions, re-init teardown, timeout clamp
Findings from the per-module review of Driver.Mqtt at
|
||
|
|
abf3116bd4 |
fix(mqtt): wildcard tags went dark on reconnect — index by filter, not topic
C1 (CRITICAL). AuthoredTable routed any wildcard-authored tag into `Wildcards`
and never into the concrete topic index. But the two paths that reconstruct
state from a SUBSCRIBED FILTER STRING — OnReconnectedAsync's re-subscribe set
and DegradeTopic — both looked up that concrete-only index, and
`_subscribedTopics` keys on the wildcard PATTERN ("f/+/temp"). So:
- reconnect resolved zero filters for a wildcard tag, hit a silent early
return, and issued NO subscribe. The cache keeps its last Good value, so the
tag never turns Bad — it just stops updating forever behind a connection
reporting healthy. Exactly the silent-death mode MqttConnection's own remarks
warn about, left unguarded for wildcards.
- DegradeTopic missed too, so a SUBACK rejection of a wildcard filter degraded
nothing and left the tag at BadWaitingForInitialData.
Fix: AuthoredTable now carries `ByFilter` (EVERY def, keyed by its authored
topic filter, wildcards included) alongside `ByExactTopic` (concrete only) and
`Wildcards`. Delivery matches an incoming published topic — which never carries
a wildcard — so it keeps using ByExactTopic + the comparer scan. Every path
keyed on a filter string uses ByFilter. The distinction is documented on the
record. The silent early return is now a Warning naming the orphaned topics.
Also in this pass:
- I2: bounded decode on the dispatcher thread. A body over `MaxPayloadBytes`
(default 1 MiB, ctor-settable) is refused BEFORE any GetString/JsonDocument
parse and degrades its own tags. Unbounded decode on the shared dispatcher is
paid by every subscription, not just the offending topic. NOTE: promoting this
to an operator-facing MqttDriverOptions key (+ WithMaximumPacketSize) needs a
.Contracts edit this task is scoped out of; flagged in the XML docs.
- I3: integral-valued reals now coerce to integer tags. JS/Python edge gateways
serialize an integer as 5.0, and TryGetInt32/int.TryParse are syntactic — an
Int32 tag fed by such a gateway silently never received data. Parsed via
decimal so integrality and range are exact across Int64/UInt64. A genuinely
fractional 5.5 is still refused, never rounded.
- I4: the JSON document is parsed ONCE per message and shared across the whole
fan-out (the documented "one document, one JSONPath per signal" shape was
re-parsing per tag). Zero-alloc via a ref struct when no Json tag matches.
- I5: pinned the documented JSON-string-holding-a-number coercion end-to-end.
- Minor: Register now prunes `_subscribedTopics` / `_handleByRawPath` entries for
tags a redeploy dropped, so a deleted topic stops being re-subscribed forever.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
|
||
|
|
d421487bcd |
feat(mqtt): plain subscribe→OnDataChange with retained seed + ref resolver
MqttSubscriptionManager is the P1 plain-MQTT ingest path: it holds the authored RawPath → MqttTagDefinition table behind the shared EquipmentTagRefResolver, establishes one SUBSCRIBE per distinct authored topic, and turns each inbound message into a LastValueCache update plus an OnDataChange notification. Load-bearing choices, each pinned by a falsified test: - The published reference IS the RawPath (def.Name), never the topic and never the TagConfig blob — DriverHostActor's dual-namespace fan-out is RawPath-keyed, so any other key passes every unit test and delivers nothing in production. - One message fans out to EVERY tag on its topic; several tags routinely share a topic with different jsonPaths, and stopping at the first match silently starves the rest. - An unauthored topic raises nothing: MQTT ingest never auto-provisions. - Wildcard-authored topics (accepted by the parser, warned at deploy) match via MQTTnet's own MqttTopicFilterComparer, so '+'/'#' behave as a broker would. Concrete topics stay a single lookup; the wildcard scan runs only when one exists. - Retained seeds are honoured natively on MQTT 5.0 (retain handling) AND filtered client-side on the retain flag, because 3.1.1 brokers always replay. - Nothing throws on MQTTnet's dispatcher thread: a malformed payload degrades that tag (BadDecodingError / BadTypeMismatch), and a throwing OnDataChange subscriber is contained without starving the tags behind it. - The manager issues the FIRST subscribe itself — Reconnected deliberately does not fire after the initial ConnectAsync. - Reconnect re-subscribe: total failure THROWS (MqttConnection then tears the session down and retries under backoff, rather than serving a connected-but-deaf driver); a partial SUBACK rejection degrades only the denied refs, because retrying forever over one ACL-denied topic would take every tag dark. MqttConnection gains a MessageReceived observer (fired on the dispatcher, throwing observers contained) and a bounded SubscribeAsync under its own linked-CTS deadline — deliberately not MqttClientOptions.Timeout, which already governs every MQTTnet op. A refused filter is an outcome, not an exception; only the SUBSCRIBE itself failing throws. Classify() is parameterised by operation name (messages unchanged for connect). JSONPath is a documented subset ($, $.a.b, $['a'], $.a[0]); filters/slices/ recursive descent report a miss rather than pulling in a JSONPath package for an expression form that selects a set where a tag needs one scalar. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW |