Moved derive units to registry architectur

DeriveUnits are now owned by a DeriveRegistry which has its own routing
thread to handle routing values to the different DeriveUnits
This commit is contained in:
2026-09-11 18:50:32 +02:00
parent c0a3117be4
commit 19b356a94d
6 changed files with 292 additions and 109 deletions
+6 -5
View File
@@ -123,7 +123,8 @@ representation.
### `get_all_signal_descriptors() -> list[SignalDescriptor]` ### `get_all_signal_descriptors() -> list[SignalDescriptor]`
Returns descriptors for every signal currently registered by connected Returns descriptors for every signal currently registered by connected
connectors. The list is empty when no connectors have completed a handshake. connectors and bound derive units. The list is empty when no connectors have
completed a handshake and no derive units are bound.
Connector membership is dynamic: a disconnected connector's signals are no Connector membership is dynamic: a disconnected connector's signals are no
longer returned. longer returned.
@@ -233,9 +234,9 @@ The parameter annotations must use the imported `ValueDescriptor` and
`SignalDescriptor` classes shown above. The source string is executed with `SignalDescriptor` classes shown above. The source string is executed with
`exec()`, so only bind code from trusted sources. `exec()`, so only bind code from trusted sources.
The returned UUID identifies the bound unit. Binding invalid source or more The returned UUID identifies the bound unit. Binding source that defines more
than one function raises `CoreMultipleFunctionsFoundError`; an invalid function or fewer than one function raises `DeriveUnitMultipleFunctionsFoundError`; an
signature raises `DeriveUnitInvalidSignatureError`. invalid function signature raises `DeriveUnitInvalidSignatureError`.
### `unbind_derive_unit(unit_id: UUID) -> None` ### `unbind_derive_unit(unit_id: UUID) -> None`
@@ -252,5 +253,5 @@ Core-specific exceptions are defined in `dynalab_core.errors`.
| Exception | Raised when | | Exception | Raised when |
| --- | --- | | --- | --- |
| `CoreStateMismatchError` | `start()` is called after the core has already started or stopped. | | `CoreStateMismatchError` | `start()` is called after the core has already started or stopped. |
| `CoreMultipleFunctionsFoundError` | A derive-unit source string does not define exactly one function. | | `DeriveUnitMultipleFunctionsFoundError` | A derive-unit source string does not define exactly one function. |
| `DeriveUnitInvalidSignatureError` | A derive function's annotations or parameter order are invalid. | | `DeriveUnitInvalidSignatureError` | A derive function's annotations or parameter order are invalid. |
+32 -78
View File
@@ -3,25 +3,22 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from collections.abc import Callable
from datetime import datetime, timezone from datetime import datetime, timezone
import inspect
import logging import logging
from queue import Empty, Queue from queue import Empty, Queue
import threading import threading
from threading import Lock, Thread from threading import Lock, Thread
import time import time
import types
from typing import Literal from typing import Literal
from uuid import UUID, uuid4 from uuid import UUID
from dynalab_core.buffer import ValueBuffer from dynalab_core.buffer import ValueBuffer
from dynalab_core.config import CoreConfig from dynalab_core.config import CoreConfig
from dynalab_core.constants import CORE_VERSION from dynalab_core.constants import CORE_VERSION
from dynalab_core.derive import DeriveUnit from dynalab_core.derive import DeriveRegistry
from dynalab_core.dlpak import DLPak from dynalab_core.dlpak import DLPak
from dynalab_core.errors import CoreMultipleFunctionsFoundError, CoreStateMismatchError from dynalab_core.errors import CoreStateMismatchError
from dynalab_core.protocols.endpoint import ConnectorRegistry from dynalab_core.protocols.endpoint import ConnectorRegistry
from dynalab_core.protocols.common import VersionDescriptor from dynalab_core.protocols.common import VersionDescriptor
from dynalab_core.protocols.json.server import JsonServer from dynalab_core.protocols.json.server import JsonServer
@@ -50,14 +47,15 @@ class Core:
self._live_values: dict[UUID, Value] = {} self._live_values: dict[UUID, Value] = {}
self._live_values_lock = Lock() self._live_values_lock = Lock()
self._derive_units: dict[UUID, DeriveUnit] = {}
self._data_input_queue: Queue[ProtocolMessage] = Queue(524288) self._data_input_queue: Queue[ProtocolMessage] = Queue(524288)
self._input_worker_thread = Thread( self._input_worker_thread = Thread(
target=self._input_worker, name="input_worker_thread", daemon=True target=self._input_worker, name="input_worker_thread", daemon=True
) )
self._input_worker_thread.start() self._input_worker_thread.start()
self._derive_registry = DeriveRegistry(
self._data_input_queue, self._get_live_value_descriptor
)
self._connector_registry = ConnectorRegistry(self._data_input_queue) self._connector_registry = ConnectorRegistry(self._data_input_queue)
self._json_server = JsonServer(self._core_config, self._connector_registry) self._json_server = JsonServer(self._core_config, self._connector_registry)
@@ -109,9 +107,8 @@ class Core:
) )
self._json_server.stop() self._json_server.stop()
self._connector_registry.stop() self._connector_registry.stop()
self._derive_registry.stop()
self._stop_event.set() self._stop_event.set()
for unit in self._derive_units.values():
unit.stop()
self._state = "stopped" self._state = "stopped"
log.info( log.info(
"Core stopped", "Core stopped",
@@ -129,31 +126,27 @@ class Core:
self._recording_buffer.normalize() self._recording_buffer.normalize()
self._processing_buffer = DLPak() self._processing_buffer = DLPak()
self._processing_buffer.set_data(self._recording_buffer) self._processing_buffer.set_data(self._recording_buffer)
signal_descriptors = self._connector_registry.get_all_signal_descriptors() connector_signal_descriptors = (
for unit in self._derive_units.values(): self._connector_registry.get_all_signal_descriptors()
signal_descriptors.append(unit._return_signal) )
derive_signal_descriptors = self._derive_registry.get_all_signal_descriptors()
self._processing_buffer.set_manifest( self._processing_buffer.set_manifest(
self._recording_timestamp, signal_descriptors self._recording_timestamp,
connector_signal_descriptors + derive_signal_descriptors,
) )
def get_signal_descriptor(self, signal_id: UUID) -> SignalDescriptor | None: def get_signal_descriptor(self, signal_id: UUID) -> SignalDescriptor | None:
connector_signal = self._connector_registry.get_signal_descriptor(signal_id) signal = self._connector_registry.get_signal_descriptor(signal_id)
if connector_signal is None: if signal is None:
for unit in self._derive_units.values(): signal = self._derive_registry.get_signal_descriptor(signal_id)
if unit._return_signal.id == signal_id:
return unit._return_signal
else:
return None
return connector_signal return signal
def get_all_signal_descriptors(self) -> list[SignalDescriptor]: def get_all_signal_descriptors(self) -> list[SignalDescriptor]:
connector_signals = self._connector_registry.get_all_signal_descriptors() connector_signals = self._connector_registry.get_all_signal_descriptors()
connector_signals.append( derive_signals = self._derive_registry.get_all_signal_descriptors()
unit._return_signal for unit in self._derive_units.values() return connector_signals + derive_signals
)
return connector_signals
def get_live_value(self, signal_id: UUID) -> float | None: def get_live_value(self, signal_id: UUID) -> float | None:
with self._live_values_lock: with self._live_values_lock:
@@ -175,32 +168,19 @@ class Core:
input_signals: list[SignalDescriptor], input_signals: list[SignalDescriptor],
output_signal: SignalDescriptor, output_signal: SignalDescriptor,
) -> UUID: ) -> UUID:
unit_id = uuid4() unit = self._derive_registry.register(function, input_signals, output_signal)
return unit.uuid()
namespace = {}
exec(function, namespace)
functions = [
obj for obj in namespace.values() if isinstance(obj, types.FunctionType)
]
if len(functions) != 1:
raise CoreMultipleFunctionsFoundError
unit = DeriveUnit(
unit_id, functions[0], input_signals, output_signal, self._data_input_queue
)
self._derive_units[unit_id] = unit
return unit_id
def unbind_derive_unit(self, unit_id: UUID) -> None: def unbind_derive_unit(self, unit_id: UUID) -> None:
try: self._derive_registry.unregister(unit_id)
self._derive_units[unit_id].stop()
self._derive_units.pop(unit_id, None) def _get_live_value_descriptor(self, signal_id: UUID) -> ValueDescriptor | None:
except KeyError: with self._live_values_lock:
return live_value = self._live_values.get(signal_id)
if live_value is None:
return None
return live_value.to_descriptor(signal_id)
def _input_worker(self) -> None: def _input_worker(self) -> None:
while not self._stop_event.is_set(): while not self._stop_event.is_set():
@@ -215,11 +195,7 @@ class Core:
live_value = self._live_values.get(message.signal_id) live_value = self._live_values.get(message.signal_id)
if live_value is None: if live_value is None:
signal_descriptor = ( signal_descriptor = self.get_signal_descriptor(message.signal_id)
self._connector_registry.get_signal_descriptor(
message.signal_id
)
)
live_value = Value( live_value = Value(
signal_descriptor.timeout_ms if signal_descriptor else 2000 signal_descriptor.timeout_ms if signal_descriptor else 2000
@@ -232,26 +208,4 @@ class Core:
message.timestamp, message.signal_id, message.value message.timestamp, message.signal_id, message.value
) )
for unit in self._derive_units.values(): self._derive_registry.put_data(message)
input_signals = unit.get_input_signals()
if message.signal_id in [signal.id for signal in input_signals]:
args: list[ValueDescriptor] = []
for signal in input_signals:
if signal.id == message.signal_id:
args.append(message)
continue
with self._live_values_lock:
live_value = self._live_values.get(signal.id)
value_descriptor = (
live_value.to_descriptor(signal.id)
if live_value is not None
else None
)
if value_descriptor is None:
break
args.append(value_descriptor)
else:
unit.put_data(args)
+172 -20
View File
@@ -5,18 +5,24 @@
from collections.abc import Callable from collections.abc import Callable
from enum import IntEnum from enum import IntEnum
import inspect import inspect
import logging
from queue import Empty, Queue from queue import Empty, Queue
from threading import Thread from threading import RLock, Thread
import threading import threading
from uuid import UUID import types
from uuid import UUID, uuid4
from dynalab_core.errors import ( from dynalab_core.errors import (
DeriveRegistryAlreadyRegisteredError,
DeriveUnitArgsMismatchError, DeriveUnitArgsMismatchError,
DeriveUnitInvalidSignatureError, DeriveUnitInvalidSignatureError,
DeriveUnitMultipleFunctionsFoundError,
) )
from dynalab_core.protocols.packets.data import ValueDescriptor from dynalab_core.protocols.packets.data import ValueDescriptor
from dynalab_core.protocols.packets.handshake import SignalDescriptor from dynalab_core.protocols.packets.handshake import SignalDescriptor
log = logging.getLogger(__name__)
class ParserState(IntEnum): class ParserState(IntEnum):
INIT = 0 INIT = 0
@@ -40,8 +46,6 @@ class DeriveUnit:
self._stop_event: threading.Event = threading.Event() self._stop_event: threading.Event = threading.Event()
self._stopped_event: threading.Event = threading.Event() self._stopped_event: threading.Event = threading.Event()
self._input_queue: Queue[list[ValueDescriptor]] = Queue() self._input_queue: Queue[list[ValueDescriptor]] = Queue()
# TODO: Deprecate output queue
self._output_queue: Queue[ValueDescriptor] = Queue()
self._return_queue: Queue[ValueDescriptor] = return_queue self._return_queue: Queue[ValueDescriptor] = return_queue
self._num_input_args: int = 0 self._num_input_args: int = 0
@@ -85,27 +89,11 @@ class DeriveUnit:
value = self._process_function(*input_args, self._return_signal) value = self._process_function(*input_args, self._return_signal)
self._return_queue.put(value) self._return_queue.put(value)
self._output_queue.put(value)
self._stopped_event.set() self._stopped_event.set()
def put_data(self, input: list[ValueDescriptor]) -> None: def put_data(self, input: list[ValueDescriptor]) -> None:
self._input_queue.put(input) self._input_queue.put(input)
def get_output(self) -> ValueDescriptor | None:
try:
return self._output_queue.get_nowait()
except Empty:
return None
def get_all_output(self) -> list[ValueDescriptor]:
output: list[ValueDescriptor] = []
while True:
try:
output.append(self._output_queue.get_nowait())
except Empty:
return output
def process_offline(self, input_args: list[ValueDescriptor]) -> ValueDescriptor: def process_offline(self, input_args: list[ValueDescriptor]) -> ValueDescriptor:
if len(input_args) == self._num_input_args: if len(input_args) == self._num_input_args:
return self._process_function(*input_args, self._return_signal) return self._process_function(*input_args, self._return_signal)
@@ -117,3 +105,167 @@ class DeriveUnit:
def stop(self) -> None: def stop(self) -> None:
self._stop_event.set() self._stop_event.set()
self._stopped_event.wait(5.0) self._stopped_event.wait(5.0)
def uuid(self) -> UUID:
return self._id
def return_signal(self) -> SignalDescriptor:
return self._return_signal
class DeriveRegistry:
def __init__(
self,
core_input_queue: Queue,
get_live_value: Callable[[UUID], ValueDescriptor | None],
) -> None:
self._lock = RLock()
self._units: dict[UUID, DeriveUnit] = {}
self._core_input_queue = core_input_queue
self._get_live_value = get_live_value
self._routing_queue: Queue[ValueDescriptor] = Queue()
self._routing_stop_event = threading.Event()
self._routing_stopped_event = threading.Event()
self._routing_worker_thread = Thread(
target=self._routing_worker,
name="derive_registry_routing_worker",
daemon=True,
)
self._routing_worker_thread.start()
def register(
self,
function: str,
input_signals: list[SignalDescriptor],
output_signal: SignalDescriptor,
unit_uuid: UUID | None = None,
) -> DeriveUnit:
if unit_uuid is not None:
with self._lock:
current = self.get(unit_uuid)
if current is not None:
log.warning(
"Derive Unit %s is already registered",
current._id,
extra={
"event": "derive.registration_rejected",
"derive_uuid": str(current._id),
"reason": "duplicate_uuid",
},
)
raise DeriveRegistryAlreadyRegisteredError
unit_id = unit_uuid or uuid4()
namespace = {}
exec(function, namespace)
functions = [
obj for obj in namespace.values() if isinstance(obj, types.FunctionType)
]
if len(functions) != 1:
raise DeriveUnitMultipleFunctionsFoundError
unit = DeriveUnit(
unit_id, functions[0], input_signals, output_signal, self._core_input_queue
)
with self._lock:
self._units[unit_id] = unit
return unit
def unregister(self, unit_uuid: UUID) -> None:
with self._lock:
current = self._units.get(unit_uuid)
if current is not None:
current.stop()
del self._units[unit_uuid]
else:
log.debug(
"Unit %s was not registered",
unit_uuid,
extra={
"event": "derive.unregister_noop",
"derive_uuid": str(unit_uuid),
},
)
return
def get(self, unit_uuid: UUID) -> DeriveUnit | None:
with self._lock:
return self._units.get(unit_uuid)
def get_signal_descriptor(self, signal_id: UUID) -> SignalDescriptor | None:
with self._lock:
units = list(self._units.values())
for unit in units:
signal = unit.return_signal()
if signal.id == signal_id:
return signal
return None
def get_all_signal_descriptors(self) -> list[SignalDescriptor]:
with self._lock:
units = list(self._units.values())
signals: list[SignalDescriptor] = []
for unit in units:
signals.append(unit.return_signal())
return signals
def put_data(self, message: ValueDescriptor) -> None:
if not self._routing_stop_event.is_set():
self._routing_queue.put_nowait(message)
def stop(self) -> None:
self._routing_stop_event.set()
if not self._routing_worker_thread.is_alive():
self._routing_stopped_event.set()
self._routing_stopped_event.wait(5.0)
with self._lock:
units = list(self._units.values())
for unit in units:
unit.stop()
self._units.clear()
def _routing_worker(self) -> None:
try:
while not self._routing_stop_event.is_set():
try:
message = self._routing_queue.get(timeout=0.1)
except Empty:
continue
self._route_data(message)
finally:
self._routing_stopped_event.set()
def _route_data(self, message: ValueDescriptor) -> None:
with self._lock:
units = list(self._units.values())
for unit in units:
input_signals = unit.get_input_signals()
if message.signal_id not in [signal.id for signal in input_signals]:
continue
args: list[ValueDescriptor] = []
for signal in input_signals:
if signal.id == message.signal_id:
args.append(message)
continue
value_descriptor = self._get_live_value(signal.id)
if value_descriptor is None:
break
args.append(value_descriptor)
else:
unit.put_data(args)
+13 -4
View File
@@ -11,10 +11,6 @@ class CoreStateMismatchError(CoreError):
"""DynaLab Core state error.""" """DynaLab Core state error."""
class CoreMultipleFunctionsFoundError(CoreError):
"""DynaLab Core multiple functions found while trying to create derive unit."""
# DLPak error declarations # DLPak error declarations
class DLPakError(Exception): class DLPakError(Exception):
"""DLPak error.""" """DLPak error."""
@@ -35,3 +31,16 @@ class DeriveUnitInvalidSignatureError(DeriveUnitError):
class DeriveUnitArgsMismatchError(DeriveUnitError): class DeriveUnitArgsMismatchError(DeriveUnitError):
"""DeriveUnit incorrect arguments provided error.""" """DeriveUnit incorrect arguments provided error."""
class DeriveUnitMultipleFunctionsFoundError(DeriveUnitError):
"""DynaLab Core multiple functions found while trying to create derive unit."""
# DeriveRegistry error declarations
class DeriveRegistryError(Exception):
"""Generic ConnectorRegistry Error"""
class DeriveRegistryAlreadyRegisteredError(DeriveRegistryError):
"""ConnectorRegistry endpoint already registered"""
+1 -2
View File
@@ -386,6 +386,7 @@ class ConnectorRegistry:
current = self._endpoints.get(connector_uuid) current = self._endpoints.get(connector_uuid)
if current is endpoint: if current is endpoint:
endpoint.stop()
del self._endpoints[connector_uuid] del self._endpoints[connector_uuid]
endpoint_count = len(self._endpoints) endpoint_count = len(self._endpoints)
else: else:
@@ -399,8 +400,6 @@ class ConnectorRegistry:
) )
return return
endpoint.stop()
log.info( log.info(
"Unregistered connector %s", "Unregistered connector %s",
connector_uuid, connector_uuid,
+68
View File
@@ -0,0 +1,68 @@
from queue import Empty, Queue
import time
from uuid import UUID, uuid4
from dynalab_core.derive import DeriveRegistry
from dynalab_core.protocols.packets.data import ValueDescriptor
from dynalab_core.protocols.packets.handshake import SignalDescriptor
PROCESSING_FUNCTION = """
from dynalab_core.protocols.packets.data import ValueDescriptor
from dynalab_core.protocols.packets.handshake import SignalDescriptor
def add(left: ValueDescriptor, right: ValueDescriptor, output: SignalDescriptor) -> ValueDescriptor:
return ValueDescriptor(
signal_id=output.id,
value=left.value + right.value,
timestamp=left.timestamp,
)
"""
def test_derive_registry_routes_with_live_values() -> None:
output_queue: Queue[ValueDescriptor] = Queue()
left_signal = SignalDescriptor(id=uuid4(), name="Left", type="number")
right_signal = SignalDescriptor(id=uuid4(), name="Right", type="number")
output_signal = SignalDescriptor(id=uuid4(), name="Total", type="number")
live_values: dict[UUID, ValueDescriptor] = {}
registry = DeriveRegistry(output_queue, live_values.get)
try:
unit = registry.register(
PROCESSING_FUNCTION, [left_signal, right_signal], output_signal
)
right_value = ValueDescriptor(
signal_id=right_signal.id, value=2.0, timestamp=1
)
live_values[right_signal.id] = right_value
left_value = ValueDescriptor(signal_id=left_signal.id, value=3.0, timestamp=2)
registry.put_data(left_value)
deadline = time.monotonic() + 1.0
while True:
try:
result = output_queue.get_nowait()
break
except Empty:
if time.monotonic() >= deadline:
raise AssertionError("Derive unit did not produce a value")
time.sleep(0.01)
registry.unregister(unit.uuid())
assert registry.get(unit.uuid()) is None
finally:
registry.stop()
assert result.signal_id == output_signal.id
assert result.value == 5.0
assert result.timestamp == left_value.timestamp
def test_derive_registry_ignores_unknown_unit_on_unregister() -> None:
registry = DeriveRegistry(Queue(), lambda signal_id: None)
try:
registry.unregister(uuid4())
finally:
registry.stop()