perf(broker): replace SSH liveness probe with reconnect-and-retry (#909) - #899
perf(broker): replace SSH liveness probe with reconnect-and-retry (#909)#899ChristopherJHart wants to merge 5 commits into
Conversation
The _is_connection_healthy() method calls device.connected, which is a PyATS property that performs a live SSH liveness probe (~0.37s round-trip). The previous implementation evaluated it twice per call: once via hasattr() (which calls the property getter) and once directly. Worse, this synchronous I/O runs directly on the broker's async event loop, blocking ALL device traffic fleet-wide while one device is being probed. This causes anti-scaling: adding devices makes each device slower. Fix: cache the health check result with a 30-second TTL. Since _execute_command already reconnects on transport failure, a brief stale-positive is harmless. Uses getattr() instead of hasattr() to avoid double-evaluation. Measured: 0.77s -> 0.004s per test. Fleet-wide penalty dropped from 1.84x to 1.49x (7 devices). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Design Question: Should
|
| Pro | Con | |
|---|---|---|
| Proactive (current) | Avoids one failed execute attempt per dead connection | 0.74s per test, blocks entire event loop, TOCTOU race (connection can die after check passes) |
| Reactive (remove check) | Zero overhead on every healthy call | One extra failed execute + reconnect when connection actually dies (rare) |
The proactive check costs 0.74s × N tests × M devices to prevent a scenario (connection dies between tests) that happens at most once per device per session. It's also subject to a time-of-check/time-of-use race — the connection can pass the health check and die before the command runs.
Suggestion
Given the "retry on failure" model in _execute_command, would it be worth removing _is_connection_healthy() entirely and simplifying _get_connection() to:
async def _get_connection(self, hostname):
async with self.connection_locks[hostname]:
if hostname in self.connected_devices:
return self.connected_devices[hostname]
return await self._create_connection(hostname)The TTL cache in this PR is a conservative middle ground — it preserves the check's intent while reducing the cost from 0.74s/call to 0.004s/call. But if the maintainers agree the check adds no value given the execute-path recovery, the simpler approach is to just remove it.
Happy to adjust this PR in either direction based on feedback.
|
@ChristopherJHart Thanks for the thorough analysis — both the PR and especially your design question comment. You're right that the proactive health check is problematic: the double evaluation via I've been thinking along the same lines as your "remove it entirely" suggestion. The TTL cache is a reasonable middle ground, but it introduces its own complexity (cache identity via My preferred direction is: remove the proactive probe and add retry-once logic to I've opened #909 to track this as the target design. Would you be interested in updating this PR in that direction? The scope would be:
Happy to discuss further if you have concerns about retry safety or edge cases. |
…tascode#909) `_get_connection()` probed every cached connection before handing it out by evaluating the pyATS `device.connected` property, which performs a live SSH round-trip (~0.37s) synchronously on the broker's event loop. The cost grew as O(tests x devices), blocked all device traffic fleet-wide while one device was probed, and could not rule out the session dying between the probe and the command anyway. Replace it with reactive recovery, per netascode#909: - `_get_connection()` returns the cached connection as-is, or creates one. `_is_connection_healthy()` is removed. - `_execute_command()` retries once on transport failure: the failed attempt tears the connection down, the retry runs on a fresh one. This heals the current request, where the previous handler only cleaned up for the next caller. - Retry is limited to failures that a fresh session can fix (Unicon `ConnectionError`, `TimeoutError`, `StateMachineError`, `OSError`, `EOFError`). Connection *establishment* errors are not retried, so an unreachable device still costs one connect attempt, not two. - `SubCommandFailure` now raises without disconnecting. The device answered and rejected the command, so the session is healthy; previously a single bad command tore down the connection and dropped the device's whole command cache. Behavior changes worth noting in review: - `connect()` (`_ensure_connection`) no longer validates the liveness of a cached session. The first command execution handles recovery. - Retrying assumes broker commands are safe to re-run. All current callers issue read-only `show` commands, including the Genie supplementary calls routed through the broker by netascode#863. Measured impact: per-test health check cost 0.77s -> 0s; fleet-wide penalty on 7 devices 1.84x -> ~1.0x; ~216s of blocking probes removed per device job (169 tests). Supersedes the TTL-cached probe previously proposed on this branch, which kept the probe's cost model and added cache-identity, invalidation and staleness problems of its own. AI-Generated: yes AI-Tool: claude-code AI-Model: opus-5 AI-Percent: 71 AI-Reason: implement netascode#909 reactive reconnect-and-retry, replacing TTL health cache
Resolves the CHANGELOG conflict: 2.1.0b1 shipped while this PR was open, so the broker performance entry moves into a new unreleased section above it. nac_test/pyats_core/broker/connection_broker.py was untouched on main since the branch point; the broker unit tests merged cleanly with the additions from netascode#905.
The retry allowlist already excludes SubCommandFailure, so netascode#899 does not need its own carve-out to avoid retrying a rejected command. Dropping `_is_command_rejection()` restores today's disconnect-on-any-exception semantics and keeps the "a rejection should not tear down a healthy session" change wholly in netascode#900 (`fix/perf-cache-wipe-on-failure`), where it has its own justification and benchmark. The unit test is retained, narrowed to what netascode#899 actually guarantees: a rejection is not retried. AI-Generated: yes AI-Tool: claude-code AI-Model: opus-5 AI-Percent: 26 AI-Reason: de-duplicate SubCommandFailure handling already owned by netascode#900
|
@oboehmer Thanks for the direction — I've repointed this PR at #909 and dropped You were right on all three counts: the What this PR does now
What it does NOT doCommand rejection handling ( Lab validation (21-device fleet, 914 test instances per arm)
A → C is 4.5x — but the TTL cache already captured almost all of it (A → B is All three arms produced identical results (726 passed, 188 failed, 0 Recovery proof (the part timing can't check): Against a live Nexus switch, One thing the recovery check revealed: killing the Unicon spawn (instead of Two things worth your eyes
|
oboehmer
left a comment
There was a problem hiding this comment.
Direction is right and the diff is small. Removing the probe is the correct call, the separation from #900 (ab3e476) is clean, and the PR body is refreshingly honest that A→B captured 4.3x of the 4.5x — this is a correctness change, not a performance one, and it says so. CI green across all 13 checks.
Taking your two closing points first, since one of them shapes what I'd ask for in this PR.
On the #863 interaction
Right to flag, and I'd push it one step further. Before #863 the command set reaching the broker was whatever test authors wrote — in-repo, reviewable, demonstrably all show. After #863 it also includes whatever supplementary commands the installed Genie parsers decide to issue (your show running-config | section router ospf 1 example). That set isn't controlled by this repo and can change on a genie bump with no diff here. So the assumption isn't just wider, it's partly owned by a dependency.
That argues for making it cheap to enforce rather than documenting it. _is_transport_failure already answers "is this failure worth retrying"; add the symmetric "is this command safe to retry" next to it:
if self._is_transport_failure(e) and self._is_retryable_command(cmd):with cmd.strip().lower().startswith("show") as the predicate. It's a no-op on every current caller, so it won't disturb the three-arm validation you've already banked, and it fails in the safe direction — a command that doesn't match simply loses the retry and behaves exactly as main does today. I'd rather see this here than as a tracking issue: the hazard only materialises when someone adds a non-show caller, which is precisely the moment a tracking issue goes unread. Three lines plus a test, and the conscious decision lives in the code rather than only in the PR description.
Smaller related note: broker_execute (ssh_base_test.py:334) keeps a client-side CommandCache that a broker-side disconnect doesn't touch, so the two diverge after any recovery. Not a correctness problem, but worth knowing when #900 gets benchmarked — the cache wipe costs less than its framing implies.
On the orphaned-cache trap
Verified, and it's the strongest defensive detail in the PR. The old binding (cache = self.command_cache[hostname] at the top) against del self.command_cache[hostname] in _disconnect_device_internal would have had the retry populate a cache nothing references — correct output returned, then silently re-executed on every subsequent request. A slow leak of exactly the property the broker exists to provide.
_get_command_cache() re-resolving inside _run_and_cache is the right shape, and the comment explaining why it re-resolves is worth keeping — it's the line that stops someone folding it back into a bug. test_retry_result_is_cached has good fidelity too: the side effect actually pops the entry rather than asserting against a mock, so it fails if the re-resolution goes away. No changes suggested.
The one other thing I'd fix in this PR
Retry fires even when the connection was created in the same call. The rationale throughout is "a session that died since its last use", but _execute_command doesn't distinguish a cache hit from a miss:
connection = await self._get_connection(hostname) # may be brand new
try:
return await self._run_and_cache(hostname, connection, cmd)
except Exception as e:
if not self._is_transport_failure(e):
raise
# → disconnect + full reconnect + re-executeUnicon TimeoutError is in the allowlist, so a device that is reachable but hangs now costs 2× execute plus a full reconnect on a connection that was alive moments earlier. The "unreachable device still costs one connect attempt" argument holds for establishment errors but not execute-time ones. Having _get_connection report cached-vs-fresh, and gating the retry on cached, makes the code enforce the invariant the docstrings already describe.
Deferrable
Retry budget vs. the client timeout. ssh_base_test.py:345 uses future.result(timeout=DEVICE_EXECUTE_TIMEOUT) (120s). Failed execute + graceful Unicon disconnect (~11s of your measured ~14s) + reconnect + re-execute can exceed that, at which point the client abandons the future while the broker keeps working — run_coroutine_threadsafe won't cancel it. Happy path is fine; a docstring note that the retry can outlive its caller would do for now.
Teardown race — suggest its own issue. Not introduced here. The per-device lock is held only inside _get_connection, so A can fail → reconnect → be executing on a fresh connection when B's late _disconnect_device tears it down. Possible on main already, but retry widens the window. I checked whether draft #902 covers it — it doesn't: #902 takes _execute_locks[hostname] while _disconnect_device guards connection_locks[hostname], two locks over two halves of one critical section. Cheapest fix, if we open an issue: _disconnect_device(hostname, expect=connection) that only tears down when connected_devices.get(hostname) is connection. Separately — #899 and #902 both rewrite _execute_command and #899 moves the dispatch into _run_and_cache(), so whichever lands second will conflict.
Unicon import guard. The lazy import matches house style; the guard doesn't — the four equivalents in ssh/connection_manager.py (:127-134, :196, :277-281) are bare. As the # pragma: no cover acknowledges, the branch can't be reached: by the time _is_transport_failure runs, an execute has happened, so unicon is installed. Dropping it removes unreachable code. Unrelated to the guard, declaring "unicon>=26.5; sys_platform != 'win32'" alongside pyats/genie would make the import contract explicit.
Minor. stats_connection_cache_hits now increments for possibly-dead connections, so the ratio no longer proxies reuse quality. And the same probe pattern survives on the non-broker path (ssh/connection_manager.py:100, :247, :461) — out of scope for #909, but worth an issue so the two paths don't rot asymmetrically.
Verified
Classification checked against installed unicon 26.5: TimeoutError subclasses builtin OSError (doubly covered); ConnectionError/StateMachineError are plain Exception, so explicit listing is needed; SubCommandFailure, CredentialsExhaustedError, UniconAuthenticationError correctly excluded — not retrying auth failure is an important call. Builtin ConnectionError from _create_connection sits outside the try, matching your claim.
Tests assert the right things: test_does_not_probe_cached_connection verifies the property was never evaluated rather than mocking a deleted helper, and TestConnectionHealthRecovery now exercises the full dead-session path against the real methods. CHANGELOG lands correctly in a new # unreleased above 2.1.0b1, and the three PRD sections match the shipped code.
Approving — nothing in the diff is wrong, and main is worse in every dimension, so this shouldn't be gated on hardening for hazards that are all forward-looking. The retry-command guard would ideally land here since it's ~3 lines and a no-op today; the cached-vs-fresh gate here or immediately after. Teardown race, retry budget, import guard and the non-broker probe are all follow-up issues.
|
@ChristopherJHart , I did not open any issues for the deferrable points, please let me know which one you can/want to address in this PR (I would definitely remove the try/except around the unicon imports) and I will let my agent open the ones which are done outside.. |
|
Approval stands — one follow-up on Two of the failures this retry is meant to catch aren't matched by the current allowlist: >>> [c.__name__ for c in unicon.core.errors.SessionConnectionError.__mro__]
['SessionConnectionError', 'Exception', 'object']
>>> [c.__name__ for c in unicon.core.errors.EOF.__mro__]
['EOF', 'Exception', 'object']
>>> [c.__name__ for c in unicon.core.errors.TimeoutError.__mro__]
['TimeoutError', 'TimeoutError', 'OSError', 'Exception', 'BaseException']
Suggested: from unicon.core.errors import EOF as UniconEOF
from unicon.core.errors import SessionConnectionError
retryable += [
UniconConnectionError,
SessionConnectionError,
UniconEOF,
StateMachineError,
] # UniconTimeoutError already covered by OSErrorSeparately, for the record on the classification question, since it affects how much of that allowlist is reachable:
except StateMachineError:
raise
except UniconBackendDecodeError:
pass
except Exception as err:
raise SubCommandFailure("Command execution failed", err) from errso transport errors raised inside That's not an argument against this PR — Worth noting the recovery here is also belt-and-braces: pyATS/unicon re-establishes a dead session on the next None of this is blocking. Happy for the allowlist tweak to land as a follow-up if you'd rather not disturb the validated branch. |
…wlist Remove the try/except around unicon imports in `_is_transport_failure` — by the time this method runs an execute has already happened, so unicon is guaranteed to be installed. Bare imports match the rest of the codebase (`ssh/connection_manager.py`). Also fix the allowlist per @oboehmer's review of the actual exception hierarchy: - Add `SessionConnectionError` — bare `Exception`, not a subclass of unicon's `ConnectionError`, so `isinstance()` was missing it. - Add `unicon.core.errors.EOF` — not the builtin `EOFError`, which was in the list but never matched a unicon-raised error. - Drop `UniconTimeoutError` — already subclasses builtin `TimeoutError` → `OSError`, which is in the base tuple. - Drop builtin `EOFError` — unicon's `EOF` is the one that surfaces. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> AI-Generated: yes AI-Tool: claude-code AI-Model: opus-4.6 AI-Percent: 51 AI-Reason: fix unicon import guard and allowlist per review
|
@oboehmer — thanks for the deep dive into the exception hierarchy; that was a gap I wouldn't have caught from the docs alone. Addressed in this PR (0a98581): The import guard and allowlist are fixed in one commit:
Tests updated: Filed as follow-up issues for the deferrable items:
Each issue references back to this PR and the relevant review context. #941 also notes the conflict surface with #902 so whoever picks it up coordinates. The |
Summary
Removes the proactive SSH liveness probe from the connection broker and replaces it with reconnect-and-retry in
_execute_command(), per the target design in #909.This supersedes the TTL-cached probe originally proposed on this branch (see the discussion below). The cache preserved the probe's cost model and added problems of its own — cache identity via
id(connection), no invalidation on disconnect, and 30s of deliberate blindness — so the root cause is addressed instead.Problem
_get_connection()probed every cached connection before handing it out by evaluating the pyATSdevice.connectedproperty, which performs a live SSH round-trip (~0.37s) synchronously on the broker's event loop:hasattr(connection, "connected")triggered the property getter, thenconnection.connectedtriggered it again (~0.74s per call).O(tests × devices): ~216s of blocking probes per device job at 169 tests.connection.execute(), so the check never guaranteed correctness anyway.The old recovery path in
_execute_command()disconnected and re-raised, which cleaned up for the next caller but failed the current request.Solution
_get_connection()returns the cached connection as-is, or creates one._is_connection_healthy()is removed._execute_command()retries once on transport failure. The failed attempt tears the connection down; the retry runs on a fresh one. This heals the current request rather than only the next.ConnectionError,TimeoutError,StateMachineError, plusOSError/EOFError. Classification lives in one small helper (_is_transport_failure) so it's easy to adjust in review.SubCommandFailureis simply absent from the retry allowlist, so a rejected command is not re-run. Whether a rejection should also skip the disconnect is perf: don't disconnect + wipe cache on command rejection #900's change, left wholly to that PR.Behavior changes worth a look in review
connect()no longer validates liveness._ensure_connection()returns success for a cached session without probing it; the first command execution handles recovery. Called out as acceptable in perf: proactive SSH health check in _get_connection() blocks event loop and anti-scales with fleet size #909.showcommands, including the Genie supplementary calls routed through the broker by feat(broker): support Genie-driven command execution in broker mode #863. If non-idempotent commands are ever sent through the broker, the retry needs to become opt-in.SubCommandFailureskip the disconnect, duplicating perf: don't disconnect + wipe cache on command rejection #900; that has been removed, so disconnect-on-any-exception behaves exactly as it does onmaintoday.Measured impact
Re-measured on a 21-device lab fleet (19 Nexus 9k + 2 Firepower 4215),
SUITE=minimal= 914 test instances per run, one variable changed between arms:
hasattr)A → C is 4.5x. The honest reading, though, is that the TTL cache already
captured almost all of it (A → B is 4.3x); removing the probe outright adds ~5%
more (B → C, 249s → 237s).
So this is not primarily a performance change over the cache it replaces. The
reason to prefer removal is the correctness argument in #909 — no
id()-keyedcache that can alias a recycled connection, no invalidation to forget on
disconnect, no window of deliberate blindness, and recovery that heals the
current request instead of only the next caller's. The 4.5x is what either
approach buys over what is on
maintoday.All three arms produced identical results: 914 tests, 726 passed, 188 failed,
0 skipped/errored, aggregated from every device's pyATS
results.json, with theper-test outcome sets matching name-for-name across arms (0 changed, 0 added, 0
missing). The retry never fired during arm C — correct for a healthy fleet.
Test plan
tests/unit— 1085 passed. Broker tests reworked: the two_is_connection_healthytests are gone;TestGetConnectionnow asserts the cached connection is handed out without touchingdevice.connected;TestConnectionHealthRecoveryexercises the full dead-session → disconnect → reconnect → retry path against the real_get_connection/_disconnect_device/_create_connection.New
TestExecuteCommandRetryandTestFailureClassification: retry succeeds, retry result lands in a live cache (regression for the cache dropped by the intervening disconnect), retry exhaustion raises with both attempts torn down,SubCommandFailureis not retried, unclassified errors disconnect but don't retry, connect errors aren't retried.tests/integration— 77 passed, 2 skipped, 1 failed. The failure (test_connection_broker_pooling_and_caching) is pre-existing and environmental: this machine has nopythonon PATH, so the mock device fails withSpawnInitError. Verified identical (assert 6 == 2) on the branch point with the change stashed.ruff check,ruff format --check,mypyclean on both touched files.Live lab validation — the three-arm run above, plus a recovery check on a
real Nexus switch, which is the part timing cannot cover. Two scenarios:
Unicon's own
execute()catches the EOF and reconnects(
%UNICON-WARNING: +++ Reconnecting +++). Worth knowing — the broker's retryis a second line of defence, and part of why the probe bought so little.
unicon ConnectionErrorat the boundary where the broker doessee failures exercises the real path: disconnect → reconnect over SSH →
command re-run → 872 chars of real
show moduleoutput → follow-up commandserved by the healed session. Recovery cost ~14s, ~11s of which is Unicon's
graceful disconnect teardown. That is the true price of a retry, paid only
when a session is actually dead — against a probe that ran on every command.
The error in the second scenario is injected, not spontaneous; it reproduces on
demand what Unicon surfaces when it cannot self-heal.
One reporting discrepancy, surfaced by the runs but not caused by this change:
nac-test's console summary split NX-OS as
175 failed / 97 skippedin arm Bagainst
176 / 96in arms A and C, while the underlying pyATS data and everyper-test result line are identical across all three arms. That looks like the
summary layer classifying one result differently and deserves its own issue.
Files changed
nac_test/pyats_core/broker/connection_broker.py_is_connection_healthy(), simplify_get_connection(), add retry-once +_run_and_cache()/_get_command_cache()/_is_transport_failure()tests/unit/pyats_core/broker/test_connection_broker.pydev-docs/PRD_AND_ARCHITECTURE.mdCHANGELOG.mdCloses #909
🤖 AI Generation Metadata