WIP: 🐛 Harden streaming energy path — 4 review findings (fix PR) #61

Closed
mat wants to merge 1 commit from fix/stream-robustness into feat/stream-resync
Owner

The deferred 'fix PR' for the Minor findings raised across the 5 streaming slices (#56-#60). Polish/robustness, no happy-path behavior change. Stacked on #60.

Fixes

  1. Thread-safety (reconciliation write-back). _fetch_device_metrics (executor) snapshotted live_total_wh, did the QUANTITY HTTP fetch (GIL released), then OVERWROTE live_total_wh with the stale snapshot — clobbering stream deltas that integrate_live_energy (event loop) added during the fetch. Now applies only the reconciliation drift as live_total_wh += (live_total - initial_live), narrowing the race from seconds to a nanosecond RMW. Test injects a concurrent +30 during the fetch and asserts the delta survives (120.0, not the old 90.0).
  2. live_by_hour unbounded growth. After reconciliation, prune hours strictly older than the high-water mark (reconciled hours are never re-read). list()-snapshots keys to stay safe vs the event loop mutating the current-hour key.
  3. _fold_capacity_map KeyError. cap["nature"]cap.get("nature") + skip if absent, so one malformed capacity can't fail the whole refresh.
  4. Coverage. Added a test for the dt_h <= 0 guard in integrate_live_energy (clock jump / duplicate burst: no delta, but last_power still advances). Code was already correct.

Tests

4 new tests (Fix 1 is a real old-vs-new gate) + 1 assertion updated (a pruned hour becomes not in, a correct consequence of Fix 2). 73/73 passing, ruff + mypy clean. stream.py/integrate_live_energy/UPDATE_INTERVAL/throttle/reconciliation-formula all unchanged.

Stacking

Base: feat/stream-resync (PR #60, tip of the streaming stack). Rebase down to main as the stack merges.

The deferred 'fix PR' for the Minor findings raised across the 5 streaming slices (#56-#60). Polish/robustness, no happy-path behavior change. Stacked on #60. ## Fixes 1. **Thread-safety (reconciliation write-back).** `_fetch_device_metrics` (executor) snapshotted `live_total_wh`, did the QUANTITY HTTP fetch (GIL released), then OVERWROTE `live_total_wh` with the stale snapshot — clobbering stream deltas that `integrate_live_energy` (event loop) added during the fetch. Now applies only the reconciliation *drift* as `live_total_wh += (live_total - initial_live)`, narrowing the race from seconds to a nanosecond RMW. Test injects a concurrent +30 during the fetch and asserts the delta survives (120.0, not the old 90.0). 2. **`live_by_hour` unbounded growth.** After reconciliation, prune hours strictly older than the high-water mark (reconciled hours are never re-read). `list()`-snapshots keys to stay safe vs the event loop mutating the current-hour key. 3. **`_fold_capacity_map` KeyError.** `cap["nature"]` → `cap.get("nature")` + skip if absent, so one malformed capacity can't fail the whole refresh. 4. **Coverage.** Added a test for the `dt_h <= 0` guard in `integrate_live_energy` (clock jump / duplicate burst: no delta, but last_power still advances). Code was already correct. ## Tests 4 new tests (Fix 1 is a real old-vs-new gate) + 1 assertion updated (a pruned hour becomes `not in`, a correct consequence of Fix 2). 73/73 passing, ruff + mypy clean. `stream.py`/`integrate_live_energy`/`UPDATE_INTERVAL`/throttle/reconciliation-formula all unchanged. ## Stacking Base: `feat/stream-resync` (PR #60, tip of the streaming stack). Rebase down to `main` as the stack merges.
🐛 fix: thread-safe reconciliation write-back (Fix 1-4)
All checks were successful
Validate / lint-ruff (pull_request) Successful in 7s
Validate / test-pytest (pull_request) Successful in 3m6s
Validate / type-check-mypy (pull_request) Successful in 3m11s
Validate / lint-ruff (push) Successful in 7s
Validate / test-pytest (push) Successful in 3m10s
Validate / type-check-mypy (push) Successful in 3m12s
db57c5e6f2
Fix 1 (thread-safety): _fetch_device_metrics snapshots initial_live before
the QUANTITY HTTP fetch and applies only the reconciliation drift as
state.live_total_wh += (live_total - initial_live), so concurrent stream
deltas added by integrate_live_energy during the fetch are not clobbered.

Fix 2 (live_by_hour pruning): after reconciliation, prune entries strictly
older than hwm_hour (state.last_bucket_ts truncated to the hour), using a
list() snapshot of keys to avoid mutation-during-iteration. Reconciled hours
below the high-water mark are never read again.

Fix 3 (_fold_capacity_map KeyError guard): read nature via cap.get('nature')
and skip capacities where nature is None, avoiding KeyError propagation as
UpdateFailed when a capacity has no nature key.

Fix 4 (test-only): add test_integrate_live_energy_skips_delta_on_non_positive_dt
to cover the dt_h <= 0 guard in integrate_live_energy.

Also update test_reconcile_across_multiple_new_buckets: the 10:00 bucket is
now pruned after reconciliation reaches hwm 11:00, so assert it is absent.
Author
Owner

Landed in main via the linear stack fast-forward (commit 9d016f7). This slice is part of the streaming feature merged as #56…#62 — closing (its commits are already in main).

Landed in `main` via the linear stack fast-forward (commit `9d016f7`). This slice is part of the streaming feature merged as #56…#62 — closing (its commits are already in `main`).
mat closed this pull request 2026-07-14 17:12:50 +00:00
All checks were successful
Validate / lint-ruff (pull_request) Successful in 7s
Validate / test-pytest (pull_request) Successful in 3m6s
Validate / type-check-mypy (pull_request) Successful in 3m11s
Validate / lint-ruff (push) Successful in 7s
Validate / test-pytest (push) Successful in 3m10s
Validate / type-check-mypy (push) Successful in 3m12s

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
mat/homeassistant-comwatt!61
No description provided.