Repository navigation
fix: publish a device's $state after its children in refresh_tree - #32
Conversation
refresh_tree() walked the tree pre-order, publishing a device completely -- description, nodes, state -- before recursing. The root therefore announced $state=ready while the children its own $description names had published nothing at all. On a 37-device enclosure tree the root went ready at publish 1 and the last child at publish 73. This is the normal path, not an edge case: on_connect calls refresh_tree() for SDK-owned clients on both the initial connect and every reconnect. Homie 5 invites a controller to gate on the root's $state, and one that does proceeded against a tree whose children had not described themselves. After a broker restart a previously-healthy consumer re-read a root claiming ready with nothing under it. Description and nodes now publish first, then descendants, then this device's own state. The set of messages is unchanged and only the order differs: on the same tree, 74 publishes and 74 unique topics before and after, identical topic sets, with the root's $state moving from index 1 to index 73. The regression test fails on main and passes here. The existing suite passes either way -- no test asserted the old order, which is why it survived.
…r assertion - ruff format collapse on child_states (the only CI blocker; our pin is 0.15.21) - delete Device._publish_self(), which this change left with zero callers and a docstring still claiming it serves the reconnect cascade - assert the state-after-children rule at depth 2 in the existing three-level test: a fix that reordered only the root passed all 546 tests as written - reframe the comment and CHANGELOG entry as a producer-side narrowing rather than a guarantee consumers may build on, and add the LWT/atomic-commit reason (a lost root makes every child lost, so the final publish is one atomic flip) - credit @cayossarian in the CHANGELOG, matching #10/#12/#20 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
dcj
left a comment
There was a problem hiding this comment.
Merging this, with four small fixups pushed to your branch rather than sending you back around for them. The diagnosis is right and the fix is the minimal correct one.
Crediting the part that made this reviewable in one pass: "74 publishes, 74 unique topics, identical topic sets" establishes up front that the message set is not in play and only sequence changed. I reproduced it independently, including with property values set, and it holds. The ordering direction also matches the convention's own add-children recipe, where a child is fully published before its parent announces. The pre-order walk inverted that on every reconnect, which is what made this the normal path rather than an edge case.
The strongest argument for your ordering is one your writeup does not make. After an ungraceful drop the LWT leaves the root retained as lost, and Homie 5 makes every child of a lost root lost too. Holding the root's $state until the recursion finishes therefore turns the whole refresh into one atomic commit, flipped by a single final publish. That is a commit barrier derived from the spec rather than asserted, and it is now the reason given in the code comment.
The part I want to push back on
You wrote in #31 that you defend against this consumer-side by "waiting for every declared descendant to describe itself before reporting ready," and characterised that as reasonable for a consumer but something the producer should not have made necessary. I would put it the other way around: that defence is mandatory and permanent, and as described it is still not sufficient, because it is a barrier rather than a loop. It runs once, passes, and stops reconciling. Commission a circuit a minute later and it is missed. The failure moves from startup to steady state, which makes it harder to find, not less real.
Three reasons no amount of producer-side ordering can retire that defence:
- Children are commissioned and decommissioned out of band, so there is no moment at which a producer knows it has published "all" its children. The strongest guarantee any producer can offer is per-transaction: within this cascade, my announcement follows the content it announces. That is a statement about a transaction, not about the world.
- Publish order does not survive retention. A consumer connecting afterwards receives the retained tree in broker-chosen order, and retained-set delivery order on SUBSCRIBE is unspecified. Your own broker-restart scenario is a late-subscriber case, so this change does not actually reach it.
- A declared child can be legitimately absent, having crashed or had its own LWT fire. "Declared" can never be made to mean "present."
There is also a case already in the SDK that breaks the same shape of parser and has nothing to do with child ordering: the content-hash suppression makes an unchanged $description a no-op, but it does not suppress the init to ready edge of an otherwise-empty transition. A consumer that waits for a $description after a ready edge can wait forever.
So $state=ready means "my own $description is current, you may act on it." It is not a rollup over the subtree, and it is not a barrier after which the tree stops changing. Our own Controller was never exposed to the bug you found precisely because it uses ready as a trigger to subscribe rather than a barrier to read, then lets each child's retained state cascade back through the same handler. Reconcile from current state on every update; never await message B because message A arrived.
That distinction had never been written down anywhere in this repo, which is the real root cause here: you read our observable behaviour, inferred a contract from it, and built against the inference. That is on us. I have written doc/consuming-a-homie-tree.md covering the producer-SHOULD / consumer-MUST asymmetry, what ready does and does not promise, and the three failure modes above. It lands right behind this.
The fixups
ruff formatcollapse onchild_states, the only CI blocker. Note our pin is 0.15.21, so ignore anything a local 0.16.x says about the Markdown files.- Deleted
Device._publish_self(). Your change left it with zero callers and a docstring still claiming it serves the reconnect cascade. - One extra assertion in the pre-existing
test_refresh_tree_three_levels. This one is worth explaining: your new test builds a flat two-level tree, and I confirmed that a fix which special-cased only the root, leaving intermediate devices on the old order, passes the entire 546-test suite as written. Two lines on the existing three-level fixture pin the recursive property, and they do fail on that mutant. - Reworded the code comment and CHANGELOG entry. Your version said the new order makes
ready"mean what it says at every level," and I did not want to ship that sentence, for the reasons above. Same change, narrower claim, plus your credit on the CHANGELOG line.
One thing I considered and deliberately did not do: publishing a root $state=init at the head of the cascade, to close the transient where children have a state and the root does not yet. It would propagate INIT down the effective-state table, so every controller would see the entire tree go non-ready on each broker reconnect, and Home Assistant availability would flap on the root's entities. The transient you introduce is a not-yet-arrived message, which every consumer must already tolerate; the one you removed was an active false claim. That is the right trade.
Ships as 0.18.1.
…de) (#34) Pure-fix release. No API change, no ebus-mqtt-client floor bump. - fix: Device.refresh_tree() now publishes a device's own $state after its descendants rather than before, so a device no longer announces ready while the children its $description names have published nothing (#31, #32). Thanks to @cayossarian. - docs: doc/consuming-a-homie-tree.md, the consumer-side contract guide (#33). Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
|
Follow-up: the thing I pushed back on now has an answer in the SDK, shipped in 0.19.0. I argued above that your descendant-wait is mandatory and permanent rather than a workaround, and that as described it is still insufficient because it is a barrier rather than a loop. Both halves of that are only fair if the SDK gives you something better to build on, and it did not. That was the actual gap your report exposed, so it is now closed: if controller.is_tree_complete("panel-1"):
... # every device transitively declared under panel-1 has described itself
controller.set_on_tree_ready_callback(lambda root: snapshot(root.device_id))
The part that matters for your parser: it will flip back to False, and So please do not take the first Two things it deliberately does not mean:
A declared cycle terminates rather than hanging, in case a mid-reconfiguration tree ever names one at you. Also in 0.19.0, and one of these is aimed at you specificallyReviewing #32 shook out four more issues, all now fixed:
|
Closes #31.
refresh_tree()walked the tree pre-order, publishing a device completely — description, nodes, state — before recursing to children. The root therefore announced$state=readywhile the children its own$descriptionnames had published nothing.self.publish_description(republish=True) self.publish_nodes() for child in list(self._children): child.refresh_tree() +self.publish_state() # was published before the recursionWhy it is the normal path
on_connectcallsrefresh_tree()for SDK-owned clients, on both the initial connect and every reconnect — so this is not a bring-your-own-transport edge case. Homie 5 invites a controller to gate on the root's$state, and one that does proceeded against a tree whose children had not yet described themselves. After a broker restart, a previously-healthy consumer re-read a root claimingreadywith nothing beneath it.Order only — the messages are unchanged
Measured on a 37-device enclosure tree (root, 30 circuits, 2 lugs, BESS, PV, EVSE), capturing every publish through an injected transport:
$stateindex$stateindexNothing added, dropped or published twice.
Test
test_refresh_tree_publishes_own_state_after_its_childrenasserts the root's$statefollows both children's, and that the root's$descriptionstill leads so the tree's shape reaches the broker before anything claims ready.It fails on
mainand passes here. The existing suite passes either way (546 passed, 2 skipped) — no test asserted the old order, which is why it survived.Context, since it is only fair
Found while moving a downstream simulator off a hand-rolled publish path onto the SDK's, following the README's bring-your-own-transport section. That section and
asyncio_driver()inebus-mqtt-client0.4.0 are exactly what our host needed — we had assumed an asyncio host could not use the SDK's synchronous transport, and had been carrying a parallel implementation because of it. Being able to delete our own topic derivation is the better outcome for everyone: one fewer place independently deriving Homie topics.