controllers: ControllerRunner, plus native timestamps and severity on attributes - #420
Conversation
- `ControllerRunner` owns the controller lifecycle - initialise, connect, the initial and periodic tasks, reconnect, disconnect - with no transport or interactive-shell concerns. `FastCS.serve` becomes a caller of it. Starting is in two halves so a transport can be wired to the APIs before the first values are read; `start()` alone does both, for an embedder that does not need them in between. - The runner also owns reconnect. A scan task that raises marks its controller disconnected and pauses; until now nothing ever called `reconnect()`, so it stayed paused unless the driver wired its own recovery. - `Controller.connected` exposes the connection state that was only readable through the private `_connected`. - A value entering an attribute may carry when it was obtained and how wrong it is, via `Update(timestamp=..., severity=...)`; a bare value is stamped on arrival and reported as no alarm. `Severity` is a FastCS enum using the same strings as EPICS. `AttrR.timestamp` and `AttrR.severity` read them back. - Documents the stable interface an embedder is restricted to. The `AttrW` setpoint cache the issue also lists was already delivered by #412, as the `.setpoint` property ADR 0016 settled on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017zpFjvbhUisfVUq3NH8M1G
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## refactor #420 +/- ##
============================================
+ Coverage 91.25% 92.18% +0.93%
============================================
Files 72 69 -3
Lines 2892 3185 +293
============================================
+ Hits 2639 2936 +297
+ Misses 253 249 -4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The `controllers` property has no caller, and `Task.cancel` does not raise - the guards `FastCS._stop_scan_tasks` wrapped it in never fired, so moving them across only moved unreachable code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017zpFjvbhUisfVUq3NH8M1G
shihab-dls
left a comment
There was a problem hiding this comment.
Requesting changes related to tests. However, tested the temperature controller with these changes, and the functionality works.
Addresses review on #420. - The two "no timestamp given" tests become one parametrized over a bare value and an `Update`, with the default-severity assertions moved out of the timestamp tests into their own parametrized test rather than dropped. - `test_stop_reports_a_failing_disconnect_without_raising` now mocks the logger and asserts the disconnect exception was actually reported, which is what its name claimed. - The reconnect counters are kept on the instance rather than the class, and asserted against the controller under test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LE4eLtgCbdRjrg7t17HRPa
|
We need to be able to handle reconnects on controllers correctly. In the current iteration, we simply try to reconnect a controller if its scan loop fails; However, a controller may not own its connection, and thus reconnecting it will call a no-op connect() method. Therefore, controllers must be able to share a
class Connection(ABC, Generic[T]):
"""A link to hardware. Owns its own health state.
Several controllers may share one instance. The Runner watches connections, not
controllers, and deduplicates by identity - so never give a Connection an __eq__.
"""
def __init__(
self,
depends_on: "Connection | None" = None,
reconnect_period: float | None = None,
max_attempts: int | None = None,
) -> None:
self._connected = False
# Declared, never derived. A channel layered over an SSH session names that
# session here; the Runner will not attempt this one while that one is down.
self.depends_on = depends_on
@property
def connected(self) -> bool:
return self._connected
@abstractmethod
async def connect(self) -> T:
"""Open the link, or raise. Return whatever introspection the caller needs.
The framework marks the connection connected when this returns cleanly, and
compares the return value against the one from startup on every reconnect.
"""
@abstractmethod
async def close(self) -> None:
"""Close the link. Called at shutdown and before a teardown-style reconnect."""
@abstractmethod
async def check(self) -> None:
"""Cheap query proving the link is alive. Raise if it is not.
Abstract so it cannot be forgotten. A connection that is genuinely always
alive once opened implements it as a no-op - a visible claim rather than a
silent gap. Skipped automatically while normal IO is succeeding.
"""
def set_disconnected(self) -> None:
"""Called by the connection's own IO when the transport fails."""
self._connected = False
def _collect_connections(roots) -> list[Connection]:
"""Run once at startup, level order, deduplicated by identity.
Identity, not equality: two sockets with matching settings are two connections,
and a Connection with an __eq__ would silently collapse them.
"""
seen: dict[int, Connection] = {}
for controller in _walk_level_order(roots):
connection = getattr(controller, "connection", None)
if connection is not None and id(connection) not in seen:
seen[id(connection)] = connection
return list(seen.values())This way, a tree of subcontrollers that all share the same connection are treated as one, where any subcontroller can mark_disconnected() and this will be picked up in the reconnect loop. If a subcontroller has its own connection object, then this connection will be reconnected independently if required, only after its
def _health_line(self) -> None:
"""One pass over every connection, deciding what to attempt.
Synchronous and side-effect-light on purpose: it only schedules work, so it can be
tested by hand-building a list of connections and asserting on what came back.
`ready` and `blocked` are rebuilt from scratch every sweep. Nothing moves between
lists - a blocked connection simply lands in `ready` on the first sweep after its
dependency comes back.
"""
ready: list[Connection] = []
blocked: list[Connection] = []
for connection in self._connections:
state = self._state[connection]
if state.task is not None:
if not state.task.done():
continue # an attempt from an earlier sweep is still running
state.task = None
if connection.connected or state.exhausted:
continue
dependency = connection.depends_on
if dependency is not None and not dependency.connected:
# Cannot succeed yet. Skipping it here means _attempt never runs, so the
# retry budget freezes for free rather than being burnt against a dead
# dependency.
blocked.append(connection)
continue
ready.append(connection)
# Own task per attempt rather than a gather, so one slow reconnect cannot hold up
# the next sweep for the others.
for connection in ready:
self._state[connection].task = asyncio.create_task(self._attempt(connection))
if ready or blocked:
self.log_event("Reconnecting", attempting=len(ready), blocked=len(blocked))and async def _attempt(self, connection: Connection) -> None:
"""One reconnect attempt. Owns retry accounting and the introspection check, and
is the only place a connection is marked back up."""
state = self._state[connection]
state.attempts += 1
try:
await connection.close() # tolerate an already-closed link
introspection = await connection.connect()
await connection.check() # verify, rather than assume, recovery
except Exception:
self.log_exception("Reconnect failed", connection=connection)
if state.attempts >= self._max_attempts(connection):
# Terminal until the process restarts. Dependents stay blocked and never
# attempt, so they will not log a giving-up message of their own - name
# them here so the log accounts for what this takes down with it.
state.exhausted = True
self.log_error("Giving up", connection=connection, blocks=state.dependents)
return
if introspection != state.introspection:
# The device came back describing itself differently. build() can never run
# again, so there is no way to accommodate the new shape.
self._fatal_introspection_mismatch(connection, state.introspection, introspection)
connection._connected = True
state.attempts = 0 # a clean connection restores the budgetSo we try to reconnect a connection, we increment its attempt count using ConnectionState: @dataclass
class ConnectionState:
task: asyncio.Task | None = None
attempts: int = 0
exhausted: bool = FalseEssentially giving each connection a max_attempts which lives on the ControllerRunner, alongside the reconnect_period which tells the runner how often to run the The code I shared references
which will be passed into the ControllerRunner to store as state.
|
|
No code change on this yet — it's a bigger design than #395 covers, so I'd rather put the trade-off to you than start guessing at it. The diagnosis is right, and this PR makes it worse rather than better. The introspection point is also real and I hadn't considered it: Why I've not just implemented it. What you've sketched is a good deal more than a fix to the reconnect loop:
ADR 0016 as written doesn't decide any of that — it says "the runner owns the whole lifecycle including reconnect" and stops. So this wants an ADR amendment ahead of the code, or it lands as a design decided in a PR thread. Two ways to go, and I'd pick the second:
If (2), there's a sub-question: does the automatic reconnect stay in the interim? It genuinely recovers the owns-its-connection case (which the demo and fastcs-thorlabs-mff are), and is a no-op for the shared case, so it's not wrong so much as incomplete. But it's also a documented behaviour change I'd be shipping knowing it's about to be replaced. I'd keep it, and say so in the ADR amendment; happy to strip it from this PR instead if you'd rather not have a mechanism land twice. Points I'd want settled in the ADR either way — mostly places the sketch leaves a decision implicit:
Say which route you want and I'll get on with it. If (2), I'll open the issue with this design and the questions above, and link it here. — overnight agent Generated by Claude Code |
Closes #395
Three related pieces from ADR 0016, all of which the embedded ophyd-async connector (#399) needs and none of which are embedding-specific.
ControllerRunnerThe controller lifecycle was inlined in
FastCS.serve, so there was no way to run controllers without also pulling in transport-serving and the interactive shell. It moves tofastcs.controllers.ControllerRunner, andFastCS.servebecomes a caller of it.Starting is in two halves because anything serving the controllers has to register its callbacks before the first values are read — the PVA transport already carries a comment explaining exactly this — so
setup()builds the APIs andstart()does the rest.start()runssetup()first if you have not, so an embedder that does not need the APIs in between just callsstart()/stop(), which is the shape the ADR asks for. Idempotency is the caller's responsibility, as agreed.The runner also owns reconnect, which closes a live gap. A scan task whose callback raises sets
_connected = Falseand pauses rather than dying — but nothing in FastCS ever calledController.reconnect(), so unless a driver wired its own recovery the controller stayed paused for good. The runner now watches for it and reconnects, so every controller recovers the same way.Controller.connectedexposes the state that was previously only readable through the private_connected.Native timestamps and severity
A value entering an attribute may now say when it was obtained and how wrong it is, through the
Updatethat getters and setters could already return:AttrR.timestampandAttrR.severityread them back. A bare value is stamped with the time it arrived and reported asSeverity.NO_ALARM, so nothing changes for a driver that does not care.Severityis a FastCS enum that happens to use the same strings as EPICS alarm severities; the value/timestamp/severity trio follows the shape of bluesky'sReadingand shares no code with it, per the ADR.Setpoint cache
Already delivered by #412, as the
.setpointproperty — cached byset()before the setter runs and regardless of whether it succeeds, which is exactly what ADR 0016's question 1 settled on. Nothing to do here beyond documenting it as part of the stable surface.Stable interface
New
docs/explanations/stable-interface.mdwrites down the narrow surface an embedder is restricted to (decision 13): the runner,ControllerAPI, and the attribute/command runtime methods — and that nothing should reach intoBaseController.Instructions to reviewer on how to test:
uv run pytest tests/test_controller_runner.py tests/test_attributes.py -vpython -m fastcs.demo run src/fastcs/demo/fastcs.yaml) against the sim, kill the sim so the scan tasks fail, restart it, and confirm the controller reconnects on its own rather than staying paused.Checks for reviewer
Controller.connectedis a new read-only property, so a controller that assignedself.connected = ...for its own bookkeeping now gets anAttributeError. Two test controllers in this repo did exactly that and are renamed; downstream drivers may too. The failure is loud rather than silent, and pre-1.0 is the window for it, but say if you would rather it wereis_connected.RECONNECT_PERIOD). A driver that previously relied on a controller staying paused after a failure will now see it retried. This is what the ADR asks for — "the runner owns the whole lifecycle including reconnect" — but it is a behaviour change, not just a move.NO_ALARM, not "unset". The ADR's consequences say "severity unset" for a bare value; modelling that as a fourth state (None) would push anOptionalinto every transport that reads it, and EPICS' own zero value already means the same thing. Say if you want the tri-state.Notes
(value)signature; a transport that wants the timestamp or severity reads them off the attribute. Widening the callback would have touched every transport for no caller that needs it yet.FastCS._scan_tasks/_initial_corosare gone — they live on the runner now.tests/test_control_system.pyreached into them and is updated.FastCS._stop_scan_tasksare not carried across:Task.cancelreturns whether the task was cancellable rather than raising, so theexcept (CancelledError, RuntimeError)andexcept Exception -> raise RuntimeErrorarms were unreachable.*Metatyped dicts #418 (Remove the DataType family — python types +*Metatyped dicts #413) insrc/fastcs/attributes/attr_r.py: that PR changes howupdate()validates, this one adds the stamping either side of it. Both are independent branches offrefactor, so whichever merges second needs a small conflict resolution inAttrR.update.uv run --locked tox -e pre-commit,type-checking, both green in full. For thetestsenv, this sandbox can't rundocs(needs outbound network) or the PVA/p4p-backed tests (RuntimeError: Address family not supported by protocol), the same known limitation noted on demo: use ControllerVector for temperature ramp sub-controllers #409/demo: cut-down Eiger REST sim + introspectable controller example #410/demo: convert temperature controller to getter/setter style #411/attributes: getter/setter IO rework, remove AttributeIORef/AttributeIO #412. Excluding those,pytest src tests --ignore=tests/benchmarkingpasses 349/359, with only the same 10 pre-existing p4p/socket-family failures, which I confirmed are identical onrefactoritself. Real CI coversdocsand PVA.