Skip to content

Add connection pool with failover and keepalive - #4

Open
pjens45 wants to merge 7 commits into
DeakoLights:mainfrom
pjens45:connection-pool-failover
Open

Add connection pool with failover and keepalive#4
pjens45 wants to merge 7 commits into
DeakoLights:mainfrom
pjens45:connection-pool-failover

Conversation

@pjens45

@pjens45 pjens45 commented Apr 14, 2026

Copy link
Copy Markdown

Summary

Adds DeakoConnectionPool, a thin supervisor over two Deako
instances that keeps one bridge as the active primary and the
other warm as a failover standby. Failover is triggered by two
paths into a single coordinator inside the pool: _Manager's
ping loop (via the new on_connection_lost callback) and a
failed send inside control_device. There is no parallel
in-pool health monitor.

What is new, public-facing

  • pydeako.DeakoConnectionPool: high-level pool with start(),
    stop(), control_device(), set_state_callback(), state().
  • pydeako.ConnectionPoolState: frozen dataclass snapshot.
  • pydeako.NoSocketException: OSError subclass raised by
    _SocketConnection when there is no live socket to send on.
  • New kwarg on Deako.__init__: on_connection_lost.
  • New helper: Deako.is_connected().

Externally observable behavior change

NoSocketException is a new exception class that subclasses
OSError. _SocketConnection.send_bytes and read_bytes now
raise it when there is no socket to operate on. Callers that
previously caught a different exception type for that condition
will see NoSocketException instead; catches on OSError
continue to work.

This is the only non-additive change in the PR.

Key design points (round 2 changes)

  • All _AddressPool changes reverted. The pool did not need
    non-destructive reads.
  • _KeepAliveSocket composes _SocketConnection, so socket I/O
    stays in utils/_socket.py.
  • No in-pool health monitor. _Manager pings. When a pong is
    missed, the pool's on-lost hook schedules one failover switch.
  • Bridge readiness on the new primary is detected with a bounded
    TCP-probe poll, capped by a module-level timeout, rather than a
    blind sleep.
  • asyncio.Lock + asyncio.Event coordinate the switch: the
    lock serializes, the event signals completion to in-flight
    callers so they either land on the new primary or fail cleanly
    on timeout.
  • ConnectionPoolState is frozen=True.
  • Idempotent start() / stop().
  • Sync state callbacks only in this PR.
  • Module logger uses __package__ to match the rest of the
    library.

Version

Mostly additive public surface, with one narrow externally
observable change noted above (NoSocketException now subclasses
OSError). Leaving the version bump to maintainer discretion;
semver minor seems appropriate for the new surface, happy to bump
if you'd like me to.

Companion HA integration PR

The deako integration in home-assistant/core is the first
consumer of the new public surface. The companion PR wires the
integration through DeakoConnectionPool and the new
Deako.on_connection_lost kwarg. The 250ms inter-command throttle
lives in the integration rather than the library, so pydeako stays
free of consumer-specific pacing. The HA PR is draft and will stay
draft until a pydeako release carrying this change is on PyPI.

Link: home-assistant/core#167234

Tests

  • pydeako/deako/test_manager.py: coverage for the new
    on_connection_lost parameter (invocation on ping timeout,
    exception swallow, no-callback path).
  • pydeako/deako/test_deako.py: coverage for the new parameter
    forwarding and for is_connected().
  • pydeako/deako/test_connection_pool.py: TCP probe behavior,
    keepalive socket lifecycle, ConnectionPoolState immutability,
    pool init validation, idempotent start / stop, switch
    coordination, readiness poll, full failover switch path (happy,
    stopped-early, host-not-ready, retry exhaustion, callback
    replay), and the on_connection_lost scheduling path.
  • pydeako/deako/utils/test_socket.py,
    pydeako/deako/utils/test_connection.py: send-failure
    foundation and NoSocketException raising paths.

Local gates run

@pjens45

pjens45 commented Apr 14, 2026

Copy link
Copy Markdown
Author

Hey Balake, thanks for the guidance posted on the HA PR thread. Opened this PR as you suggested. To answer your questions:
Non-destructive address pool: The new DeakoConnectionPool is additive — it wraps Deako rather than modifying it. Existing code using Deako and DeakoDiscoverer directly keeps working unchanged. The pool is opt-in.
Keepalive logic: _KeepAliveSocket is a separate, minimal TCP socket that holds a standby device in bridge mode so it's ready for instant failover. It's not replacing anything in pydeako, it's specifically for keeping a second device warm while the primary is in use.
On keeping PRs small: Good note. This PR has connection pool, keepalive, and failover as one unit since they're related. the pool needs the keepalive to make failover work. But I do have command throttling in here as well. Happy to split anything out if you'd prefer.

pjens45 added a commit to pjens45/core that referenced this pull request Apr 14, 2026
Moved connection pool, failover, keepalive, and command throttling
logic into pydeako (DeakoLights/pydeako#4). The HA integration now
delegates to DeakoConnectionPool and only handles HA-specific concerns:
zeroconf discovery, config flow, device registry, and failover scanning.

- Removed translations/en.json (auto-generated in core)
- Removed version field from manifest.json
- Removed unused same_host error from strings.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread pydeako/discover/_address_pool.py Outdated
Comment on lines +34 to +35
Maintained for backward compatibility. New code should
prefer peek_address() or get_all_addresses().

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.

Can you explain this? Why? Deako devices are safe to use as singular connections, meaning if you use an address, it should not be available to use again as multiple TCP connections to the same device are not well supported. Has that changed?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You're right, the helpers had no consumers in pydeako or the HA integration. Reverted pydeako/discover/_address_pool.py and its tests to the pre-PR state. The pool handles failover entirely inside DeakoConnectionPool and does not rely on non-destructive peeks at the discovery address pool.

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.

My overall piece of feedback for this is that you are re-defining much of what is already defined. We have a socket connection util here. Why not use that?

Furthermore, the manager already does keepalive pinging through the integration itself. Why not use that for connection pooling? I'm sure some modifications/hooks are needed, but much of what you're implementing here already exists.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for catching this. Two changes:

_KeepAliveSocket now composes _SocketConnection rather than carrying its own socket path. All real socket I/O stays in utils/_socket.py.
The in-pool health monitor is gone. Liveness is detected by _Manager's existing ping loop through a new on_connection_lost callback (optional kwarg on Deako.init, forwarded to _Manager). When a pong is missed, _Manager fires the callback once; the pool schedules a single failover switch. No parallel keepalive loop, single source of truth for "primary is dead."

Also added NoSocketException(OSError) in utils/_socket.py so send failures from the socket wrapper surface as a distinct, catchable type through the manager and pool.

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.

I still don't understand why you need the _KeepAliveSocket. That doesn't do any of the pinging to validate that the integration is working. You could just use another _Manager and that'll also verify that the integration is working.

Comment thread pydeako/deako/_connection_pool.py Outdated
self._keepalive = None

# Give the bridge time to release the keepalive session
await asyncio.sleep(BRIDGE_RECYCLE_DELAY_S)

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.

Is there no way to determine this besides waiting some arbitrary amount of time?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Replaced the fixed sleep with a bounded poll loop. A private TCP probe opens a connection to the failover host and closes immediately; a wait helper polls it at a fixed interval up to a module-level timeout before giving up. The pool only proceeds once the bridge is actually accepting connections. No blind sleeps remain in the failover path.

pjens45 and others added 4 commits April 21, 2026 12:27
Adds NoSocketException(OSError) in utils/_socket.py and raises it
from send_bytes / read_bytes when self.sock is None.

Subclassing OSError means callers can keep a single narrow catch
covering both real socket send failures (sock_sendall / sock_recv
raising OSError) and the "no socket at all" case, without
widening to bare Exception.

This is a narrow externally observable behavior change: callers
that previously relied on send_bytes / read_bytes raising a
different exception type will now see NoSocketException. Existing
catches on OSError continue to work.

Also threads the exception through utils/_connection.py so
upstream send-failure paths can distinguish "socket gone" from
other errors and react by reconnecting rather than retrying in
place.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an optional on_connection_lost callback to _Manager, invoked
once from maintain_connection_worker when a pong is missed,
before close() and the reconnect task start. The callback is
wrapped in a broad try/except logged at WARNING so a buggy
callback cannot stall reconnection.

This is the single source of truth for primary-connection
liveness. External supervisors (for example DeakoConnectionPool)
hook here instead of running parallel health loops, addressing
review feedback about duplicating existing keepalive logic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds on_connection_lost kwarg to Deako.__init__ and forwards it
to the underlying _Manager. Adds Deako.is_connected() as a thin
wrapper over the manager's connection state for callers (the
pool) that need to check liveness without poking manager
internals.

Existing Deako(get_address, client_name=...) callers are
unaffected. Both additions are optional and backward compatible.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an opt-in DeakoConnectionPool that keeps one bridge as the
active primary and the other warm via a held TCP connection on
port 23 (_KeepAliveSocket, which composes _SocketConnection so
all socket I/O stays in utils/_socket.py).

Failover triggers:
  * _Manager's ping loop calls a sync hook wired as Deako's
    on_connection_lost, which schedules one failover switch. No
    parallel in-pool health monitor.
  * A failed send inside control_device (OSError or
    NoSocketException from Deako._control_device_strict) triggers
    exactly one failover switch and exactly one retry on the new
    active before raising NoSocketException.

Switch coordination uses asyncio.Lock + asyncio.Event: the lock
serializes the switch, the event signals completion to in-flight
callers so they either land on the new primary or fail cleanly
on timeout.

Bridge readiness on the new primary is detected with a bounded
TCP-probe poll, capped by a module-level timeout, rather than a
blind sleep.

Public surface:
  * DeakoConnectionPool (opt-in; existing Deako usage unchanged).
  * ConnectionPoolState (frozen dataclass, five fields:
    primary_host, failover_host, primary_connected,
    failover_keepalive_active, started).
  * NoSocketException (re-exported at pydeako top level;
    OSError subclass).

Other behaviors:
  * Idempotent start() / stop().
  * Sync state callbacks only; async callbacks are out of scope
    for this PR.
  * Module logger uses __package__ to match the rest of the
    library.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@pjens45

pjens45 commented Apr 21, 2026

Copy link
Copy Markdown
Author

Thanks for the excellent review. Heads up, about to force-push this branch to condense from 14 to 4 commits with refactoring to address your review comments. Didn't bump the version but happy to if you'd like. Replies to each comment coming right after the push.

@pjens45
pjens45 force-pushed the connection-pool-failover branch from f9b3baf to 2dc1382 Compare April 21, 2026 19:31
@pjens45 pjens45 changed the title Add connection pool with failover, keepalive, and command throttling Add connection pool with failover and keepalive Apr 21, 2026
Close three stop-race windows in DeakoConnectionPool where a terminal
stop() that arrived while a long connect was in flight would still
see the freshly connected Deako installed on self.active after the
await returned. start(), _switch_to_failover(), and _attempt_recovery()
now re-check self._stopped after the connect path returns and, if
stopped, disconnect the new Deako under wait_for(STEP_TIMEOUT_S) and
bail without mutating the host map.

Also enforce the docstring promise that single-bridge pools are
unsupported: the constructor now raises ValueError when
primary_host == failover_host rather than silently returning a pool
whose failover target equals its primary.

Adds four pytest cases covering the three stop-race paths and the
constructor guard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
except OSError as exc:
_LOGGER.error("Error sending data: %s", exc)
self.state = ConnectionState.ERROR
raise

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.

Can you explain why you are adding this here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The state flip ahead of the re-raise keeps is_connected() accurate for the next pool-level check. The pool's failover logic polls is_connected(), not the exception path, so without the flip the pool could see the socket as still healthy after a failed send. Rewrote the docstring to say that directly instead of just describing the code.

Comment thread pydeako/deako/utils/_connection.py Outdated
Comment on lines +63 to +72
Narrow catch: only OSError (which includes NoSocketException
via its OSError inheritance) is caught to flip state to ERROR
before re-raising. Other exceptions propagate unchanged, so
programming errors and unrelated failures surface normally.
Callers get OSError propagation on real send failure.
"""
_LOGGER.debug("[%s] Sending data: %s", self.address, data_to_send)
try:
await self.socket.send_bytes(str.encode(data_to_send))
except Exception as exc: # pylint: disable=broad-exception-caught
except OSError as exc:

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.

Can you better explain this behavioral change? The comment just explains what the code does, not why.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fair, the comment was all "what" and no "why". Rewrote it: narrowed from except Exception so programming errors (TypeError on malformed payload, AttributeError on stale refs, etc.) propagate as bugs rather than getting masked as connection-state transitions. Only socket-level failures (OSError, which NoSocketException subclasses) mean the socket is actually dead and warrant the state flip.

_LOGGER.info("Connecting to %s", self.address)
address, port = self.address.split(":")
await self.loop.sock_connect(self.sock, (address, port))
await self.loop.sock_connect(self.sock, (address, int(port)))

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.

Why does is this type cast necessary?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

self.address.split(":") returns strings, while loop.sock_connect() expects (host, int_port). The cast makes that contract explicit instead of relying on any implementation-specific coercion. Added a one-line inline comment next to the cast so a future reader does not wonder the same thing.

Comment thread pydeako/deako/_deako.py Outdated
) -> None:
"""Add control request to queue.

Preserves 0.x external behavior per decision 29: any send

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.

What is decision 29?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Sorry, that was a leftover ref to internal design notes. The substance: control_device()'s existing 0.x behavior was to swallow send failures silently. The pool needs a raising version to detect failures and drive failover, so I added _control_device_strict() (raises on failure) as an internal API while keeping the public control_device() swallow-behavior unchanged. Removed the ref and updated the comment to say that directly in a follow-up docs cleanup commit.

Comment thread pydeako/deako/_connection_pool.py Outdated
def is_connected(self) -> bool:
"""Return True iff the active primary is currently connected.

Uses `Deako.is_connected()` (decision 21) so the pool does

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.

What is decision 21?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Another internal doc ref, sorry. The underlying point: the pool checks connection health through Deako.is_connected() rather than reaching through Deako.connection_manager to the raw socket, so the pool stays coupled to Deako's public surface only. Comment rewritten without the ref.

Comment thread pydeako/deako/_connection_pool.py Outdated
Returns True iff a TCP connection to ``host:port`` could be
established within ``timeout`` seconds. This is a reachability
probe, not a readiness proof; a bridge that answers here may
still reject the application-level handshake. Callers follow

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.

Can you explain what the application level handshake is?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Clarified the docstring. By "application-level handshake" I meant the Deako protocol exchange after TCP connect succeeds. Not just "port 23 accepted a socket," but that the bridge can actually complete Deako.connect() / find_devices() and enter normal request/response flow. _tcp_probe() only proves the port is open, so callers still use _connect_primary() as the real readiness gate.

Comment thread pydeako/deako/_connection_pool.py Outdated
# own keepalive lives on failover_host, and we are
# about to connect there; without this it fights us
# for the single TCP slot on that bridge. None-guard
# is defensive per section 7.2 note.

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.

What is the section 7.2 note?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Internal doc ref, sorry. The actual note: the bridge only accepts one TCP session on port 23, so the standby keepalive has to be dropped before opening a real Deako connection to the failover host, otherwise the pool blocks its own switch. Comment rewritten inline without the ref.

Comment thread pydeako/deako/_connection_pool.py Outdated

# ----- Switch and recovery ---------------------------------

# pylint: disable-next=too-many-return-statements,too-many-branches

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.

I don't think this should be disabled. This function is very difficult to follow

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. Refactored _switch_to_failover into a short orchestration method that calls smaller private helpers (_wait_for_in_flight_switch, _teardown_active, _teardown_keepalive, _discard_if_stopped, _install_new_active, _execute_failover_switch). Removed the disable; pylint is clean at 10/10 without it.

Comment thread pydeako/deako/_connection_pool.py Outdated
"on_failover_switch callback error: %s", exc,
)

# pylint: disable-next=too-many-return-statements,too-many-branches,too-many-statements

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.

I don't think this should be disabled either as it's leading to a difficult to follow function

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Same treatment: pulled out _wait_for_in_flight_recovery for the concurrent-caller branch and _try_recovery_candidate(target) for the per-host probe-and-connect, reused the shared _teardown_active / _teardown_keepalive / _discard_if_stopped helpers, and removed the disable. The body now reads as the candidate loop with a concurrent-caller branch in front of it.

Pierce Volkman and others added 2 commits April 24, 2026 08:43
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

2 participants