Skip to content

fix: publish a device's $state after its children in refresh_tree - #32

Merged
dcj merged 2 commits into
electrification-bus:mainfrom
cayossarian:fix/refresh-tree-state-order
Aug 7, 2026
Merged

dcj merged 2 commits into
electrification-bus:mainfrom
cayossarian:fix/refresh-tree-state-order

Conversation

@cayossarian

Copy link
Copy Markdown
Contributor

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=ready while the children its own $description names 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 recursion

Why it is the normal path

on_connect calls refresh_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 claiming ready with 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:

before after
publishes 74 74
unique topics 74 74
topic sets identical
root $state index 1 73
last child $state index 73 72

Nothing added, dropped or published twice.

Test

test_refresh_tree_publishes_own_state_after_its_children asserts the root's $state follows both children's, and that the root's $description still leads so the tree's shape reaches the broker before anything claims ready.

It fails on main and 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() in ebus-mqtt-client 0.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.

cayossarian and others added 2 commits August 6, 2026 22:43
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 dcj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 format collapse on child_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.

@dcj
dcj merged commit 6d9e20b into electrification-bus:main Aug 7, 2026
5 checks passed
dcj added a commit that referenced this pull request Aug 7, 2026
…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]>
@dcj

dcj commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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))

is_tree_complete(root_id) walks the declared tree and returns whether every device named transitively under it has published its own $description. Call it whenever you like, as often as you like.

The part that matters for your parser: it will flip back to False, and on_tree_ready re-arms. Commission a circuit after the tree first settles and the predicate goes False, then fires again once that circuit describes itself. A tree commissioned in stages produces one call per settled shape rather than one call ever.

So please do not take the first on_tree_ready as a barrier and stop listening. That rebuilds the exact failure I was describing, out of the API built to prevent it. It is called out in both docstrings for the same reason. The invariant to hold onto is that the predicate is idempotent and order-independent: run it on every update, in any order, as many times as you like, and it converges. It does not care whether a child published before or after its parent, whether a $description was suppressed by the content hash, or whether messages arrived live or retained.

Two things it deliberately does not mean:

  • Not liveness. A device counts as described once its $description parses, whatever its $state. A declared child sitting at lost has still told you what it is. get_effective_state() is the liveness answer, and it already implements Homie 5's rule that a non-ready root propagates down the tree.
  • Not permanence. It is true of the tree visible right now, and says nothing about the tree a second from now. That is the open child set showing through, not a defect. There is no moment at which a producer knows it has published all of its children, so no API can honestly promise otherwise.

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 specifically

Reviewing #32 shook out four more issues, all now fixed:

doc/consuming-a-homie-tree.md covers all of the consumer-side reasoning in one place now, including a worked version of the reconcile-do-not-await loop. If the parser defence you described is still in your codebase as a one-shot, that document plus on_tree_ready should let you delete it rather than port it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refresh_tree() publishes a device's $state before its children, so the root announces ready to an empty tree

2 participants