A node's subscription, schedule or poll loop was a bare asyncio task: one that raised outside its own retry handling was simply gone, and the node went on being listed as running while nothing listened any more. Those loops now run under a supervisor that restarts them with a growing delay and quarantines a flow that burns through five restarts in five minutes — a flow crash-looping every second is worse than one that is visibly stopped, and the dashboard can now say which. The MQTT subscription loses its private five-second reconnect in the process: one backoff policy per socket, and it belongs to the supervisor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
156 lines
5.8 KiB
Python
156 lines
5.8 KiB
Python
"""The contract a connector node is written against.
|
|
|
|
A connector is the device-facing node class: it talks to something outside the
|
|
engine — a device, a service, a protocol — and publishes what it finds as
|
|
ordinary typed messages. Third parties write these, so this surface is the one
|
|
part of the engine that has to stay stable; it is versioned by
|
|
:data:`CONTRACT_VERSION` and a connector declares which version it was written
|
|
for.
|
|
|
|
What a connector gets from the base class:
|
|
|
|
* a polling loop that runs :meth:`ConnectorNode.poll` on a schedule, publishes
|
|
only the ports whose value changed, and reports health around it;
|
|
* :meth:`Node.report_health`, so a connection problem shows on the node rather
|
|
than only in the log;
|
|
* the lifecycle hooks the controller drives, so nothing device-specific has to
|
|
be known by the engine.
|
|
|
|
The message schemas and the parameter model are the rest of the contract, and
|
|
they are the same ones the built-in nodes use. See ``docs/connectors/`` for the
|
|
authoring guide.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.flow.nodes import Node
|
|
|
|
if TYPE_CHECKING:
|
|
from fastapi import FastAPI
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
#: Bumped when a change would break connectors written against the old surface.
|
|
#: The loader refuses a connector declaring anything else.
|
|
CONTRACT_VERSION = 1
|
|
|
|
|
|
class ConnectorNode(Node):
|
|
"""Base class for device- and service-facing nodes.
|
|
|
|
A subclass declares the contract version it was written for, describes
|
|
itself for the editor, and implements :meth:`poll`, :meth:`start`, or both::
|
|
|
|
class RandomSensor(ConnectorNode):
|
|
contract = CONTRACT_VERSION
|
|
title = "Random sensor"
|
|
description = "Emits a random reading, for trying the contract out."
|
|
|
|
class Params(ConnectorNode.Params):
|
|
ceiling: float = 1.0
|
|
|
|
async def poll(self):
|
|
return {"reading": random.random() * self.config.ceiling}
|
|
"""
|
|
|
|
#: Declared explicitly by every connector; inheriting it does not count.
|
|
contract: ClassVar[int]
|
|
title: ClassVar[str] = ""
|
|
description: ClassVar[str] = ""
|
|
|
|
class Params(BaseModel):
|
|
"""Settings the editor renders a form for.
|
|
|
|
Subclass it to add your own. A field holding a credential should carry
|
|
``json_schema_extra={"x-secret": True}``, which makes the editor offer
|
|
the stored secrets instead of a text box.
|
|
"""
|
|
|
|
poll_interval: float = Field(
|
|
default=0,
|
|
ge=0,
|
|
description="Seconds between polls; 0 polls never.",
|
|
)
|
|
|
|
__slots__ = ("config", "_poll_task", "_stop_event", "_last_published")
|
|
|
|
def __init__(self, **kwargs: Any) -> None:
|
|
super().__init__(f=self._unused, **kwargs)
|
|
self.config = type(self).Params(**self.params)
|
|
self._poll_task: asyncio.Task[None] | None = None
|
|
self._stop_event: asyncio.Event | None = None
|
|
self._last_published: dict[str, Any] = {}
|
|
|
|
@staticmethod
|
|
def _unused(**_: Any) -> None:
|
|
"""A connector publishes from its own loop, not from the scheduler."""
|
|
return None
|
|
|
|
# -------------------------------------------------------------------------
|
|
# What a connector implements
|
|
# -------------------------------------------------------------------------
|
|
|
|
async def poll(self) -> dict[str, Any] | None:
|
|
"""Read the device once and return values keyed by output port.
|
|
|
|
Return ``None`` when there is nothing new. Raising is reported as a
|
|
health problem and retried on the next tick.
|
|
"""
|
|
return None
|
|
|
|
# -------------------------------------------------------------------------
|
|
# What the engine drives
|
|
# -------------------------------------------------------------------------
|
|
|
|
async def start(self, app: FastAPI | None = None) -> None:
|
|
if self.config.poll_interval > 0 and self._stop_event is None:
|
|
self._stop_event = asyncio.Event()
|
|
self._poll_task = self._run_supervised("poll", self._poll_loop)
|
|
|
|
async def stop(self, app: FastAPI | None = None) -> None:
|
|
if self._stop_event is None:
|
|
return
|
|
self._stop_event.set()
|
|
if self._poll_task is not None:
|
|
self._poll_task.cancel()
|
|
try:
|
|
await self._poll_task
|
|
except (asyncio.CancelledError, Exception): # noqa: B014 - shutting down
|
|
pass
|
|
self._poll_task = None
|
|
self._stop_event = None
|
|
self._last_published = {}
|
|
|
|
async def _poll_loop(self) -> None:
|
|
"""Poll, publish what changed, and say how the connection is doing.
|
|
|
|
Only changed ports are published: a device polled every few seconds is
|
|
usually saying the same thing, and every publication wakes everything
|
|
downstream of it.
|
|
"""
|
|
while not (self._stop_event and self._stop_event.is_set()):
|
|
try:
|
|
values = await self.poll()
|
|
self.report_health("ok")
|
|
changed = {
|
|
port: value
|
|
for port, value in (values or {}).items()
|
|
if self._last_published.get(port, object()) != value
|
|
}
|
|
if changed:
|
|
self._last_published.update(changed)
|
|
# inject runs the graph, which is blocking work.
|
|
await asyncio.to_thread(self.inject, changed)
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as exc:
|
|
logger.warning("Connector '%s' failed to poll: %s", self.id, exc)
|
|
self.report_health("down", f"{type(exc).__name__}: {exc}")
|
|
await asyncio.sleep(self.config.poll_interval)
|