Keep startup polling failures isolated#841
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## dev #841 +/- ##
=======================================
Coverage 97.27% 97.27%
=======================================
Files 55 55
Lines 10941 10949 +8
=======================================
+ Hits 10643 10651 +8
Misses 298 298 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
An alternative approach here would be to disable concurrency limiting entirely and make sure that device state polling is done with low priority: async with self.request_priority(t.PacketPriority.LOW):
# code to poll a deviceMaybe we can add this as a kwarg to Zigpy has a mechanism to control concurrency if a request is properly tagged with the above context manager. |
|
@puddly Thanks — I updated the PR to take this approach. ZHA no longer limits complete device-initialization pipelines during startup. It starts every eligible initializer as LOW-priority work and delegates actual radio-request scheduling to zigpy and the radio backend. The revised call site is here. In abbreviated form, it is now: startup_polling_priority = t.PacketPriority.LOW
async with self.request_priority(startup_polling_priority):
initialization_results = await asyncio.gather(
*(
device.async_initialize(
from_cache=False,
request_priority=startup_polling_priority,
)
for device in online_devices
),
return_exceptions=True,
)Why removing the ZHA-level limiter is the better boundaryThe previous implementation inspected zigpy's private
Zigpy now owns the latter concern. Its request-priority context supplies the ambient priority, and its global request limiter resolves that context when a backend submits a request without an explicit priority. The limiter itself is a priority-aware cascading limiter, rather than a single undifferentiated semaphore. With zigpy's default concurrency of 8 and 25% LOW / 50% NORMAL / 25% HIGH capacity fractions, the cumulative admission thresholds are 2 for LOW, 6 for LOW plus NORMAL, and 8 when HIGH is included. These are cascading priority thresholds, not a strict total-concurrency ceiling for every possible arrival ordering, but the important distinction is that startup traffic now enters the LOW tier instead of consuming an arbitrary number of whole-device slots. For common configured values, the structural difference looks like this:
Those columns are intentionally not performance equivalents: the old limit covered the entire initialization pipeline, while the new tier governs individual radio requests. The latter is the abstraction we actually want. The old ZHA reservation heuristic came from ZHA #510; zigpy subsequently gained its priority-aware request limiting in zigpy #1635, so the scheduling responsibility can now live at the lower layer.
There is a subtle distinction between the priority used to acquire a local limiter and the priority stored on the outgoing packet:
That would make a context-only version look correct inside ZHA while Ziggurat still received the startup reads as normal interactive traffic. The revised implementation closes that gap:
Backend behavior
The explicit propagation currently covers the radio-producing work in Concrete behavior on a larger networkConsider 50 recently seen mains-powered devices during startup:
F4 failure and cancellation semantics remain intactRemoving the outer concurrency helper does not undo the original corrective behavior:
So I agree with the suggested direction and have changed the implementation accordingly: ZHA expresses startup polling as LOW-priority work, zigpy/backends own radio scheduling, and Ziggurat receives the priority explicitly on the packet rather than accidentally treating startup reads as NORMAL. |
There was a problem hiding this comment.
Approve. Nicely reasoned and thoroughly tested — verified the load-bearing pieces locally against zigpy 2.0.1 and CI is green across 3.12/3.13/3.14.
What I checked:
request_priorityis genuinely wired through, not dead code. Tracedinitialize_cluster_configs→cluster.read_attributes(priority=…)→read_attributes_raw→_read_attributes/general_command(**kwargs)→Cluster.request(priority=…)→ the packet. When no priority is passed the packet'sprioritystaysNone(the ambient contextvar only feeds zigpy's per-request limiter fallback — it's never written onto the packet), and on-packet-priority backends like zigpy-ziggurat treat thatNoneas NORMAL, so the explicit propagation is what actually putsLOWon the wire.test_initialize_request_prioritylocks this in.- Dropping the
radio_concurrency - 4reservation is reasonable. For backends that route through zigpy's global request limiter (bellows/ZNP/deCONZ),RequestLimitercaps the LOW tier structurally — with the default 0.25/0.50/0.25 fractions and concurrency 8 the cumulative caps are LOW=2, LOW+NORMAL=6, +HIGH=8, so startup reads occupy at most 2 slots and interactive NORMAL/HIGH work retains reserved capacity (more than the old-4left at default concurrency). Caveat: those fractions are user-configurable, and backends that don't call zigpy's limiter (zigpy-xbee/zigate) lose ZHA-level startup limiting entirely — which the PR calls out as an intentional trade-off rather than reinstating a duplicate whole-device limiter. - Failure isolation and cancellation semantics are correct.
gather(return_exceptions=True)awaits every sibling before returning, so one device's failure can no longer orphan the rest or skip the polling re-enable (the real pre-existing bug).zip(strict=True)over order-preserving results is sound, theisinstance(CancelledError)-before-Exceptionordering is right (CancelledError isBaseException), and outer cancellation still propagates through the gather and re-raises — withfinally: allow_polling = Truenow guaranteeing polling is restored on any exit. The three new gateway tests exercise exactly these paths. gather_with_limited_concurrencyis still used inhelpers.py; only the now-unusedradio_concurrencyproperty is removed, with no remaining references in zha (or ha-core's ZHA component).- mypy clean on the changed files.
Summary
Startup polling of recently seen mains-powered devices is intended to be best effort. A failure from one device should not terminate supervision of the remaining refreshes or prevent normal polling from being enabled afterward. Startup radio traffic should also be scheduled through zigpy's request-priority mechanism rather than a second whole-device concurrency limit in ZHA.
This change:
PacketPriority.LOW;Problem
During startup, ZHA temporarily disables normal polling while it refreshes the current state of recently seen mains-powered devices.
Previously, those refreshes ran through a ZHA-specific limited-concurrency gather. Its default fail-fast behavior propagated the first unexpected device exception without cancelling or awaiting the remaining initializer tasks. Sibling refreshes could therefore continue issuing Zigbee requests after the supervising startup operation had ended.
The same exception skipped the later assignment that enables normal polling. Since availability and polling-based update paths consult this shared flag, one failing device could leave unrelated devices without normal periodic refreshes for the remainder of the gateway's lifetime.
The whole-device limit also duplicated scheduling policy now owned by zigpy's priority-aware request limiter and required ZHA to inspect zigpy's private concurrency state.
Root cause
The startup call site combined three independent concerns:
There was also a packet-priority propagation gap relevant to zigpy-ziggurat. An ambient request-priority context controls backends that consult zigpy's context-aware limiter, but an unspecified request priority remains
Noneon the outgoing packet. zigpy-ziggurat interprets that missing packet value as NORMAL priority. Startup attribute reads therefore need the LOW priority passed explicitly as well as established in the ambient context.Resolution
Startup polling now establishes
PacketPriority.LOWinside the operation itself and starts all eligible device initializers withasyncio.gather(..., return_exceptions=True). Zigpy and priority-aware radio backends control actual request scheduling.Device.async_initialize()accepts an optional keyword-only request priority and forwards it through aggregated cluster initialization to each startup attribute read. Existing callers omit the argument and retain their current behavior. Startup polling supplies LOW explicitly, ensuring packet-priority consumers such as zigpy-ziggurat receiveLOWrather than treating an unspecified packet priority asNORMAL.Because gather results preserve input order, each failure or cancellation is associated with the correct device while every sibling initializer remains supervised and awaited.
The background startup operation restores
allow_pollingin afinallyblock. Unexpected orchestration failures are reported, whileasyncio.CancelledErroris explicitly re-raised so reload and shutdown cancellation continue to behave normally.Potential real-world examples