Added playback mode ready for offline processing

CoreConfig how has a playback mode option which can be set to reprocess
or read.

Setting it to reprocess means that all derived signals are reprocessed
during playback, in this case the PlaybackEngine only plays back the
signals whose source is set to "origin"

Setting it to read means that all derived signals are playedback from
the recorded buffer, this whill be the preferred mode once offline
processing is set up which will allow for more complex data processing
offline once a recording is completed, which would require the read
playback mode as it wouldn't support realtime processing as reprocess
does
This commit is contained in:
h3cx committed 2026-09-18 21:27:52 +02:00
1 parent 563a35ee2c
commit 377e6fb323
11 files changed
+52 -689

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@ processing_str = Path(
signal_id_1: UUID = UUID("3f32cea3-d872-4c16-a0a2-54b57171aeb2")
signal_id_2: UUID = UUID("6578ac37-99d1-410c-b3f7-d049339919b2")
config = CoreConfig(port=8765)
config = CoreConfig(port=8765, thread_debug=True)
log.info("Configured manual core on %s", config.bind_str())
dl_core = Core(config)
dl_core.start()
+1 -1
View File
@@ -273,7 +273,7 @@ def main() -> None:
dynalab_logger.setLevel(logging.DEBUG)
dynalab_logger.propagate = False
dynalab_logger.addHandler(log_handler)
config = CoreConfig(host=HOST, port=PORT, thread_debug=False)
config = CoreConfig(host=HOST, port=PORT, playback_mode="read")
core = Core(config)
processing_source = (
Path(__file__).with_name("process.py").read_text(encoding="utf-8")
-134
View File
@@ -1,134 +0,0 @@
import logging
from pathlib import Path
import threading
from uuid import uuid4
import pytest
from dynalab_core import Core
from dynalab_core.config import CoreConfig
from dynalab_core.dlunit import DLUnit
from dynalab_core.errors import CoreError
from dynalab_core.protocols.endpoint import ConnectorEndpoint
from dynalab_core.protocols.packets.data import ValueDescriptor
from dynalab_core.protocols.packets.handshake import ConnectorHello, SignalDescriptor
from test.common import find_available_port
def test_core_cannot_start_twice(caplog: pytest.LogCaptureFixture) -> None:
port = find_available_port(8765)
core = Core(CoreConfig(port=port))
with caplog.at_level(logging.DEBUG, logger="dynalab_core"):
core.start()
try:
with pytest.raises(CoreError, match="can only be started once") as raised:
core.start()
finally:
core.stop()
events = [getattr(record, "event", None) for record in caplog.records]
assert events.count("core.starting") == 1
assert "core.start_rejected" in events
assert "core.stopped" in events
rejection = next(
record
for record in caplog.records
if getattr(record, "event", None) == "core.start_rejected"
)
assert rejection.levelno == logging.WARNING
assert rejection.core_state == "started"
assert raised.value.code == "invalid_state"
def test_core_input_worker_logs_failure(
caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
core = Core(CoreConfig(port=find_available_port(8765)))
def fail_routing(message: ValueDescriptor) -> None:
raise RuntimeError("routing failed")
monkeypatch.setattr(core._derive_registry, "put_data", fail_routing)
with caplog.at_level(logging.DEBUG, logger="dynalab_core"):
core.start()
try:
core._data_input_queue.put_nowait(
ValueDescriptor(signal_id=uuid4(), value=1.0, timestamp=1)
)
assert core._input_worker_stopped_event.wait(1.0)
finally:
core.stop()
failures = [
record
for record in caplog.records
if getattr(record, "event", None) == "core.input_worker_failed"
]
assert len(failures) == 1
assert failures[0].exception_type == "RuntimeError"
assert failures[0].message_type == "ValueDescriptor"
assert failures[0].exc_info is not None
def test_core_mode_is_shared_with_endpoints() -> None:
core = Core(CoreConfig(port=find_available_port(8765)))
endpoint = ConnectorEndpoint(
ConnectorHello(
connector_uuid=uuid4(),
connector_name="test",
connector_version="1.0",
signals=[],
),
threading.Event(),
threading.Event(),
core._data_input_queue,
core._connector_registry._core_mode,
)
try:
assert endpoint._core_mode is core._mode
assert core.get_mode() == "realtime"
core.set_mode("playback")
assert endpoint._core_mode.value == "playback"
assert core.get_mode() == "playback"
with pytest.raises(CoreError) as raised:
core.load_playback_engine()
assert raised.value.code == "no_playback_data"
finally:
core.stop()
def test_core_binds_in_memory_dlunit() -> None:
core = Core(CoreConfig(port=find_available_port(8765)))
input_signal = SignalDescriptor(id=uuid4(), name="Input", type="number")
return_signal = SignalDescriptor(
id=uuid4(), name="Output", type="number", origin="derived"
)
dlunit = DLUnit()
dlunit.set_manifest(uuid4(), [input_signal], return_signal)
dlunit.set_data(
"""
from dynalab_core.protocols.packets.data import ValueDescriptor
from dynalab_core.protocols.packets.handshake import SignalDescriptor
def identity(value: ValueDescriptor, output: SignalDescriptor) -> ValueDescriptor:
return ValueDescriptor(signal_id=output.id, value=value.value, timestamp=value.timestamp)
"""
)
try:
unit_id = core.bind_derive_unit(dlunit)
assert core.get_signal_descriptor(return_signal.id) == return_signal
with pytest.raises(CoreError, match="either a DLUnit or a file") as raised:
core.bind_derive_unit(dlunit, file=Path("unit.dlunit"))
assert raised.value.code == "bind_derive_failed"
core.unbind_derive_unit(unit_id)
finally:
core.stop()
-128
View File
@@ -1,128 +0,0 @@
import logging
from queue import Empty, Queue
import time
from uuid import UUID, uuid4
import pytest
from dynalab_core.derive import DeriveRegistry
from dynalab_core.dlunit import DLUnit
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,
)
"""
FAILING_PROCESSING_FUNCTION = """
from dynalab_core.protocols.packets.data import ValueDescriptor
from dynalab_core.protocols.packets.handshake import SignalDescriptor
def fail(value: ValueDescriptor, output: SignalDescriptor) -> ValueDescriptor:
raise RuntimeError("derive failed")
"""
def make_dlunit(
source: str,
input_signals: list[SignalDescriptor],
return_signal: SignalDescriptor,
) -> DLUnit:
dlunit = DLUnit()
dlunit.set_manifest(uuid4(), input_signals, return_signal)
dlunit.set_data(source)
return dlunit
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(
make_dlunit(
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()
def test_derive_worker_logs_failure_and_stops(
caplog: pytest.LogCaptureFixture,
) -> None:
input_signal = SignalDescriptor(id=uuid4(), name="Input", type="number")
output_signal = SignalDescriptor(id=uuid4(), name="Output", type="number")
registry = DeriveRegistry(Queue(), lambda signal_id: None)
with caplog.at_level(logging.DEBUG, logger="dynalab_core"):
try:
unit = registry.register(
make_dlunit(
FAILING_PROCESSING_FUNCTION,
[input_signal],
output_signal,
)
)
unit.put_data(
[ValueDescriptor(signal_id=input_signal.id, value=1.0, timestamp=1)]
)
assert unit._stopped_event.wait(1.0)
assert registry.get_all_signal_descriptors() == []
finally:
registry.stop()
failures = [
record
for record in caplog.records
if getattr(record, "event", None) == "derive.worker_failed"
]
assert len(failures) == 1
assert failures[0].derive_uuid == str(unit.uuid())
assert failures[0].exception_type == "RuntimeError"
assert failures[0].exc_info is not None
-47
View File
@@ -1,47 +0,0 @@
import logging
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from dynalab_core.buffer import ValueBuffer
from dynalab_core.dlpak import DLPak
from dynalab_core.protocols.packets.handshake import SignalDescriptor
def test_dlpak_logs_written_archive(caplog: pytest.LogCaptureFixture, tmp_path) -> None:
signal = SignalDescriptor(id=uuid4(), name="Signal", type="number")
buffer = ValueBuffer()
buffer.append(1, signal.id, 2.0)
package = DLPak()
package.set_data(buffer)
package.set_manifest(datetime.now(timezone.utc), [signal])
with caplog.at_level(logging.INFO, logger="dynalab_core"):
package.write(tmp_path, "recording")
record = next(
record
for record in caplog.records
if getattr(record, "event", None) == "dlpak.written"
)
assert record.output_filename == "recording.dlpak"
assert record.sample_count == 1
assert record.signal_count == 1
def test_dlpak_read_restores_manifest_and_data(tmp_path) -> None:
timestamp = datetime.now(timezone.utc)
signal = SignalDescriptor(id=uuid4(), name="Signal", type="number")
buffer = ValueBuffer()
buffer.append(1, signal.id, 2.0)
package = DLPak()
package.set_data(buffer)
package.set_manifest(timestamp, [signal])
package.write(tmp_path, "recording")
loaded_package = DLPak()
loaded_package.read(tmp_path / "recording.dlpak")
assert loaded_package._manifest == package._manifest
assert loaded_package._data is not None
assert loaded_package._data.export_csv() == buffer.export_csv()
-150
View File
@@ -1,150 +0,0 @@
from datetime import datetime, timezone
from queue import Queue
from uuid import uuid4
from zipfile import BadZipFile
import pytest
from dynalab_core.buffer import ValueBuffer
from dynalab_core.derive import DeriveRegistry, DeriveUnit
from dynalab_core.dlpak import DLPak
from dynalab_core.dlunit import DLUnit
from dynalab_core.errors import (
CoreError,
DLPakError,
DeriveError,
DynaLabError,
PlaybackError,
)
from dynalab_core.playback import PlaybackEngine
from dynalab_core.protocols.packets.data import ValueDescriptor
from dynalab_core.protocols.packets.handshake import SignalDescriptor
def _identity(
value: ValueDescriptor, output: SignalDescriptor
) -> ValueDescriptor:
return ValueDescriptor(
signal_id=output.id,
value=value.value,
timestamp=value.timestamp,
)
def test_subsystem_errors_share_public_base_and_code() -> None:
error = PlaybackError("Playback data is unavailable", code="no_data")
assert isinstance(error, DynaLabError)
assert error.code == "no_data"
assert str(error) == "Playback data is unavailable"
def test_core_rejects_invalid_mode_and_missing_recording() -> None:
from dynalab_core import Core
core = Core.__new__(Core)
core._processing_buffer = None
with pytest.raises(CoreError, match="Unknown core mode") as mode:
core.set_mode("invalid") # type: ignore[arg-type]
with pytest.raises(CoreError, match="no recording") as recording:
core.write(".", "unused")
assert mode.value.code == "invalid_mode"
assert recording.value.code == "no_recording"
def test_dlpak_requires_data_before_manifest() -> None:
package = DLPak()
with pytest.raises(DLPakError, match="before setting its data") as raised:
package.set_manifest(datetime.now(timezone.utc), [])
assert raised.value.code == "no_data"
def test_dlpak_wraps_archive_read_failures(tmp_path) -> None:
invalid_archive = tmp_path / "invalid.dlpak"
invalid_archive.write_text("not a zip archive")
with pytest.raises(DLPakError, match="Could not read") as raised:
DLPak().read(invalid_archive)
assert raised.value.code == "read_failed"
assert isinstance(raised.value.__cause__, BadZipFile)
def test_derive_unit_reports_argument_mismatch() -> None:
input_signal = SignalDescriptor(id=uuid4(), name="Input", type="number")
output_signal = SignalDescriptor(
id=uuid4(), name="Output", type="number", origin="derived"
)
unit = DeriveUnit(uuid4(), _identity, [input_signal], output_signal, Queue())
try:
with pytest.raises(DeriveError, match="expected 1 input") as raised:
unit.process_offline([])
finally:
unit.stop()
assert raised.value.code == "argument_mismatch"
def test_derive_registry_wraps_invalid_source() -> None:
registry = DeriveRegistry(Queue(), lambda signal_id: None)
output_signal = SignalDescriptor(
id=uuid4(), name="Output", type="number", origin="derived"
)
try:
with pytest.raises(DeriveError, match="Could not load") as raised:
dlunit = DLUnit()
dlunit.set_manifest(uuid4(), [], output_signal)
dlunit.set_data("def broken(")
registry.register(dlunit)
finally:
registry.stop()
assert raised.value.code == "invalid_source"
assert isinstance(raised.value.__cause__, SyntaxError)
def test_derive_unit_rejects_configured_input_mismatch() -> None:
output_signal = SignalDescriptor(
id=uuid4(), name="Output", type="number", origin="derived"
)
with pytest.raises(DeriveError, match="input signal") as raised:
DeriveUnit(uuid4(), _identity, [], output_signal, Queue())
assert raised.value.code == "input_signal_mismatch"
def test_playback_rejects_incomplete_data_and_negative_seek() -> None:
engine = PlaybackEngine(Queue())
try:
with pytest.raises(PlaybackError, match="incomplete") as incomplete:
engine.set_data(DLPak())
with pytest.raises(PlaybackError, match="cannot be negative") as timestamp:
engine.seek(-1)
finally:
engine.stop()
assert incomplete.value.code == "incomplete_data"
assert timestamp.value.code == "invalid_timestamp"
def test_playback_accepts_complete_dlpak() -> None:
signal = SignalDescriptor(id=uuid4(), name="Input", type="number")
data = ValueBuffer()
data.append(1, signal.id, 2.0)
package = DLPak()
package.set_data(data)
package.set_manifest(datetime.now(timezone.utc), [signal])
engine = PlaybackEngine(Queue())
try:
engine.set_data(package)
finally:
engine.stop()
-212
View File
@@ -1,212 +0,0 @@
import asyncio
import logging
import socket
import time
from uuid import uuid4
import pytest
from dynalab_core import Core
from dynalab_core.config import CoreConfig
from dynalab_core.errors import JsonServerError
from dynalab_core.protocols.constants import PROTOCOL_VERSION
from dynalab_core.protocols.common import VersionDescriptor
from dynalab_core.protocols.packets import JsonConnectorHello
from dynalab_core.protocols.packets.handshake import (
ConnectorHello,
JsonSignalDescriptor,
SignalDescriptor,
)
from test.common import find_available_port
def _wait_for_event(
caplog: pytest.LogCaptureFixture, event: str, timeout: float = 1.0
) -> logging.LogRecord:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
for record in caplog.records:
if getattr(record, "event", None) == event:
return record
time.sleep(0.01)
raise AssertionError(f"Log event {event!r} was not emitted")
def test_json_server_raises_timeout_error(
caplog: pytest.LogCaptureFixture,
) -> None:
port = find_available_port(8765)
core = Core(CoreConfig(port=port))
core._json_server._debug_timeout_test = True
core._json_server._timeout = 0.01
with caplog.at_level(logging.DEBUG, logger="dynalab_core"):
try:
with pytest.raises(JsonServerError, match="did not start") as raised:
core.start()
finally:
core.stop()
timeout_record = next(
record
for record in caplog.records
if getattr(record, "event", None) == "json_server.start_timeout"
)
assert timeout_record.levelno == logging.ERROR
assert timeout_record.port == port
assert raised.value.code == "start_timeout"
def test_json_server_raises_startup_error(
caplog: pytest.LogCaptureFixture,
) -> None:
port = find_available_port(8765)
core1 = Core(CoreConfig(port=port))
core2 = Core(CoreConfig(port=port))
with caplog.at_level(logging.DEBUG, logger="dynalab_core"):
core1.start()
try:
with pytest.raises(JsonServerError, match="failed to start") as raised:
core2.start()
finally:
core1.stop()
core2.stop()
failure_record = next(
record
for record in caplog.records
if getattr(record, "event", None) == "json_server.start_failed"
)
assert failure_record.levelno == logging.ERROR
assert failure_record.port == port
assert failure_record.exc_info is not None
assert raised.value.code == "startup_failed"
assert isinstance(raised.value.__cause__, OSError)
def test_json_server_logs_invalid_handshake(
caplog: pytest.LogCaptureFixture,
) -> None:
port = find_available_port(8765)
core = Core(CoreConfig(port=port))
with caplog.at_level(logging.DEBUG, logger="dynalab_core"):
core.start()
try:
with socket.create_connection(("127.0.0.1", port), timeout=1) as peer:
server_hello = peer.makefile("rb").readline()
assert server_hello
peer.sendall(b'{"secret":"must-not-appear-in-logs"}\n')
rejection = _wait_for_event(caplog, "connector.handshake_rejected")
finally:
core.stop()
assert rejection.levelno == logging.WARNING
assert rejection.reason == "invalid_frame"
assert rejection.connection_id
assert rejection.peer_address
assert "must-not-appear-in-logs" not in caplog.text
def test_json_server_reports_unexpected_thread_startup_failure(
caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
async def fail_start_server(*args: object, **kwargs: object) -> None:
raise RuntimeError("unexpected startup failure")
monkeypatch.setattr(asyncio, "start_server", fail_start_server)
core = Core(CoreConfig(port=find_available_port(8765)))
with caplog.at_level(logging.DEBUG, logger="dynalab_core"):
try:
with pytest.raises(JsonServerError) as raised:
core.start()
finally:
core.stop()
failures = [
record
for record in caplog.records
if getattr(record, "event", None) == "json_server.thread_failed"
]
assert len(failures) == 1
assert failures[0].startup_complete is False
assert failures[0].exception_type == "RuntimeError"
assert failures[0].exc_info is not None
assert raised.value.code == "startup_failed"
assert isinstance(raised.value.__cause__, RuntimeError)
def test_rejected_handshake_is_not_logged_as_connected(
caplog: pytest.LogCaptureFixture,
) -> None:
port = find_available_port(8765)
core = Core(CoreConfig(port=port))
connector_uuid = uuid4()
connector_hello = JsonConnectorHello(
connector_uuid=connector_uuid,
protocol_version=VersionDescriptor(type="alpha", major=999, minor=0, patch=0),
connector_name="Incompatible connector",
connector_version="0.1.0-test",
signals=[JsonSignalDescriptor(id=uuid4(), name="Dummy signal", type="number")],
)
with caplog.at_level(logging.DEBUG, logger="dynalab_core"):
core.start()
try:
with socket.create_connection(("127.0.0.1", port), timeout=1) as peer:
peer_file = peer.makefile("rb")
assert peer_file.readline()
peer.sendall(connector_hello.model_dump_json().encode("utf-8") + b"\n")
assert b'"type":"handshake_rejected"' in peer_file.readline()
rejection = _wait_for_event(caplog, "endpoint.handshake_rejected")
finally:
core.stop()
assert rejection.reason == "protocol_version_mismatch"
assert rejection.connector_uuid == str(connector_uuid)
assert not any(
getattr(record, "event", None) == "connector.connected"
and getattr(record, "connector_uuid", None) == str(connector_uuid)
for record in caplog.records
)
def test_json_server_waits_for_connection_handlers_on_stop(
caplog: pytest.LogCaptureFixture,
) -> None:
port = find_available_port(8765)
core = Core(CoreConfig(port=port))
connector_uuid = uuid4()
connector_hello = JsonConnectorHello(
connector_uuid=connector_uuid,
protocol_version=PROTOCOL_VERSION,
connector_name="Test connector",
connector_version="0.1.0-test",
signals=[JsonSignalDescriptor(id=uuid4(), name="Dummy signal", type="number")],
)
with caplog.at_level(logging.DEBUG, logger="dynalab_core"):
core.start()
try:
with socket.create_connection(("127.0.0.1", port), timeout=1) as peer:
peer_file = peer.makefile("rb")
assert peer_file.readline()
peer.sendall(connector_hello.model_dump_json().encode("utf-8") + b"\n")
assert b'"type":"handshake_accepted"' in peer_file.readline()
core.stop()
finally:
if core._state != "stopped":
core.stop()
events = [getattr(record, "event", None) for record in caplog.records]
assert "json_server.stop_timeout" not in events
assert "connector.connected" in events
assert events.index("connector.registered") < events.index("connector.connected")
assert core._json_server._stopped_event.is_set()
assert not core._json_server._handler_tasks
assert core._connector_registry.get(connector_uuid) is None