Skip to content

Remove old device on DB worker during re-interview - #1899

Draft
TheJulianJES wants to merge 3 commits into
devfrom
zigpy-bot/reinterview-remove-via-worker
Draft

TheJulianJES wants to merge 3 commits into
devfrom
zigpy-bot/reinterview-remove-via-worker

Conversation

@TheJulianJES

@TheJulianJES TheJulianJES commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

DRAFT.

ControllerApplication._device_reinterviewed() deleted the old device by awaiting PersistingListener._remove_device() directly, outside the database worker queue. Since #1835, that path ends with a forced commit, so it can land between two statements of whatever handler the worker is running (e.g. _neighbors_updated's DELETE and INSERT), committing that handler's partial writes. This came up in the #1835 review rounds.

The direct call was deliberate (#1789): the fire-and-forget device_removed() only enqueues the delete, while the re-interview needs it done before _finalize_device() enqueues the save of the new device. A queued delete already runs before that save, but a failed one would only be logged, and the save would then go ahead on top of the stale rows. So the re-interview still has to wait for the delete and see its error. Copilot's review on #1789 suggested enqueueing and awaiting completion, which is what this does.

Changes

  • New PersistingListener.remove_device(): it queues _remove_device like any other event, then waits until the worker has run it. An exception from the handler is raised to the caller instead of being logged by the worker, so a failed delete still aborts the re-interview, as before. If the caller was cancelled in the meantime, the error is logged like any other.
  • The worker queue items carry an optional future for this. enqueue() passes None, so fire-and-forget events are handled as before, with one exception: once the worker task has stopped, enqueue() discards them, because nothing would mark them done and shutdown() would hang in join().
  • The worker looks up the handler inside its error handling, so an unknown handler name is logged instead of stopping the worker. If the worker does stop while the listener is still running, it logs an error, since every later write is dropped from then on.
  • If the worker stops, callers still waiting get a RuntimeError instead of waiting forever. This covers both the job being run and any queued behind it. Those jobs are also marked done, so a later shutdown() doesn't hang in join(). remove_device() also raises RuntimeError up front if the listener is shutting down or the worker is gone.
  • _device_reinterviewed() awaits remove_device().

The delete now runs in order after every write queued before it, including pending writes for the old device, which it then removes. The new device's save is still enqueued only after the delete has finished.

One limitation is unchanged: if the re-interview is cancelled after the delete has been queued, the delete still runs and the new device is never saved. The old direct call behaved the same way, because the DELETE kept running on aiosqlite's thread and was committed by the next commit.

Testing: new tests cover remove_device():

  • it waits behind an earlier queued handler and returns only after the delete is committed, and while it waits, that handler's partial write is not committed (the bug this fixes);
  • it raises the handler's error and doesn't also log it;
  • a caller cancelled while waiting doesn't break the worker, and its handler's error is still logged;
  • it refuses to run while the listener shuts down;
  • waiting callers get an error when the worker stops, the stop is logged, later events are discarded, and shutdown() still completes afterwards, while a normal shutdown is not logged as an unexpected stop.

Another test, with two cases, pins the logging of fire-and-forget handler errors, which had no test before, and one more checks that an unknown handler name is logged and doesn't stop the worker. Each test fails when the behaviour it covers is removed, and the waits on remove_device() and on the worker have timeouts. Full suite: 1358 passed.

`_device_reinterviewed` awaited `PersistingListener._remove_device()`
directly, outside the worker queue. Its forced commit could therefore land
between two statements of another handler, committing that handler's
partial writes.

Add `PersistingListener.remove_device()`, which queues the removal like any
other event but waits until the worker has run it and raises its exception
to the caller. The re-interview keeps its guarantee that a failed delete
aborts it, and the delete now runs in order with every other database write.

Errors that nobody waits for, including a waited handler whose caller was
cancelled, are logged as before. If the worker stops, every waiting caller
gets a `RuntimeError` instead of waiting forever, and the unfinished jobs
are marked done so that `shutdown()` does not hang on them.
@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.51%. Comparing base (b4543ff) to head (e196bd2).

Additional details and impacted files
@@           Coverage Diff           @@
##              dev    #1899   +/-   ##
=======================================
  Coverage   99.51%   99.51%           
=======================================
  Files          61       61           
  Lines       12773    12797   +24     
=======================================
+ Hits        12711    12735   +24     
  Misses         62       62           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Events enqueued after the worker stops can still make database shutdown hang.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Serializes re-interview device deletion through the database worker while preserving error propagation.

Changes:

  • Adds queued, awaitable database operations.
  • Updates re-interview deletion to use the worker.
  • Adds worker failure and ordering tests.
File Description
zigpy/​application.py Uses queued device removal.
zigpy/​appdb.py Adds awaitable queue results and cleanup.
tests/​test_application.py Updates re-interview mocks.
tests/​test_appdb.py Tests ordering, errors, cancellation, and shutdown.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread zigpy/appdb.py
`running` stays true if the worker stops on its own, so events enqueued
afterwards would never be marked done and `shutdown()` would hang in
`join()`. Discard them, as `remove_device()` already refuses to wait.
An unknown handler name raised outside the worker's error handling and
stopped the worker. Since events are discarded once the worker is gone,
every later write would then be dropped with only a debug message. Look the
handler up inside the error handling, and log an error if the worker stops
while the listener is still running.

Each queue item now marks itself done and fails its waiting caller in its
own `finally`, which replaces the `in_progress` flag. A new test checks that
the blocked handler's partial write is not committed while `remove_device()`
waits, the bug the queued removal fixes.

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

Database worker cancellation and persistence ordering changes warrant final human concurrency review.

Review effort: Balanced
Findings: None

Resolved since last review (1)

@zigpy-review-bot zigpy-review-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No blocking findings. The re-interview delete now runs on the DB worker in queue order, and its errors still reach the caller. zigpy-review-bot wrote these commits, so this review is a COMMENT and not an approval. The approval should come from a maintainer.

Verified (6 checks)
  • Full suite at e196bd2: 1358 passed. zha's suite (current dev) with this branch's zigpy installed editable: 1458 passed.
  • test_remove_device_waits_for_queued_work fails when remove_device() is changed back to call _remove_device() directly (assert not remove_task.done()), so it guards the bug this PR fixes.
  • Worker accounting: every item taken off the queue gets exactly one task_done(). The item being run gets it in the inner finally, and the items behind it get it in the drain loop. A cancellation that lands in get() takes no item and calls no task_done(). CancelledError isn't caught by except Exception, so a stopping worker still reaches _fail_pending_result() for the waiting caller.
  • A cancelled caller cancels its future, so result.done() is true and the handler's error goes to the normal logging (pinned by test_remove_device_cancelled_caller_error_is_logged).
  • Ordering: old_device.remove_listener() runs before the delete is queued, so no new device-level events for the old device arrive after that. Events already queued for it run before the delete, and the delete then removes their rows. The old direct call could interleave with them instead.
  • Nothing downstream touches _remove_device, _callback_handlers, or the queue's item shape (grep of zha, zha-device-handlers, bellows, zigpy-znp, zigpy-deconz, and HA's zha component). No listener event is named remove_device, so the new public method can't be dispatched as a listener callback by accident.

One optional note inline.

Comment thread tests/test_application.py
# Set up a mock DB listener
db_listener = MagicMock()
db_listener._remove_device = AsyncMock()
db_listener.shutdown = AsyncMock()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Optional: these db_listener.shutdown = AsyncMock() lines (here, L1940 and L1999) do nothing on this branch alone, because the app fixture doesn't shut the app down yet. They start to matter once #1900 adds await app.shutdown() to the fixture teardown. Without them the tests still pass, but ControllerApplication.shutdown() awaits a plain MagicMock and logs "Failed to disconnect from database". A short comment, or a line in the PR body, would keep someone from removing them as dead code in the meantime.

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

Labels

bugfix This PR fixes a bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants