Merge branch 'main' of git.stroblme.de:Fluksio/app
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m44s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m48s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 2m32s
Compose Smoke Test / test-compose (push) Successful in 34s
Playwright Tests / merge-reports (push) Canceled after 0s

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5H4uLCCpsbipL1R7WKCee
This commit is contained in:
2026-08-29 16:42:07 +02:00
co-authored by Claude Opus 5
101 changed files with 4423 additions and 717 deletions
+34
View File
@@ -104,6 +104,40 @@ def test_a_failing_poll_reports_down_and_keeps_going():
assert health[-1][0] == "ok"
def test_an_undeclared_port_keeps_failing_until_the_node_declares_it():
"""A publication that raised is retried, not remembered as published.
The loop remembers what it published. If it remembered what it read, a
value the node cannot publish would be skipped on the next poll, the poll
would succeed, and the node would go back to reporting itself healthy with
its port still dark.
"""
class Chatty(Sensor):
"""Reads a port it never declared."""
async def poll(self) -> dict[str, Any]:
self.polls += 1
return {"reading": 21.5, "lat": 48.1}
health: list[tuple[str, str | None]] = []
node = Chatty(
provides=[MessageSpec(name="reading", dtype=DType.FLOAT)],
params={"poll_interval": 0.01},
)
node.assign_flow("demo", "sensor")
node._on_health = lambda _node, status, detail: health.append((status, detail))
pipeline = Pipeline(nodes=[node])
run_briefly(node)
assert pipeline.state.get("demo.reading") is None
assert health[-1][0] == "down"
assert "NodeOutputError" in (health[-1][1] or "")
# Still failing on the last poll, not just the first.
assert len([entry for entry in health if entry[0] == "down"]) > 1
class Actuator(ConnectorNode):
"""A connector that commands something instead of reading it."""
+50
View File
@@ -0,0 +1,50 @@
"""Tearing a node down must not swallow a cancellation meant for the caller."""
import asyncio
import pytest
from fluksio.flow.connector import ConnectorNode
from fluksio.flow.nodes import DelayNode
async def stubborn() -> None:
"""A loop whose shutdown does not answer the first cancellation."""
try:
await asyncio.sleep(3600)
except asyncio.CancelledError:
await asyncio.sleep(3600)
def test_stop_cron_lets_the_callers_cancellation_through():
async def scenario() -> None:
node = DelayNode(params={"cron": "* * * * *"})
node._stop_cron = asyncio.Event()
node._cron_task = asyncio.create_task(stubborn())
stopping = asyncio.create_task(node.stop_cron())
await asyncio.sleep(0.05) # let it reach the await on the cron task
stopping.cancel()
with pytest.raises(asyncio.CancelledError):
await stopping
node._cron_task.cancel()
asyncio.run(scenario())
def test_connector_stop_lets_the_callers_cancellation_through():
async def scenario() -> None:
node = ConnectorNode()
node._stop_event = asyncio.Event()
node._poll_task = asyncio.create_task(stubborn())
stopping = asyncio.create_task(node.stop())
await asyncio.sleep(0.05) # let it reach the await on the poll task
stopping.cancel()
with pytest.raises(asyncio.CancelledError):
await stopping
node._poll_task.cancel()
asyncio.run(scenario())
+41
View File
@@ -197,6 +197,47 @@ def test_a_flux_request_is_run_rather_than_written(monkeypatch):
assert out == {"answer": {"rows": [], "range_s": 3600}}
def test_the_configured_timeout_reaches_the_influx_client(monkeypatch):
"""The client counts in milliseconds; the param is seconds like its peers."""
import influxdb_client
from fluksio.flow.nodes import InfluxDbNode
seen: dict = {}
class FakeClient:
def __init__(self, **kwargs):
seen.update(kwargs)
def __enter__(self):
return self
def __exit__(self, *_):
return False
def query_api(self):
return self
def query(self, *_, **__):
return []
monkeypatch.setattr(influxdb_client, "InfluxDBClient", FakeClient)
node = InfluxDbNode(
provides=[MessageSpec(name="answer", dtype=DType.JSON)],
params={
"url": "http://influx",
"token": "t",
"org": "o",
"bucket": "b",
"timeout": 2.5,
},
)
node._run_flux({"flux": 'from(bucket: "b")'})
assert seen["timeout"] == 2500
def test_a_falsy_return_is_a_mistake_not_silence():
"""Only None means "nothing to publish"."""
from fluksio.flow.nodes import Node
+5 -1
View File
@@ -129,7 +129,7 @@ def test_a_preferred_label_falls_back_here_and_is_still_accounted(loop):
assert placer.local.snapshot()["cpus"]["free"] == 1
def test_asking_for_more_than_anything_has_gets_what_there_is(loop):
def test_asking_for_more_than_anything_has_gets_what_there_is(loop, caplog):
"""A flow written on a cluster still has to run on a laptop."""
placer = placer_over(cpus=2)
@@ -138,6 +138,10 @@ def test_asking_for_more_than_anything_has_gets_what_there_is(loop):
assert allocation.cpus == 2
assert allocation.gpus == ()
# Cards are declared, not detected, so a machine that has one reads as
# having none until it is told — and the warning is where that is noticed.
assert "fluksio serve --gpus" in caplog.text
def test_what_is_clamped_to_is_a_machine_that_exists(loop):
"""Each dimension taken separately can describe a machine nobody has.
+62
View File
@@ -89,3 +89,65 @@ def test_a_string_goes_on_the_wire_bare():
asyncio.run(node._publish_with(client, {"plug": "ON", "level": 60}))
assert client.published == [("actor/plug", "ON"), ("light/level", "60")]
def test_the_configured_timeout_reaches_the_broker_client(monkeypatch):
"""Without one, a dead socket makes the disconnect ack wait forever."""
import aiomqtt
seen: dict = {}
class FakeClient:
def __init__(self, **kwargs):
seen.update(kwargs)
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def publish(self, *_, **__):
return None
monkeypatch.setattr(aiomqtt, "Client", FakeClient)
node = MqttNode(
requires=[MessageSpec(name="setpoint", port="setpoint", dtype=DType.FLOAT)],
params={"topic": {"setpoint": "heating/setpoint"}, "timeout": 2.5},
)
asyncio.run(node._publish_once({"setpoint": 21.0}))
assert seen["timeout"] == 2.5
def test_the_configured_backlog_reaches_the_publish_queue(monkeypatch):
"""The depth is read when the queue is built, so it has to be per node."""
import aiomqtt
class FakeClient:
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
monkeypatch.setattr(aiomqtt, "Client", FakeClient)
node = MqttNode(
requires=[MessageSpec(name="setpoint", port="setpoint", dtype=DType.FLOAT)],
params={"topic": {"setpoint": "heating/setpoint"}, "publish_queue_size": 8},
)
node.assign_flow("heating", "out")
async def scenario() -> int:
await node.start_publisher()
assert node._publish_queue is not None
size = node._publish_queue.maxsize
await node.stop_publisher()
return size
assert asyncio.run(scenario()) == 8
@@ -0,0 +1,42 @@
"""A node that loaded but is not working shows up as an issue on its flow.
Health used to go nowhere: the connector reported it, the controller stored it,
and no screen ever asked. These cover the derivation that closes that gap.
"""
from pathlib import Path
from fluksio.flow.controller import FlowController, LoadedNode
from fluksio.flow.store import FlowStore
def a_controller(tmp_path: Path) -> FlowController:
controller = FlowController(FlowStore(tmp_path / "flows"))
controller.loaded["house.owm"] = LoadedNode(
id="house.owm",
flow="house",
health="down",
health_detail="ConnectionError: name resolution failed",
)
return controller
def test_a_down_node_is_an_issue_on_its_flow(tmp_path: Path) -> None:
controller = a_controller(tmp_path)
issues = controller.flow_issues("house")
assert [issue.code for issue in issues] == ["node_unhealthy"]
assert issues[0].node == "house.owm"
assert "name resolution failed" in issues[0].message
# Not advisory: the canvas has to mark the node.
assert not issues[0].advisory
assert controller.flow_issues("other") == []
def test_the_issue_clears_when_the_node_reports_itself_well(tmp_path: Path) -> None:
controller = a_controller(tmp_path)
controller.loaded["house.owm"].health = "ok"
assert controller.flow_issues("house") == []
+31
View File
@@ -69,6 +69,24 @@ def test_a_node_returns_its_value_and_what_it_printed(pool, capsys):
assert "seen 21" in capsys.readouterr().out
def test_a_node_can_log_through_the_module_it_imports(pool, capsys):
"""The SDK exports `logger`; inside a worker `fluksio` is the reporter.
Without it, `fluksio.logger.info(...)` died with AttributeError — after
the training it was reporting on had already succeeded.
"""
result = run(
pool,
"import fluksio\n\n\n"
"def process(value):\n"
" fluksio.logger.info('tuned %s', value)\n"
" return {'out': value}\n",
value=7,
)
assert result == {"out": 7}
assert "tuned 7" in capsys.readouterr().out
def test_a_failure_keeps_its_class_and_points_at_the_node(pool):
with pytest.raises(Exception) as caught:
run(pool, "def process():\n raise ValueError('bad input')\n")
@@ -667,6 +685,19 @@ def test_retiring_workers_reaches_the_children(pool):
assert child._generation > before
def test_retiring_the_cards_leaves_the_other_pools_warm(pool):
"""A library that preallocated the card only gives it back by dying."""
card = pool.for_env({"CUDA_VISIBLE_DEVICES": "0"})
threads = pool.for_env({"OMP_NUM_THREADS": "2"})
before = (card._generation, threads._generation)
pool.retire_gpu_children()
assert card._generation > before[0]
# Nothing to hand back, so nothing pays a cold start for it.
assert threads._generation == before[1]
def test_cancelling_reaches_a_node_running_in_a_child(pool):
child = pool.for_env({"OMP_NUM_THREADS": "2"})
started = threading.Event()