perf: don't disconnect + wipe cache on command rejection - #900
perf: don't disconnect + wipe cache on command rejection#900ChristopherJHart wants to merge 2 commits into
Conversation
8f85552 to
209c936
Compare
|
thanks again for fixing this, @ChristopherJHart ! I raised #908 to track it. could you please complete this PR with adding test coverage, and also adding a changelog entry. Suggested test strategy: Extend tests/integration/test_connection_broker_failure_modes.py using the existing make_broker + _patch_executor + _run_broker infrastructure with two contrasting tests:
This validates the fix at the real socket-protocol level and explicitly contrasts the two exception paths against each other. |
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
|
Thanks for the review guidance! I've added both items: Tests (
Both use the existing Changelog — added entry under |
|
This looks right to me in substance, but I'll hold the formal approval until I can see the rebased code — as noted at the bottom, it will look meaningfully different after #899 goes in, and I'd rather approve what actually merges. Upfront caveat: I've leaned heavily on an agent-assisted analysis of the unicon call path here, and I don't claim to hold all of its subtleties in my head myself 😉 — so if something below is wrong, say so plainly and I'll take your word over the analysis. On the premiseThe PR says "If the exception is except StateMachineError:
raise
except UniconBackendDecodeError:
pass
except Exception as err:
raise SubCommandFailure("Command execution failed", err) from errso an EOF or socket error inside This does not change my view on the PR, because the recovery you'd be skipping already exists one layer down. I tested it against a mock device — I looked at discriminating on What I would like is a two-line comment in the code capturing the resulting design point, so it's deliberate rather than rediscovered during an incident: # SubCommandFailure is a fast path, not a health guarantee - unicon wraps
# transport errors in it too. Those get neither a disconnect here nor a retry
# in _execute_command; recovery relies on unicon re-establishing the session
# on the next execute(), which it does transparently.Question on the measured impactHere I think one of us is missing something, and it's quite possibly me. Reading the code, a rejection isn't written to either cache — the broker only calls That doesn't square with the 45% outlier figure, and you measured it on real hardware while I'm reasoning from source, so I'd rather ask than assert: where does the repeat get absorbed? If it genuinely doesn't, that's not a problem with this PR — the amplification removal stands on its own — but it might be worth a follow-up for negative caching, and the PR description would be worth softening slightly. Merge orderI'd like #899 to go in first, since it's lab-validated as it stands and I'd rather not disturb that branch. This one then needs a rebase, and note the disconnect will have moved: the Nit
So: no objections to the approach, and nothing here that I'd expect to change the shape of the fix. Ping me once #899 is merged and this is rebased, and I'll do the final pass and approve on the post-rebase diff. |
…) (#899) * perf: cache connection health check with TTL (0.77s -> 0.004s/test) 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> * perf(broker): replace SSH liveness probe with reconnect-and-retry (#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 #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 #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 #909 reactive reconnect-and-retry, replacing TTL health cache * refactor(broker): leave command-rejection handling to #900 The retry allowlist already excludes SubCommandFailure, so #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 #900 (`fix/perf-cache-wipe-on-failure`), where it has its own justification and benchmark. The unit test is retained, narrowed to what #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 #900 * fix(broker): drop unreachable import guard and correct transport allowlist 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 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7b34f8f to
73150f9
Compare
|
Thanks for the thorough analysis — rebased onto main now that #899 is in. Here's what changed and responses to your points: On the premise / design-point comment — Added verbatim. It's in # SubCommandFailure is a fast path, not a health guarantee - unicon wraps
# transport errors in it too. Those get neither a disconnect here nor a retry
# in _execute_command; recovery relies on unicon re-establishing the session
# on the next execute(), which it does transparently.On the measured impact — You're right. Merge order / rebase — Done. The Changelog nit — Fixed: All 27 integration tests pass, including the two new ones. |
73150f9 to
af6dc35
Compare
|
@oboehmer reached out via Webex and asked to defer the |
SubCommandFailure (device rejects command) is caught in _run_and_cache before the generic except-and-disconnect path. The import is deferred inside the method, matching the pattern used by _is_transport_failure. 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: 92 AI-Reason: deferred SubCommandFailure import in _run_and_cache
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: 99 AI-Reason: integration tests + changelog
af6dc35 to
c05e1f9
Compare
|
Thanks — rebase looks clean, and this addressed everything from the last round. The design-point comment is in And yes — the deferred import inside Three things I'd like fixed before this merges. None are architectural and none should take long, but I'd rather they go in with the change than trail it. 1. Lint is red
2.
|
Summary
Distinguishes between command rejection (device says "Invalid command") and transport failure (SSH drops) to avoid unnecessary disconnect + cache wipe.
Problem
When a device rejects a command (e.g.,
SubCommandFailurefor "Invalid command at '^' marker"), the SSH session is still healthy. Previously, ANY exception in_run_and_cachetriggered:This turned a 4.4s test into a 20.8s test (reconnect + re-execute all previously cached commands).
Solution
SubCommandFailurefromunicon.core.errors_run_and_cache, catchSubCommandFailurebefore the genericexcept Exception— log a warning and re-raise without disconnectingDesign note
SubCommandFailureis a fast path, not a health guarantee — unicon wraps transport errors in it too. Those get neither a disconnect in_run_and_cachenor a retry in_execute_command; recovery relies on unicon re-establishing the session on the nextexecute(), which it does transparently.Measured Impact
CommandCacheis per-test-instance. A follow-up for negative caching could reduce that further.Files Changed
nac_test/pyats_core/broker/connection_broker.pySubCommandFailurecatch in_run_and_cachewith design-point commenttests/integration/test_connection_broker_failure_modes.pyCHANGELOG.md# Unreleased > ## PerformanceTest plan
test_sub_command_failure_preserves_connection_and_cache— successful command populates cache, SubCommandFailure on second command propagates error but connection and cache remain intacttest_transport_failure_disconnects_and_wipes_cache— same setup with OSError triggers disconnect and cache wipe (regression guard)SubCommandFailurehandler placed in_run_and_cache(not_execute_command)🤖 Generated with Claude Code
🤖 AI Generation Metadata