Merge release/SIENTIAPDE-1646 into feature/SIENTIAPDE-1986-simple-metrics-rewrite
Requirements follow the release; only sientia_model>=0.12.0 (RegressionMetrics), model-library@0.13.1 and scikit-learn==1.8.0 (local) are kept from this branch.
This commit is contained in:
@@ -29,6 +29,7 @@ from laborious.workflows.sub_workflows.prediction_process import PredictionProce
|
||||
from sientia_do.notifications.handlers import CoreNotificationHandler
|
||||
from sientia_do.observability.logger import Logger
|
||||
from sientia_do.observability.metrics_controller import MetricsController
|
||||
from sientia_do.repository.mongodb_repository_sync import MongoDBRepository
|
||||
|
||||
# Single source of truth for the test database schema. Mirrors the production
|
||||
# DDL for ``sientia_data`` so any production change can be pasted directly into
|
||||
@@ -141,6 +142,24 @@ def notification_inserts(notification_handler):
|
||||
collection.insert_one = original_insert_one
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
def mongo_repository(mock_logger, notification_handler, metrics_controller, mongo_container):
|
||||
"""Real MongoDBRepository for gate metadata (prediction_metadata), same testcontainer as
|
||||
``notification_handler`` above."""
|
||||
mongo_port = mongo_container.get_exposed_port(27017)
|
||||
repository = MongoDBRepository(
|
||||
connection_string=f'mongodb://localhost:{mongo_port}',
|
||||
database_name='test_db',
|
||||
logger=mock_logger,
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=metrics_controller,
|
||||
)
|
||||
try:
|
||||
yield repository
|
||||
finally:
|
||||
repository.close()
|
||||
|
||||
|
||||
class _FakeModelWrapper:
|
||||
"""External MLflow wrapper double used by repository stub."""
|
||||
|
||||
@@ -233,6 +252,7 @@ async def test_activities(
|
||||
plugin_store_stub,
|
||||
pi_web_api_client_stub,
|
||||
opc_repository_stub,
|
||||
mongo_repository,
|
||||
):
|
||||
"""Activities with real infra and external-system stubs only."""
|
||||
minio_client = minio_container.get_client()
|
||||
@@ -269,6 +289,7 @@ async def test_activities(
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=metrics_controller,
|
||||
mlflow_repository=mlflow_repository_stub,
|
||||
mongo_repository=mongo_repository,
|
||||
)
|
||||
activities.pi_web_api_client = pi_web_api_client_stub
|
||||
activities.opc_repository = {'1': opc_repository_stub}
|
||||
@@ -289,6 +310,7 @@ async def test_activities_real_minio(
|
||||
plugin_store_stub,
|
||||
pi_web_api_client_stub,
|
||||
opc_repository_stub,
|
||||
mongo_repository,
|
||||
):
|
||||
"""Compatibility alias for offload tests."""
|
||||
minio_client = minio_container.get_client()
|
||||
@@ -325,6 +347,7 @@ async def test_activities_real_minio(
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=metrics_controller,
|
||||
mlflow_repository=mlflow_repository_stub,
|
||||
mongo_repository=mongo_repository,
|
||||
)
|
||||
activities.pi_web_api_client = pi_web_api_client_stub
|
||||
activities.opc_repository = {'1': opc_repository_stub}
|
||||
@@ -535,6 +558,7 @@ async def test_activities_real_opc(
|
||||
plugin_store_stub,
|
||||
pi_web_api_client_stub,
|
||||
opc_e2e_server: OpcE2ETestServer,
|
||||
mongo_repository,
|
||||
):
|
||||
"""Activities with real OpcRepository connected to the in-process OPC UA server."""
|
||||
minio_client = minio_container.get_client()
|
||||
@@ -571,6 +595,7 @@ async def test_activities_real_opc(
|
||||
notification_handler=notification_handler,
|
||||
metrics_controller=metrics_controller,
|
||||
mlflow_repository=mlflow_repository_stub,
|
||||
mongo_repository=mongo_repository,
|
||||
)
|
||||
activities.pi_web_api_client = pi_web_api_client_stub
|
||||
await asyncio.to_thread(_connect_activities_to_opc_server, activities, opc_e2e_server.url)
|
||||
|
||||
@@ -40,6 +40,7 @@ CREATE TABLE IF NOT EXISTS sientia_data.predictions (
|
||||
"timestamp" timestamptz NOT NULL,
|
||||
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"comments" text NULL,
|
||||
metadata_id varchar(24) NULL,
|
||||
CONSTRAINT unique_model_id_timestamp
|
||||
UNIQUE (model_id, "timestamp")
|
||||
);
|
||||
|
||||
@@ -147,8 +147,11 @@ class OpcE2ETestServer:
|
||||
value = await self._confidence_node.read_value()
|
||||
return float(value)
|
||||
|
||||
async def _pre_write_callback(self, _event, _service) -> None:
|
||||
if self._session_bad_on_write:
|
||||
async def _pre_write_callback(self, event, _service) -> None:
|
||||
# Only client writes are faulted. asyncua's own clock task writes CurrentTime
|
||||
# through this same PreWrite dispatch, and raising there kills its time_task;
|
||||
# the stored exception then resurfaces as a teardown failure in server.stop().
|
||||
if self._session_bad_on_write and getattr(event, 'is_external', False):
|
||||
raise ServiceError(ua.StatusCodes.BadSessionIdInvalid)
|
||||
|
||||
|
||||
|
||||
@@ -7,10 +7,51 @@ It is a functional reference of scenario behavior, inputs, and expected outcomes
|
||||
|
||||
## Execution Context
|
||||
|
||||
### One-time setup: disable the testcontainers reaper
|
||||
|
||||
Do this once per machine, before running anything under `e2e/`:
|
||||
|
||||
```bash
|
||||
echo "ryuk.disabled=true" >> ~/.testcontainers.properties
|
||||
```
|
||||
|
||||
**Why.** Before starting any test container, testcontainers starts a reaper container ("Ryuk") whose
|
||||
job is to clean up containers if the test process dies without running its teardown. Ryuk bind-mounts
|
||||
the Docker socket. Under **Docker Desktop on Linux** the socket lives at
|
||||
`~/.docker/desktop/docker.sock`, which Docker Desktop's File Sharing does not expose, so the mount is
|
||||
denied and the reaper cannot start:
|
||||
|
||||
```
|
||||
docker.errors.APIError: 500 Server Error ... /containers/<id>/start:
|
||||
Mounts denied: The path /socket_mnt/home/<user>/.docker/desktop/docker.sock
|
||||
is not shared from the host and is not known to Docker.
|
||||
```
|
||||
|
||||
Every test then errors **at setup** — `ERROR`, not `FAILED`, and never inside an assertion. If you see
|
||||
that, this is why.
|
||||
|
||||
**What you give up.** Nothing in a normal run: every fixture in `conftest.py` stops its own containers
|
||||
in teardown. Ryuk only covers the case where the process is killed without teardown (`kill -9`, a
|
||||
crash, a hard stop from the IDE). After one of those, clean up by hand:
|
||||
|
||||
```bash
|
||||
docker ps -a --filter "label=org.testcontainers=true" -q | xargs -r docker rm -f
|
||||
```
|
||||
|
||||
*Alternative, if you want to keep the reaper:* point Docker at the classic socket with
|
||||
`export DOCKER_HOST=unix:///var/run/docker.sock`. That only works if a native Docker Engine is
|
||||
running alongside Docker Desktop; if the Desktop daemon is the only one, disabling Ryuk is the way.
|
||||
|
||||
|
||||
- Tests run under `e2e/` and are marked with `@pytest.mark.integration`.
|
||||
- PostgreSQL and MinIO are provisioned with testcontainers.
|
||||
- `test_minio_offload.py` uses real MinIO I/O; other scenario suites may use stubs/mocks for optional outputs.
|
||||
- Real OPC UA scenarios use `@pytest.mark.opc` and an in-process asyncua server (`e2e/test_opc_real_server.py`).
|
||||
- **Nothing under `e2e/` is covered by `validate`**: that script lints `laborious/` and `tests/`
|
||||
only, and its test step is `pytest tests/` (`testpaths = ["tests"]`). Lint these files by hand
|
||||
(`ruff format --check e2e/ && ruff check e2e/`), and remember that `addopts` carries
|
||||
`--strict-markers`: a test without `@pytest.mark.integration` is not collected, and a run that
|
||||
collects nothing still exits 0. Check the collected count, not the exit code.
|
||||
|
||||
### Local validation
|
||||
|
||||
|
||||
@@ -7,13 +7,17 @@ Mock-based OPC tests remain in test_predictions_batch_format_export.py.
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from e2e.helpers import assert_prediction, insert_sample_data, make_workflow_id, start_and_await_workflow
|
||||
from e2e.helpers import (
|
||||
assert_prediction,
|
||||
insert_sample_data,
|
||||
make_workflow_id,
|
||||
start_and_await_workflow,
|
||||
)
|
||||
from e2e.opc_test_server import UNKNOWN_NODE_ID, OpcE2ETestServer, build_opc_output_config
|
||||
from e2e.test_predictions_batch_format_export import get_base_input_data
|
||||
from laborious.activities.activities import Activities
|
||||
@@ -22,16 +26,24 @@ from laborious.utils.repository.opc_repository import OpcRepository
|
||||
from laborious.workflows.predictions_batch import PredictionsBatch
|
||||
|
||||
|
||||
def _slow_reconnect_under_lock(repo: OpcRepository, hold_seconds: float = 0.75) -> None:
|
||||
def _reconnect_under_held_lock(
|
||||
repo: OpcRepository, acquired: threading.Event, release: threading.Event
|
||||
) -> None:
|
||||
"""
|
||||
Hold the connection lock briefly so concurrent writes see reconnect_in_progress.
|
||||
Hold the connection lock until released, then reconnect.
|
||||
|
||||
The lock must stay held for the whole workflow, and how long the workflow takes to
|
||||
reach the OPC write is not bounded — a fixed sleep here lets `_reconnect_locked`
|
||||
finish first, which sets `_session_ready` again and the write then succeeds.
|
||||
|
||||
Args:
|
||||
repo (OpcRepository): Connected repository.
|
||||
hold_seconds (float): Time to keep the lock before reconnecting.
|
||||
acquired (threading.Event): Set once the lock is held, before any write can race.
|
||||
release (threading.Event): Waited on while holding the lock; set by the caller.
|
||||
"""
|
||||
with repo._connection_lock:
|
||||
time.sleep(hold_seconds)
|
||||
acquired.set()
|
||||
release.wait()
|
||||
repo._reconnect_locked()
|
||||
|
||||
|
||||
@@ -172,12 +184,15 @@ async def test_scenario_3_2_5_opc_write_blocked_during_reconnect_real_server(
|
||||
|
||||
repo = test_activities_real_opc.opc_repository['1']
|
||||
repo._session_ready.clear()
|
||||
acquired = threading.Event()
|
||||
release = threading.Event()
|
||||
reconnect_thread = threading.Thread(
|
||||
target=_slow_reconnect_under_lock,
|
||||
args=(repo,),
|
||||
target=_reconnect_under_held_lock,
|
||||
args=(repo, acquired, release),
|
||||
daemon=True,
|
||||
)
|
||||
reconnect_thread.start()
|
||||
acquired.wait(timeout=5.0)
|
||||
|
||||
input_data = get_base_input_data(model_id)
|
||||
input_data['opc_output_config'] = build_opc_output_config(opc_e2e_server.node_ids)
|
||||
@@ -191,6 +206,7 @@ async def test_scenario_3_2_5_opc_write_blocked_during_reconnect_real_server(
|
||||
make_workflow_id('test-opc-real-reconnect-block'),
|
||||
)
|
||||
finally:
|
||||
release.set()
|
||||
reconnect_thread.join(timeout=5.0)
|
||||
|
||||
assert_prediction(
|
||||
|
||||
Reference in New Issue
Block a user