Files
app/backend/tests/flow/test_senders.py
T
rootandClaude Fable 5 f8693daad6 Split the node types into a package, and stop reconnecting per message
nodes.py had grown to 2k lines holding every integration behind a single
blanket mypy exemption. It is now a package split by the outside world
each node talks to, so the exemption shrinks to the four integration
modules; base and mlp are type-checked, which turned up a dozen missing
annotations.

The senders opened a fresh connection — and, in the MQTT case, a fresh
thread pool and event loop — for every single message. HTTP senders now
share one pooled client, and a publisher holds one broker connection for
its lifetime, fed from a bounded queue that drops the oldest value when
the broker cannot keep up.

An HTTP sender also no longer trips over a JSON reply that is not an
object: outputs are keyed by port, so a bare scalar is a valid reply with
nothing to publish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 07:33:10 +02:00

67 lines
2.1 KiB
Python

"""The outbound nodes reuse one connection instead of opening one per message."""
import asyncio
from app.flow.messages import DType, MessageSpec
from app.flow.nodes import MqttNode
from app.flow.nodes.http import close_shared_client, shared_client
def test_http_senders_share_one_pooled_client():
first = shared_client()
try:
assert shared_client() is first
finally:
close_shared_client()
# Closing lets the next request build a fresh one rather than reusing a
# closed pool.
assert shared_client() is not first
close_shared_client()
def _publisher() -> MqttNode:
node = MqttNode(
requires=[MessageSpec(name="setpoint", port="setpoint", dtype=DType.FLOAT)],
params={"topic": {"setpoint": "heating/setpoint"}},
)
node.assign_flow("heating", "out")
return node
def test_a_started_publisher_queues_instead_of_connecting():
"""The handler runs on a worker thread; it must not block on the broker."""
node = _publisher()
async def scenario() -> None:
node._publish_queue = asyncio.Queue(maxsize=4)
node._loop = asyncio.get_running_loop()
await asyncio.to_thread(node._publisher_handler, {}, setpoint=21.0)
# call_soon_threadsafe lands on the next loop pass.
await asyncio.sleep(0)
assert node._publish_queue.qsize() == 1
assert node._publish_queue.get_nowait() == {"setpoint": 21.0}
asyncio.run(scenario())
def test_a_full_publish_queue_drops_the_oldest():
"""A broker that cannot keep up must not grow the queue without bound."""
node = _publisher()
health: list[tuple[str, str | None]] = []
node._on_health = lambda _n, status, detail: health.append((status, detail))
async def scenario() -> None:
queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=2)
for value in (1.0, 2.0, 3.0):
node._enqueue(queue, {"setpoint": value})
assert queue.qsize() == 2
assert queue.get_nowait() == {"setpoint": 2.0}
assert queue.get_nowait() == {"setpoint": 3.0}
assert health == [("degraded", "publish queue full")]
asyncio.run(scenario())