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
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:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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") == []
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user