Skip to content

Buffer state_changed events across the sync window and preserve delivery order - #21

Merged
adborbas merged 2 commits into
mainfrom
fix/sync-realtime-ordering
Jul 16, 2026
Merged

Buffer state_changed events across the sync window and preserve delivery order#21
adborbas merged 2 commits into
mainfrom
fix/sync-realtime-ordering

Conversation

@adborbas

Copy link
Copy Markdown
Owner

Changes

  • Subscribe to state_changed before fetching the initial snapshot in both start() and resync(), buffering events that arrive during the fetch window and flushing them in arrival order once the snapshot lands. The flush runs on both the success and failure paths so no data is wiped and the buffer can't persist across a failed sync.
  • Removed the per-event unstructured Task in both the state_changed subscription and the connection-transition delegate, handling each callback synchronously via MainActor.assumeIsolated. HAKit dispatches both callbacks on callbackQueue = .main, so this preserves HAKit's serial delivery order.
  • Bounded bufferedEvents with a drop-oldest cap (maxBufferedEvents = 2000) plus a one-shot warning, so a stalled sync (live socket, unanswered getStates) can't grow the buffer without bound.
  • Extracted HAConnectionManager.handleTransition(_:) from the delegate body.

Why

  • Dropped events during the sync window: the snapshot was fully applied before the subscription was established, so any state_changed firing in that gap was never delivered and never reconciled — an entity could show a stale value (e.g. a light shown off after being turned on) until the next full resync. Subscribe-first buffering closes the gap.
  • Reordering under load: wrapping each serially-delivered callback in its own unstructured Task discards HAKit's ordering guarantee. Back-to-back changes for one entity could apply out of order (persisting the older state), and connection flapping could compute the reconnect check against stale state and miss or spuriously fire onReconnect.

Notes

  • Unverified against a live HA server (both findings are code-analysis-only and not reproducible in demo mode, which has no live event stream); verification is limited to unit-level buffering/ordering logic.
  • Resync liveness tradeoff (intentional): during a resync (foreground / pull-to-refresh / reconnect), live updates are now held until the snapshot flushes rather than applied immediately. Nothing is lost and order is preserved on flush; this is the necessary cost of closing the reconnect gap and is documented in resync().
  • Not included: an app-wide timeout on fetchSyncPayload for a hung sync — that changes sync-failure semantics broadly and is better scoped as its own task.
  • Tests: HADataSyncBufferingTests (buffer/flush ordering, last-writer-wins, drop-oldest cap) and HAConnectionManagerTransitionTests (reconnect fires once, never spuriously). Full HemeraTests suite green (374 tests).

adborbas and others added 2 commits July 16, 2026 22:36
…ery order

Subscribe to state_changed before fetching the initial snapshot so events
firing during the network-bound fetch window are buffered and flushed in
arrival order once the snapshot lands, instead of being dropped and leaving
entities stale until the next resync. resync() re-arms buffering; the flush
runs on both the success and failure paths so the buffer can't grow unbounded.

Remove the per-event unstructured Task in both the state_changed subscription
and the connection-transition delegate. HAKit delivers both callbacks
synchronously on .main (callbackQueue = .main), so handle them via
MainActor.assumeIsolated to keep HAKit's serial delivery order — a Task per
callback dropped that guarantee, risking stale-overwrite of newer state and
missed/spurious onReconnect under connection flapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the review of the buffer-then-flush change:

- Bound bufferedEvents with a drop-oldest cap (maxBufferedEvents) so a stalled
  sync — a live socket whose getStates never returns — can't grow the buffer
  without bound. Past the cap the oldest events are dropped and a one-shot
  warning is logged; the snapshot re-baselines every entity on flush, so only
  intermediate states of a fast-changing entity during the stall are lost.
- Document the resync buffering tradeoff: live updates are held (not lost)
  until the snapshot lands, so a foreground/pull-to-refresh resync briefly
  pauses live UI updates. This is the intended cost of closing the reconnect
  gap; it is bounded, self-healing, and order-preserving.

Adds a test asserting the oldest event is evicted once the cap is exceeded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@adborbas
adborbas force-pushed the fix/sync-realtime-ordering branch from afa9289 to da28eb0 Compare July 16, 2026 20:37
@adborbas
adborbas merged commit 1db61fe into main Jul 16, 2026
1 check passed
@adborbas
adborbas deleted the fix/sync-realtime-ordering branch July 16, 2026 20:37
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.

1 participant