From 00cda9d2f135b4636a045966c8dca7f4a3eb6ec3 Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Tue, 27 Oct 2026 15:20:24 +0100 Subject: [PATCH] connector: wanted(port) says whether anyone watches or reads a port --- backend/fluksio/flow/connector.py | 14 ++++++++++++++ backend/fluksio/flow/demand.py | 14 ++++++++++++++ backend/tests/flow/test_connector.py | 26 ++++++++++++++++++++++++++ docs/reference/connector-contract.md | 14 ++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/backend/fluksio/flow/connector.py b/backend/fluksio/flow/connector.py index b75282c..c0566f6 100644 --- a/backend/fluksio/flow/connector.py +++ b/backend/fluksio/flow/connector.py @@ -21,6 +21,8 @@ What a connector gets from the base class: * :meth:`ConnectorNode.save_artifact`, for a device whose readings are bytes — a camera frame, a recorded clip — which travel as a reference on a media port rather than as the message itself; +* :meth:`ConnectorNode.wanted`, so a camera fetches frames only while someone + watches them or a running flow reads them; * :attr:`ConnectorNode.store`, for the few short strings that have to outlive a restart — a certificate pinned on first contact, a pairing token — shared by every node of the connector's package; :mod:`fluksio.flow.connector_tls` @@ -40,6 +42,7 @@ from typing import TYPE_CHECKING, Any, ClassVar from pydantic import BaseModel, Field from fluksio.flow.connector_store import ConnectorStore, namespace_of +from fluksio.flow.demand import demand from fluksio.flow.nodes import Node if TYPE_CHECKING: @@ -173,6 +176,17 @@ class ConnectorNode(Node): [data], name=name, media_type=media_type, volatile=volatile ) + def wanted(self, port: str) -> bool: + """Whether anyone wants what ``port`` publishes right now. + + A dashboard tile on screen, a screen through the portal, or a node of a + running flow reading the port's message. A camera asks before each + frame, so it sends nothing while nobody watches. Always true on a + remote worker, which cannot see who watches. + """ + names = {s.name for s in self.output_ports if s.port == port and s.name} + return demand.wants(names) + @property def store(self) -> ConnectorStore: """Short strings this connector keeps across restarts. diff --git a/backend/fluksio/flow/demand.py b/backend/fluksio/flow/demand.py index 613045b..f101b44 100644 --- a/backend/fluksio/flow/demand.py +++ b/backend/fluksio/flow/demand.py @@ -58,6 +58,20 @@ class Demand: with self._lock: return set().union(*self._wanted.values()) + def wants(self, names: set[str]) -> bool: + """Whether a socket views, or a running flow reads, any of ``names``. + + What a connector's camera asks before it fetches a frame. True while + nothing is bound, which is a worker process: the sockets are in the + engine's, out of its sight. + """ + controller = self._controller + if controller is None: + # ponytail: a remote worker always sends; forward demand to workers + # when a camera placed on one costs too much. + return True + return bool(names & (self.viewed() | controller.consumed_messages())) + def ports(self, device_id: str) -> list[str]: """The device's ports that have demand, sorted.""" return list(self._compute().get(device_id, ())) diff --git a/backend/tests/flow/test_connector.py b/backend/tests/flow/test_connector.py index f530369..c843eec 100644 --- a/backend/tests/flow/test_connector.py +++ b/backend/tests/flow/test_connector.py @@ -306,3 +306,29 @@ def test_a_connector_publishes_bytes_as_a_media_reference(tmp_path): # point of a media dtype. MessageSpec(name="demo.frame", dtype=DType.IMAGE).check(reference) assert store.path(reference["digest"]).read_bytes() == b"\x89PNG..." + + +def test_a_port_is_wanted_while_watched_or_read(monkeypatch): + """A camera fetches frames only while a screen shows them or a flow reads them.""" + from fluksio.flow.demand import demand + + node = a_sensor([]) + # A worker process binds no controller and cannot see who watches. + assert node.wanted("reading") + + reads: set[str] = set() + monkeypatch.setattr( + demand, "_controller", SimpleNamespace(consumed_messages=lambda: reads) + ) + assert not node.wanted("reading") + + reads.add("demo.reading") + assert node.wanted("reading") + + reads.clear() + demand.want("a viewer", {"demo.reading"}) + try: + assert node.wanted("reading") + finally: + demand.drop("a viewer") + assert not node.wanted("reading") diff --git a/docs/reference/connector-contract.md b/docs/reference/connector-contract.md index 3e75e41..8fec635 100644 --- a/docs/reference/connector-contract.md +++ b/docs/reference/connector-contract.md @@ -144,6 +144,20 @@ new frames arrive. Use durable storage for recordings. Local screens receive pushed live frames; portal screens fetch the bytes. Choose a modest poll rate for remote viewing. +`self.wanted(port)` says whether anyone wants what a port publishes right +now. That is a dashboard tile on screen, a portal screen, or a node of a +running flow reading its message. Check it before fetching a frame so a camera +sends nothing while nobody watches: + +```python +async def poll(self): + if not self.wanted("frame"): + return None + ... +``` + +On a remote worker it is always true, since the worker cannot see who watches. + ## Keeping state across restarts Keep connections and device handles in node attributes. Store short strings