Compare commits
10 Commits
0.6.6
...
test-SIENT
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d6d169b09 | ||
|
|
60f4e3e421 | ||
|
|
efa0d9411b | ||
|
|
6334be2edf | ||
| b834c0b8e6 | |||
| bc4e64a271 | |||
| 52dffeecd4 | |||
|
|
c85c3440ed | ||
|
|
0edb2b5be0 | ||
|
|
da8a796197 |
@@ -45,9 +45,9 @@ CI quality gate (`.github/workflows/quality-gate.yml`) calls the shared reusable
|
|||||||
|
|
||||||
### Layering
|
### Layering
|
||||||
- **`orchestrator/worker/worker.py`** — entrypoint (`python -m orchestrator.worker.worker`). Builds a single `Activities` instance, connects to Temporal, starts Prometheus (`HTTP_METRICS_PORT`) and Temporal SDK metrics (`HTTP_SDK_METRICS_PORT`) servers, then runs three `Worker`s concurrently via `asyncio.gather`, each on its own task queue: `orchestrator-queue`, `alerts-queue`, `reports-queue`. `prepare_worker.py` derives the queue name from the workflow class name (`camel_to_snake`) and reads per-queue concurrency/poller tuning from env vars prefixed with the workflow name (e.g. `ORCHESTRATOR_MAX_CONCURRENT_ACTIVITIES`).
|
- **`orchestrator/worker/worker.py`** — entrypoint (`python -m orchestrator.worker.worker`). Builds a single `Activities` instance, connects to Temporal, starts Prometheus (`HTTP_METRICS_PORT`) and Temporal SDK metrics (`HTTP_SDK_METRICS_PORT`) servers, then runs three `Worker`s concurrently via `asyncio.gather`, each on its own task queue: `orchestrator-queue`, `alerts-queue`, `reports-queue`. `prepare_worker.py` derives the queue name from the workflow class name (`camel_to_snake`) and reads per-queue concurrency/poller tuning from env vars prefixed with the workflow name (e.g. `ORCHESTRATOR_MAX_CONCURRENT_ACTIVITIES`).
|
||||||
- **`orchestrator/workflows/`** — Temporal workflow definitions: `orchestrator.py` (pipeline/slot/schedule orchestration), `alerts.py` (real-time ERROR notifications), `reports.py` (scheduled all-level reports). `alerts.py` and `reports.py` both delegate to the same two subworkflows in `workflows/subworkflows/`: `load_notification_package.py` (load notifications + receiver configs from Mongo, incremental via Redis timestamp) and `process_notifications.py` (build HTML email, send, log to Postgres). The difference between alerts and reports is which filter activity they call (`filter_notification_alerts` vs `filter_notification_reports`) and their base data filter (`level: ERROR` by default vs all levels) — `load_notification_package.py`'s `_compute_query_time_filter` can widen either beyond that default to the union of active receiver groups' own `levels` (SIENTIAPDE-2011); the ERROR-only default only holds when no active group narrows further. `load_latest_data`'s Mongo query is capped at `NOTIFICATION_QUEUE_BATCH_SIZE` documents per run (default `0`, meaning unlimited — unchanged from pre-SIENTIAPDE-2013 behavior unless a deployment opts in), sorted by `timestamp` ascending so a capped run always returns the oldest pending batch first (SIENTIAPDE-2013) — a hard cap with no drain loop, since the incremental Redis cursor picks up any remainder on the next run.
|
- **`orchestrator/workflows/`** — Temporal workflow definitions: `orchestrator.py` (pipeline/slot/schedule orchestration), `alerts.py` (real-time ERROR notifications), `reports.py` (scheduled all-level reports). `alerts.py` and `reports.py` both delegate to the same two subworkflows in `workflows/subworkflows/`: `load_notification_package.py` (load notifications + receiver configs from Mongo, incremental via Redis timestamp) and `process_notifications.py` (build HTML email, send, log to Postgres). The difference between alerts and reports is which filter activity they call (`filter_notification_alerts` vs `filter_notification_reports`) and their base data filter (`level: ERROR` by default vs all levels) — `load_notification_package.py`'s `_compute_query_time_filter` can widen either beyond that default to the union of active receiver groups' own `levels` (SIENTIAPDE-2011); the ERROR-only default only holds when no active group narrows further. Receiver groups also support a `model_ids` allow-list (SIENTIAPDE-2149), independent of the pre-existing `ignore_models_id` deny-list — both apply together (AND) when a group configures both, and `model_ids` follows the same union-at-query-time / precise-per-group-slice split as `levels`. `load_latest_data`'s Mongo query is capped at `NOTIFICATION_QUEUE_BATCH_SIZE` documents per run (default `0`, meaning unlimited — unchanged from pre-SIENTIAPDE-2013 behavior unless a deployment opts in), sorted by `timestamp` ascending so a capped run always returns the oldest pending batch first (SIENTIAPDE-2013) — a hard cap with no drain loop, since the incremental Redis cursor picks up any remainder on the next run.
|
||||||
- **`orchestrator/activities/activities.py`** — `Activities` is a single class combining every activity mixin via multiple inheritance: `TemporalManager, SlotManager, Formatters, MongoDB, Email, Postgres` (Postgres comes from `sientia_do.temporal.activities.postgres`, the external `sientia-dataops-library` dependency). One instance is constructed in `worker.py` and its bound methods are handed to each `Worker` as the activity list — activities are plain methods on this shared object, not separate classes. Each mixin's `__init__` takes its own config dict plus shared `logger`, `notification_handler`, `metrics_controller`. `Activities.shutdown()` closes every mixin's connections.
|
- **`orchestrator/activities/activities.py`** — `Activities` is a single class combining every activity mixin via multiple inheritance: `TemporalManager, SlotManager, Formatters, MongoDB, Email, Postgres` (Postgres comes from `sientia_do.temporal.activities.postgres`, the external `sientia-dataops-library` dependency). One instance is constructed in `worker.py` and its bound methods are handed to each `Worker` as the activity list — activities are plain methods on this shared object, not separate classes. Each mixin's `__init__` takes its own config dict plus shared `logger`, `notification_handler`, `metrics_controller`. `Activities.shutdown()` closes every mixin's connections.
|
||||||
- **`orchestrator/utils/`** — `connectors_config.py` builds each config dict from env vars (Temporal/Redis/MongoDB/Postgres/Email); `email_builder.py` renders Jinja2 templates (`utils/templates/email_template.html`, `general_template.html`); `orchestrator_functions.py` transforms pipeline documents into pipeline-type-specific configs (`scouter`, `pi_web_api_scouter`, `predictions_batch`, `minimal_retrain`, `drift`, `simple_metrics`); `converters.py` handles Temporal schedule frequency parsing.
|
- **`orchestrator/utils/`** — `connectors_config.py` builds each config dict from env vars (Temporal/Redis/MongoDB/Postgres/Email); `email_builder.py` renders Jinja2 templates (`utils/templates/email_template.html`, `general_template.html`); `orchestrator_functions.py` transforms pipeline documents into pipeline-type-specific configs (`scouter`, `pi_web_api_scouter`, `predictions_batch`, `minimal_retrain`, `drift`, `simple_metrics`) and builds the OPC slot v2 the gateway reads (`gather_read_tags` / `build_tag_config`, contract C1: one entry per address with a `names[]` list, tags resolved through the tag database when they carry a `tag_id`; see the README section "OPC slots"). `resolve_tag_names` is the single source of tag names for both the scouter's `model_tags` and the slot's `names` (SIENTIAPDE-1974 SC1); `converters.py` handles Temporal schedule frequency parsing.
|
||||||
|
|
||||||
### Temporal-specific conventions (all workflow/activity files follow this)
|
### Temporal-specific conventions (all workflow/activity files follow this)
|
||||||
- Every workflow and activity-consuming module wraps non-deterministic/side-effecting imports in `with workflow.unsafe.imports_passed_through(): ...` right after `from temporalio import workflow`, because Temporal's sandbox restricts what workflow code can import directly. When adding a new workflow file, follow the existing pattern (see `orchestrator/workflows/orchestrator.py`, `orchestrator/activities/activities.py`) rather than importing at module top level.
|
- Every workflow and activity-consuming module wraps non-deterministic/side-effecting imports in `with workflow.unsafe.imports_passed_through(): ...` right after `from temporalio import workflow`, because Temporal's sandbox restricts what workflow code can import directly. When adding a new workflow file, follow the existing pattern (see `orchestrator/workflows/orchestrator.py`, `orchestrator/activities/activities.py`) rather than importing at module top level.
|
||||||
|
|||||||
94
README.md
94
README.md
@@ -97,7 +97,7 @@ The **Orchestrator** workflow is the main coordination workflow that manages pip
|
|||||||
- **Infrastructure Management**: Creates, updates, and deletes workflow schedules
|
- **Infrastructure Management**: Creates, updates, and deletes workflow schedules
|
||||||
|
|
||||||
**Execution Flow**:
|
**Execution Flow**:
|
||||||
1. **Parallel Data Loading**: Concurrently loads pipelines, OPC servers, orchestrated schedules, OPC slots, and active ingestors
|
1. **Parallel Data Loading**: Concurrently loads pipelines, OPC servers, orchestrated schedules, OPC slots, active ingestors, and the tag database (`connectors` and `connector_tags`)
|
||||||
2. **Parallel Processing**: Formats orchestrated schedules and processes new schedules and slots
|
2. **Parallel Processing**: Formats orchestrated schedules and processes new schedules and slots
|
||||||
3. **Parallel Config Creation**: Creates schedule and slot action configurations, normalizes schedules, and creates collections with TTL indexes
|
3. **Parallel Config Creation**: Creates schedule and slot action configurations, normalizes schedules, and creates collections with TTL indexes
|
||||||
4. **Parallel Operations**: Executes slot deletions, slot updates, schedule deletions, schedule creations, and schedule updates concurrently
|
4. **Parallel Operations**: Executes slot deletions, slot updates, schedule deletions, schedule creations, and schedule updates concurrently
|
||||||
@@ -121,6 +121,81 @@ The **Orchestrator** workflow is the main coordination workflow that manages pip
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Optional: `connectors_query` and `connector_tags_query` override the tag database aggregations. The defaults live in the code (`orchestrator/workflows/orchestrator.py`), so the schedule input does not need them:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"connectors_query": {
|
||||||
|
"collection": "connectors",
|
||||||
|
"aggregation": [
|
||||||
|
{"$match": {"type": "opc_ua", "status": "Active"}},
|
||||||
|
{"$project": {"_id": 0, "connector_id": 1, "name": 1, "url": 1, "server_uri": 1,
|
||||||
|
"use_security": 1, "subscription_period_ms": 1}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"connector_tags_query": {
|
||||||
|
"collection": "connector_tags",
|
||||||
|
"aggregation": [
|
||||||
|
{"$match": {"category": "Read", "status": "Active"}},
|
||||||
|
{"$project": {"_id": 0, "tag_id": 1, "connector_id": 1, "address": 1, "tag_name": 1}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The tag database is read only when a scouter read tag carries a `tag_id`, and the workflow puts `{"$match": {"tag_id": {"$in": [...]}}}` in front of `connector_tags_query`, so it loads only the referenced tags. Environments without a tag database, or whose pipelines do not use `tag_id`, never query it. An override should keep a `$project` of these fields: any other attribute may hold a BSON type a Temporal payload cannot carry (such as `ObjectId`).
|
||||||
|
|
||||||
|
#### OPC slots (slot v2, contract C1)
|
||||||
|
|
||||||
|
`process_slots` writes one slot per active ingestor to Redis (`slot:opc_tags:{n}`). The OPC Gateway reads it (SIENTIAPDE-1974). Each server carries its connection fields, and each tag address carries the list of names it is written under:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kepserver": {
|
||||||
|
"server_id": "1", "name": "kepserver", "url": "opc.tcp://host:49320",
|
||||||
|
"server_uri": "urn:...", "use_security": true, "subscription_period_ms": 7500.0,
|
||||||
|
"tags": {
|
||||||
|
"ns=2;s=Channel1.Device1.Tag1": {
|
||||||
|
"frequency": 15000,
|
||||||
|
"active_read": {"enabled": false},
|
||||||
|
"names": [
|
||||||
|
{"collection": "raw_model_a", "tag_name": "Counter", "model_id": "1", "model_name": "model a"},
|
||||||
|
{"collection": "raw_model_b", "tag_name": "Contador", "model_id": "2", "model_name": "model b"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
How each scouter `read_tags` entry is resolved:
|
||||||
|
|
||||||
|
| Read tag | Address and server | Name |
|
||||||
|
|---|---|---|
|
||||||
|
| With `tag_id` | `connector_tags` (`address`, `connector_id`) and `connectors` (`url`, `server_uri`, `use_security`, `subscription_period_ms`) | The read tag's `tag_name`, else the `connector_tags` one |
|
||||||
|
| Without `tag_id` | `server_id` + `tag_address`, server from `opc-servers` | The read tag's `tag_name` |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- **One entry per address.** An address read by N pipelines is read once and written to N collections: one `names` item per pipeline (`collection` = `raw_{schedule_name}`).
|
||||||
|
- **Same name source as the scouter.** `resolve_tag_names` names both the slot's `names` and the scouter's `model_tags`, so the gateway always writes the name the scouter looks for.
|
||||||
|
- **`frequency`:** the lowest among the pipelines of the address, as an integer. Every read tag needs one, with or without `tag_id`.
|
||||||
|
- **`tag_id`:** a missing, `null` or empty `tag_id` means the read tag does not use the tag database.
|
||||||
|
- **`subscription_period_ms`:** the connector's value. For `opc-servers`, it is half the server's lowest tag frequency in the slot, as before.
|
||||||
|
- **`use_security`:** the connector's value. For `opc-servers`, it is `true` when the server has a `cert_path`. The certificate itself comes from MinIO on the gateway, so the slot carries no certificate path.
|
||||||
|
- **`active_read`:** off by default. It is on when any pipeline sets `read_tags[].active_read.enabled`, with the lowest `cycle_multiple`.
|
||||||
|
- **Same `server_id` in both sources:** a server known through `connectors` and `opc-servers` under the same id is one server, and the `connectors` fields win. Two different servers with the same name get distinct slot keys (`name`, then `name:server_id`).
|
||||||
|
- **Slot division:** the addresses are split across the slots, sorted by server and address, and the names of an address always stay in the same slot.
|
||||||
|
- **Unresolved tags:** a read tag that cannot be resolved is left out of the slot, and the rest of the slot is still written. It is notified once per run:
|
||||||
|
- `ORCHESTRATOR_TAG_NOT_FOUND_DURING_SLOT_CONFIGURATION`: the `tag_id` is not an active Read tag, or its document lacks `connector_id`, `address` or `tag_name`;
|
||||||
|
- `ORCHESTRATOR_CONNECTOR_NOT_FOUND_DURING_SLOT_CONFIGURATION`: the connector is missing or inactive, or lacks `name`, `url` or `server_uri`;
|
||||||
|
- `ORCHESTRATOR_SERVER_NOT_FOUND_DURING_SLOT_CONFIGURATION`: the `server_id` is not in `opc-servers`;
|
||||||
|
- `ORCHESTRATOR_INVALID_READ_TAG_DURING_SLOT_CONFIGURATION`: the read tag has no integer `frequency` or, without `tag_id`, lacks `server_id`, `tag_address` or `tag_name`.
|
||||||
|
- **Only changed slots are written.** The slot is built in a stable order, so unchanged pipelines produce an identical slot, and the gateway does not re-subscribe. A slot still in the v1 format (`topics`, `cert_path`) is replaced once.
|
||||||
|
|
||||||
|
**Gateway plan B:** the gateway's legacy read engine (`READ_ENGINE=legacy`, kept until the end of SIENTIAPDE-1974) reads the slot v1 only. Once this version writes the slot v2, switching the gateway to the legacy engine also requires rolling the Orchestrator back to a version that writes v1.
|
||||||
|
|
||||||
|
**Known limitation:** scouter schedules are only redeployed when the pipeline's `updated_at` changes. If a tag is renamed in `connector_tags`, the slot picks up the new name on the next run, but the scouter keeps the old `model_tags` until the pipeline is updated. When renaming, also update the pipeline, or keep the name in `read_tags[].tag_name`.
|
||||||
|
|
||||||
#### Architecture
|
#### Architecture
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -608,10 +683,27 @@ Temporal input configuration sample:
|
|||||||
"filters": {
|
"filters": {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"connectors_query": {
|
||||||
|
"collection": "connectors",
|
||||||
|
"aggregation": [
|
||||||
|
{"$match": {"type": "opc_ua", "status": "Active"}},
|
||||||
|
{"$project": {"_id": 0, "connector_id": 1, "name": 1, "url": 1, "server_uri": 1,
|
||||||
|
"use_security": 1, "subscription_period_ms": 1}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"connector_tags_query": {
|
||||||
|
"collection": "connector_tags",
|
||||||
|
"aggregation": [
|
||||||
|
{"$match": {"category": "Read", "status": "Active"}},
|
||||||
|
{"$project": {"_id": 0, "tag_id": 1, "connector_id": 1, "address": 1, "tag_name": 1}}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`connectors_query` and `connector_tags_query` are optional: the values above are the defaults in the code (see "OPC slots" above).
|
||||||
|
|
||||||
#### Alerts Workflow
|
#### Alerts Workflow
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ def default_receiver_group(
|
|||||||
levels: list[str] | None = None,
|
levels: list[str] | None = None,
|
||||||
contents: list[str] | None = None,
|
contents: list[str] | None = None,
|
||||||
ignore_models_id: list[str] | None = None,
|
ignore_models_id: list[str] | None = None,
|
||||||
|
model_ids: list[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Minimal active receiver_groups document."""
|
"""Minimal active receiver_groups document."""
|
||||||
return {
|
return {
|
||||||
@@ -356,6 +357,7 @@ def default_receiver_group(
|
|||||||
'levels': levels or ['ERROR', 'WARNING', 'INFO'],
|
'levels': levels or ['ERROR', 'WARNING', 'INFO'],
|
||||||
'contents': contents or ['core_alerts', 'persistent_alerts', 'reports'],
|
'contents': contents or ['core_alerts', 'persistent_alerts', 'reports'],
|
||||||
'ignore_models_id': ignore_models_id or [],
|
'ignore_models_id': ignore_models_id or [],
|
||||||
|
'model_ids': model_ids or [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ the query-time union/intersection across active groups (SIENTIAPDE-2011, see
|
|||||||
`levels`/`ignore_models_id`, even when the query-time filter widened the fetch
|
`levels`/`ignore_models_id`, even when the query-time filter widened the fetch
|
||||||
to satisfy another active group's broader configuration.
|
to satisfy another active group's broader configuration.
|
||||||
|
|
||||||
|
### A.2.2 Model_id allow-list
|
||||||
|
|
||||||
|
Receiver group `model_ids` (SIENTIAPDE-2149) honored: a group configuring it only
|
||||||
|
receives notifications from those models, even when another active group with
|
||||||
|
no such restriction widens the query-time fetch to include every model.
|
||||||
|
|
||||||
### A.3.1 Empty queue
|
### A.3.1 Empty queue
|
||||||
|
|
||||||
No notifications → no SMTP, no Postgres row.
|
No notifications → no SMTP, no Postgres row.
|
||||||
@@ -99,6 +105,7 @@ No notifications → no SMTP, no Postgres row.
|
|||||||
- No prior Redis timestamp → all matching notifications returned, max timestamp stored.
|
- No prior Redis timestamp → all matching notifications returned, max timestamp stored.
|
||||||
- Prior timestamp → only newer notifications returned.
|
- Prior timestamp → only newer notifications returned.
|
||||||
- Query-time filter (SIENTIAPDE-2011) → union of active groups' `levels`, intersection of their `ignore_models_id`; a model ignored by only one of several active groups is not excluded from the fetch.
|
- Query-time filter (SIENTIAPDE-2011) → union of active groups' `levels`, intersection of their `ignore_models_id`; a model ignored by only one of several active groups is not excluded from the fetch.
|
||||||
|
- Model_id allow-list union at query time (SIENTIAPDE-2149) → when every active group sets `model_ids`, the fetch is restricted to the union of every group's allow-list; a model outside that union is never fetched at all, even for a group that would otherwise want it via a different axis.
|
||||||
- Empty Mongo → no Redis timestamp write.
|
- Empty Mongo → no Redis timestamp write.
|
||||||
- Batch size cap (SIENTIAPDE-2013) → with 5 pending notifications and a batch size of 2, each run returns exactly the next-oldest 2 (sorted ascending by timestamp), and the Redis cursor advances only to what was actually returned, so three successive runs drain the backlog in order instead of skipping anything.
|
- Batch size cap (SIENTIAPDE-2013) → with 5 pending notifications and a batch size of 2, each run returns exactly the next-oldest 2 (sorted ascending by timestamp), and the Redis cursor advances only to what was actually returned, so three successive runs drain the backlog in order instead of skipping anything.
|
||||||
- Batch size 0 means unlimited (SIENTIAPDE-2013) → the configured default (`0`) does not cap the query; 12 pending notifications all come back in one run, verified against a real MongoDB instance rather than assumed from pymongo's documented convention.
|
- Batch size 0 means unlimited (SIENTIAPDE-2013) → the configured default (`0`) does not cap the query; 12 pending notifications all come back in one run, verified against a real MongoDB instance rather than assumed from pymongo's documented convention.
|
||||||
|
|||||||
@@ -182,6 +182,73 @@ async def test_scenario_a_2_1_per_group_levels_and_ignore_models_id(
|
|||||||
assert 'model-x' not in combined
|
assert 'model-x' not in combined
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scenario_a_2_2_model_ids_allow_list(
|
||||||
|
temporal_env: WorkflowEnvironment,
|
||||||
|
orchestrator_worker,
|
||||||
|
stub_workers,
|
||||||
|
mongo_uri: str,
|
||||||
|
smtp_server,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
A.2.2 (SIENTIAPDE-2149): a group configuring `model_ids` only receives
|
||||||
|
notifications from those models, on top of the existing query-time/per-group
|
||||||
|
filtering (SIENTIAPDE-2011/2012).
|
||||||
|
|
||||||
|
group-allow (model_ids=['model-a']) coexists with group-everything (no
|
||||||
|
model_ids restriction). Because the query-time filter only restricts to an
|
||||||
|
allow-list union when EVERY active group has one, group-everything's lack
|
||||||
|
of a model_ids restriction means the query itself fetches both models --
|
||||||
|
the per-group filter added by this ticket is what narrows group-allow back
|
||||||
|
down to model-a only.
|
||||||
|
"""
|
||||||
|
seed_receiver_groups(
|
||||||
|
mongo_uri,
|
||||||
|
E2E_DATABASE,
|
||||||
|
[
|
||||||
|
default_receiver_group(
|
||||||
|
group_name='group-allow', contents=['core_alerts'], model_ids=['model-a']
|
||||||
|
),
|
||||||
|
default_receiver_group(group_name='group-everything', contents=['core_alerts']),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
seed_notifications(
|
||||||
|
mongo_uri,
|
||||||
|
E2E_DATABASE,
|
||||||
|
[
|
||||||
|
default_notification(
|
||||||
|
notification_id='err-a', level='ERROR', model_name='model-a', model_id='model-a'
|
||||||
|
),
|
||||||
|
default_notification(
|
||||||
|
notification_id='err-b',
|
||||||
|
level='ERROR',
|
||||||
|
model_name='model-b',
|
||||||
|
model_id='model-b',
|
||||||
|
timestamp=datetime(2024, 6, 1, 12, 1, 0, tzinfo=UTC),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
await start_and_await_workflow(
|
||||||
|
temporal_env.client,
|
||||||
|
Alerts.run,
|
||||||
|
load_scenario_input('alerts_happy_path.json'),
|
||||||
|
make_workflow_id('alerts-2149'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# One email per group -- group-allow got only err-a, group-everything got both.
|
||||||
|
assert len(smtp_server.messages) == 2
|
||||||
|
bodies = [extract_html_body(message) for message in smtp_server.messages]
|
||||||
|
|
||||||
|
# Distinguish the two emails by content rather than order: only
|
||||||
|
# group-everything's email mentions model-b.
|
||||||
|
allow_body = next(b for b in bodies if 'model-b' not in b)
|
||||||
|
everything_body = next(b for b in bodies if 'model-b' in b)
|
||||||
|
assert 'model-a' in allow_body
|
||||||
|
assert 'model-a' in everything_body
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
@pytest.mark.e2e
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scenario_a_3_1_empty_queue(
|
async def test_scenario_a_3_1_empty_queue(
|
||||||
|
|||||||
@@ -205,6 +205,57 @@ async def test_load_package_query_time_filter_union_and_intersection(
|
|||||||
assert ids == {'error-included', 'warning-included', 'modely-included'}
|
assert ids == {'error-included', 'warning-included', 'modely-included'}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_package_query_time_filter_model_ids_union(
|
||||||
|
temporal_env: WorkflowEnvironment,
|
||||||
|
orchestrator_worker,
|
||||||
|
stub_workers,
|
||||||
|
mongo_uri: str,
|
||||||
|
redis_client: Redis,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
SIENTIAPDE-2149: query-time filter unions active groups' `model_ids`
|
||||||
|
allow-list when every active group sets one, excluding any model outside
|
||||||
|
that union from the fetch entirely -- not just from a single group's
|
||||||
|
per-group slice.
|
||||||
|
|
||||||
|
group_a wants only model-a, group_b wants only model-b -- both restrict
|
||||||
|
levels to ERROR so only the model_ids axis is under test. model-c is
|
||||||
|
outside the union of both allow-lists and must never be fetched.
|
||||||
|
"""
|
||||||
|
seed_receiver_groups(
|
||||||
|
mongo_uri,
|
||||||
|
E2E_DATABASE,
|
||||||
|
[
|
||||||
|
default_receiver_group(group_name='group_a', levels=['ERROR'], model_ids=['model-a']),
|
||||||
|
default_receiver_group(group_name='group_b', levels=['ERROR'], model_ids=['model-b']),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
seed_notifications(
|
||||||
|
mongo_uri,
|
||||||
|
E2E_DATABASE,
|
||||||
|
[
|
||||||
|
default_notification(notification_id='a-included', level='ERROR', model_id='model-a'),
|
||||||
|
default_notification(notification_id='b-included', level='ERROR', model_id='model-b'),
|
||||||
|
default_notification(notification_id='c-excluded', level='ERROR', model_id='model-c'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
input_data = load_scenario_input('subworkflow_load_notification_package.json')
|
||||||
|
_build_metadata(input_data)
|
||||||
|
|
||||||
|
result = await start_and_await_workflow(
|
||||||
|
temporal_env.client,
|
||||||
|
LoadNotificationPackage.run,
|
||||||
|
input_data,
|
||||||
|
make_workflow_id('load-pkg-model-ids-union'),
|
||||||
|
)
|
||||||
|
|
||||||
|
ids = {n['notification_id'] for n in result['notification_package']}
|
||||||
|
assert ids == {'a-included', 'b-included'}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
@pytest.mark.e2e
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_load_package_empty_mongo_no_redis_write(
|
async def test_load_package_empty_mongo_no_redis_write(
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ with workflow.unsafe.imports_passed_through():
|
|||||||
build_tag_config,
|
build_tag_config,
|
||||||
drift,
|
drift,
|
||||||
gather_read_tags,
|
gather_read_tags,
|
||||||
|
index_connector_tags,
|
||||||
|
index_connectors,
|
||||||
minimal_retrain,
|
minimal_retrain,
|
||||||
pi_web_api_scouter,
|
pi_web_api_scouter,
|
||||||
predictions_batch,
|
predictions_batch,
|
||||||
|
resolve_tag_names,
|
||||||
scouter,
|
scouter,
|
||||||
simple_metrics,
|
simple_metrics,
|
||||||
)
|
)
|
||||||
@@ -173,6 +176,8 @@ class Formatters(SientiaMonitoring):
|
|||||||
- input_data (dict[str, Any]): The input data containing
|
- input_data (dict[str, Any]): The input data containing
|
||||||
the schedules to process.
|
the schedules to process.
|
||||||
- pipelines (list[dict[str, Any]]): The schedules to process.
|
- pipelines (list[dict[str, Any]]): The schedules to process.
|
||||||
|
- connector_tags (list[dict[str, Any]], optional): Active Read tags of
|
||||||
|
the tag database, to name the scouter read tags that carry only a tag_id.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- dict[str, Any]: The schedule configuration dictionary keyed by namespace
|
- dict[str, Any]: The schedule configuration dictionary keyed by namespace
|
||||||
@@ -183,6 +188,7 @@ class Formatters(SientiaMonitoring):
|
|||||||
self.info('Processing schedules...', metadata=metadata)
|
self.info('Processing schedules...', metadata=metadata)
|
||||||
|
|
||||||
pipelines = input_data['pipelines']
|
pipelines = input_data['pipelines']
|
||||||
|
tags_by_id = index_connector_tags(input_data.get('connector_tags', []))
|
||||||
|
|
||||||
schedule_config: dict[str, dict[str, Any]] = {
|
schedule_config: dict[str, dict[str, Any]] = {
|
||||||
self.scouter_namespace: {},
|
self.scouter_namespace: {},
|
||||||
@@ -196,6 +202,13 @@ class Formatters(SientiaMonitoring):
|
|||||||
self.error(f'Workflow type {workflow_type} not supported', metadata=metadata)
|
self.error(f'Workflow type {workflow_type} not supported', metadata=metadata)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if workflow_type == 'scouter' and 'read_tags' in pipeline:
|
||||||
|
# Same names as the slot's names[] (process_slots): SIENTIAPDE-1974 SC1
|
||||||
|
pipeline = {
|
||||||
|
**pipeline,
|
||||||
|
'read_tags': resolve_tag_names(pipeline['read_tags'], tags_by_id),
|
||||||
|
}
|
||||||
|
|
||||||
schedule_type = schedule_types[workflow_type]
|
schedule_type = schedule_types[workflow_type]
|
||||||
namespace = schedule_type['namespace']
|
namespace = schedule_type['namespace']
|
||||||
function = schedule_type['function']
|
function = schedule_type['function']
|
||||||
@@ -216,14 +229,25 @@ class Formatters(SientiaMonitoring):
|
|||||||
def process_slots(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
def process_slots(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Extracts all read tags from input pipelines, divides them into slots and
|
Extracts all read tags from input pipelines, divides them into slots and
|
||||||
returns a slot config dictionary. If no ingestor is available, only one slot
|
returns a slot config dictionary in the slot v2 format (contract C1). If no
|
||||||
is created.
|
ingestor is available, only one slot is created.
|
||||||
|
|
||||||
|
The division is by address: all the names of an address stay in the same slot
|
||||||
|
(SIENTIAPDE-1974, O5). Read tags that cannot be resolved are left out and
|
||||||
|
notified once per run: a tag_id missing from connector_tags, a connector missing
|
||||||
|
or inactive, a server_id missing from opc_servers, or a read tag without a usable
|
||||||
|
frequency (or, without tag_id, without server_id, tag_address or tag_name).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
- input_data (dict[str, Any]): The input data containing
|
- input_data (dict[str, Any]): The input data containing
|
||||||
the schedules to process.
|
the schedules to process.
|
||||||
- pipelines (list[dict[str, Any]]): The schedules to process.
|
- pipelines (list[dict[str, Any]]): The schedules to process.
|
||||||
- opc_servers (list[str]): The OPC servers to create ingestor config.
|
- opc_servers (list[dict[str, Any]]): The OPC servers (opc-servers), for
|
||||||
|
read tags without tag_id.
|
||||||
|
- connectors (list[dict[str, Any]], optional): Active OPC UA connectors of
|
||||||
|
the tag database, for read tags with tag_id.
|
||||||
|
- connector_tags (list[dict[str, Any]], optional): Active Read tags of the
|
||||||
|
tag database, for read tags with tag_id.
|
||||||
- active_ingestors (list[str]): The active ingestors to divide into slots.
|
- active_ingestors (list[str]): The active ingestors to divide into slots.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -235,55 +259,69 @@ class Formatters(SientiaMonitoring):
|
|||||||
self.info('Processing slots...', metadata=metadata)
|
self.info('Processing slots...', metadata=metadata)
|
||||||
|
|
||||||
pipelines = input_data['pipelines']
|
pipelines = input_data['pipelines']
|
||||||
opc_servers_list = input_data['opc_servers']
|
|
||||||
active_ingestors = input_data['active_ingestors']
|
active_ingestors = input_data['active_ingestors']
|
||||||
|
opc_servers = {str(server['id']): server for server in input_data['opc_servers']}
|
||||||
|
connectors = index_connectors(input_data.get('connectors', []))
|
||||||
|
tags_by_id = index_connector_tags(input_data.get('connector_tags', []))
|
||||||
|
|
||||||
opc_servers = {}
|
entries, issues = gather_read_tags(pipelines, opc_servers, connectors, tags_by_id)
|
||||||
for server in opc_servers_list:
|
self._notify_slot_issues(issues, metadata)
|
||||||
opc_servers[server['id']] = {
|
|
||||||
**server,
|
|
||||||
}
|
|
||||||
|
|
||||||
tags = list(gather_read_tags(pipelines).values())
|
|
||||||
|
|
||||||
number_of_tags = len(tags)
|
|
||||||
number_of_slots = len(active_ingestors) if active_ingestors else 1
|
number_of_slots = len(active_ingestors) if active_ingestors else 1
|
||||||
tags_per_slot = ceil(number_of_tags / number_of_slots)
|
entries_per_slot = ceil(len(entries) / number_of_slots)
|
||||||
|
|
||||||
slot_config: dict[str, Any] = {}
|
slot_config: dict[str, Any] = {}
|
||||||
last_index = 0
|
for i in range(number_of_slots):
|
||||||
|
slot_entries = entries[i * entries_per_slot : (i + 1) * entries_per_slot]
|
||||||
for i in range(1, number_of_slots):
|
slot_config[f'{i + 1}'] = build_tag_config(slot_entries)
|
||||||
slot_tags = tags[last_index : last_index + tags_per_slot]
|
|
||||||
slot_config[f'{i}'], notifications = build_tag_config(slot_tags, opc_servers)
|
|
||||||
|
|
||||||
if notifications:
|
|
||||||
self.send_notification(
|
|
||||||
metadata=metadata,
|
|
||||||
notification_id='ORCHESTRATOR_SERVER_NOT_FOUND_DURING_SLOT_CONFIGURATION',
|
|
||||||
message=f'Servers {", ".join(notifications)} not found in opc_servers',
|
|
||||||
block='orchestrator',
|
|
||||||
level=NotificationLevel.ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
last_index += tags_per_slot
|
|
||||||
|
|
||||||
slot_tags = tags[last_index:]
|
|
||||||
slot_config[f'{number_of_slots}'], notifications = build_tag_config(slot_tags, opc_servers)
|
|
||||||
if notifications:
|
|
||||||
self.send_notification(
|
|
||||||
metadata=metadata,
|
|
||||||
notification_id='ORCHESTRATOR_SERVER_NOT_FOUND_DURING_SLOT_CONFIGURATION',
|
|
||||||
message=f'Servers {", ".join(notifications)} not found in opc_servers',
|
|
||||||
block='orchestrator',
|
|
||||||
level=NotificationLevel.ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.info('Processed slots', metadata=metadata)
|
self.info('Processed slots', metadata=metadata)
|
||||||
self.debug(json.dumps(slot_config, indent=4, sort_keys=True), metadata=metadata)
|
self.debug(json.dumps(slot_config, indent=4, sort_keys=True), metadata=metadata)
|
||||||
|
|
||||||
return slot_config
|
return slot_config
|
||||||
|
|
||||||
|
def _notify_slot_issues(self, issues: dict[str, set[str]], metadata: dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
Notify the read tags left out of the slots, one notification per kind of issue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
issues (dict[str, set[str]]): What gather_read_tags left out, by kind:
|
||||||
|
'servers' (opc-servers server_id), 'tags' (tag_id), 'connectors'
|
||||||
|
(connector_id) and 'invalid' (schedule_name/read tag)
|
||||||
|
metadata (dict[str, Any]): Metadata for the notifications
|
||||||
|
"""
|
||||||
|
kinds = (
|
||||||
|
(
|
||||||
|
'servers',
|
||||||
|
'ORCHESTRATOR_SERVER_NOT_FOUND_DURING_SLOT_CONFIGURATION',
|
||||||
|
'Servers {} not found in opc_servers',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'tags',
|
||||||
|
'ORCHESTRATOR_TAG_NOT_FOUND_DURING_SLOT_CONFIGURATION',
|
||||||
|
'Tags {} not found among the active, complete Read tags of connector_tags',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'connectors',
|
||||||
|
'ORCHESTRATOR_CONNECTOR_NOT_FOUND_DURING_SLOT_CONFIGURATION',
|
||||||
|
'Connectors {} not found, not active or incomplete in connectors',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'invalid',
|
||||||
|
'ORCHESTRATOR_INVALID_READ_TAG_DURING_SLOT_CONFIGURATION',
|
||||||
|
'Read tags {} have no integer frequency or lack server_id, tag_address or tag_name',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for kind, notification_id, message in kinds:
|
||||||
|
if issues.get(kind):
|
||||||
|
self.send_notification(
|
||||||
|
metadata=metadata,
|
||||||
|
notification_id=notification_id,
|
||||||
|
message=message.format(', '.join(sorted(issues[kind]))),
|
||||||
|
block='orchestrator',
|
||||||
|
level=NotificationLevel.ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
@activity.defn(name='format_schedule_config')
|
@activity.defn(name='format_schedule_config')
|
||||||
def format_schedule_config(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
def format_schedule_config(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -430,15 +468,21 @@ class Formatters(SientiaMonitoring):
|
|||||||
to determine which slots need to be inserted or deleted. Slots are identified
|
to determine which slots need to be inserted or deleted. Slots are identified
|
||||||
by numeric IDs, and excess slots are marked for deletion.
|
by numeric IDs, and excess slots are marked for deletion.
|
||||||
|
|
||||||
|
Only slots whose content changed are inserted, so an unchanged slot is not
|
||||||
|
rewritten (nor reported) on every run. gather_read_tags builds the slot in a
|
||||||
|
stable order, so the same pipelines always give the same slot (SIENTIAPDE-1974, O6).
|
||||||
|
A slot in the v1 format differs from its v2 rewrite and is replaced once.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_data (dict[str, Any]): The input data containing:
|
input_data (dict[str, Any]): The input data containing:
|
||||||
- current_slot_config (dict[str, Any]): The current slot config in Redis
|
- current_slot_config (dict[str, Any]): The current slot config in Redis,
|
||||||
|
keyed by Redis key (slot:opc_tags:{id}) or by slot id
|
||||||
- slot_config (dict[str, Any]): The slot config to process
|
- slot_config (dict[str, Any]): The slot config to process
|
||||||
- metadata (dict): Metadata for logging purposes
|
- metadata (dict): Metadata for logging purposes
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: A dictionary containing:
|
dict[str, Any]: A dictionary containing:
|
||||||
- to_insert (dict): Slots that need to be inserted/updated
|
- to_insert (dict): Slots that are new or changed
|
||||||
- to_delete (list[str]): Slot IDs that need to be deleted
|
- to_delete (list[str]): Slot IDs that need to be deleted
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -456,7 +500,16 @@ class Formatters(SientiaMonitoring):
|
|||||||
if number_of_current_slots > number_of_slots:
|
if number_of_current_slots > number_of_slots:
|
||||||
to_delete = [str(i) for i in range(number_of_slots + 1, number_of_current_slots + 1)]
|
to_delete = [str(i) for i in range(number_of_slots + 1, number_of_current_slots + 1)]
|
||||||
|
|
||||||
output = {'to_delete': to_delete, 'to_insert': slot_config}
|
current_by_id = {
|
||||||
|
str(key).rsplit(':', 1)[-1]: config for key, config in current_slot_config.items()
|
||||||
|
}
|
||||||
|
to_insert = {
|
||||||
|
slot: config
|
||||||
|
for slot, config in slot_config.items()
|
||||||
|
if slot not in current_by_id or current_by_id[slot] != config
|
||||||
|
}
|
||||||
|
|
||||||
|
output = {'to_delete': to_delete, 'to_insert': to_insert}
|
||||||
|
|
||||||
self.info('Created slot config', metadata=metadata)
|
self.info('Created slot config', metadata=metadata)
|
||||||
self.debug(json.dumps(output, indent=4, sort_keys=True), metadata=metadata)
|
self.debug(json.dumps(output, indent=4, sort_keys=True), metadata=metadata)
|
||||||
@@ -826,6 +879,7 @@ class Formatters(SientiaMonitoring):
|
|||||||
- ignore (list, optional): Notification IDs to exclude
|
- ignore (list, optional): Notification IDs to exclude
|
||||||
- levels (list, optional): Notification levels to include
|
- levels (list, optional): Notification levels to include
|
||||||
- ignore_models_id (list, optional): Model IDs to exclude
|
- ignore_models_id (list, optional): Model IDs to exclude
|
||||||
|
- model_ids (list, optional): Model IDs to allow (SIENTIAPDE-2149)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: Filtered receiver groups with their notifications, keyed by group_name.
|
dict[str, Any]: Filtered receiver groups with their notifications, keyed by group_name.
|
||||||
@@ -851,6 +905,7 @@ class Formatters(SientiaMonitoring):
|
|||||||
ignore_list = receiver_group.get('ignore', [])
|
ignore_list = receiver_group.get('ignore', [])
|
||||||
levels_list = receiver_group.get('levels')
|
levels_list = receiver_group.get('levels')
|
||||||
ignore_models_id_list = receiver_group.get('ignore_models_id') or []
|
ignore_models_id_list = receiver_group.get('ignore_models_id') or []
|
||||||
|
model_ids_list = receiver_group.get('model_ids')
|
||||||
|
|
||||||
for notification in notification_package:
|
for notification in notification_package:
|
||||||
alert_type = 'reports'
|
alert_type = 'reports'
|
||||||
@@ -868,6 +923,7 @@ class Formatters(SientiaMonitoring):
|
|||||||
not ignore_models_id_list
|
not ignore_models_id_list
|
||||||
or notification.get('model_id') not in ignore_models_id_list
|
or notification.get('model_id') not in ignore_models_id_list
|
||||||
)
|
)
|
||||||
|
and (not model_ids_list or notification.get('model_id') in model_ids_list)
|
||||||
):
|
):
|
||||||
receiver_groups[group_name]['notifications'].append(notification)
|
receiver_groups[group_name]['notifications'].append(notification)
|
||||||
already_added_keys.append(key)
|
already_added_keys.append(key)
|
||||||
|
|||||||
@@ -398,6 +398,7 @@ class SlotManager(SientiaMonitoring):
|
|||||||
- ignore (list, optional): Notification IDs to exclude
|
- ignore (list, optional): Notification IDs to exclude
|
||||||
- levels (list, optional): Notification levels to include
|
- levels (list, optional): Notification levels to include
|
||||||
- ignore_models_id (list, optional): Model IDs to exclude
|
- ignore_models_id (list, optional): Model IDs to exclude
|
||||||
|
- model_ids (list, optional): Model IDs to allow (SIENTIAPDE-2149)
|
||||||
- notification_ttl (int): Seconds before considering notification persistent
|
- notification_ttl (int): Seconds before considering notification persistent
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -450,6 +451,7 @@ class SlotManager(SientiaMonitoring):
|
|||||||
ignore_list = receiver_group.get('ignore', [])
|
ignore_list = receiver_group.get('ignore', [])
|
||||||
levels_list = receiver_group.get('levels')
|
levels_list = receiver_group.get('levels')
|
||||||
ignore_models_id_list = receiver_group.get('ignore_models_id') or []
|
ignore_models_id_list = receiver_group.get('ignore_models_id') or []
|
||||||
|
model_ids_list = receiver_group.get('model_ids')
|
||||||
|
|
||||||
notifications = []
|
notifications = []
|
||||||
for notification in notification_package:
|
for notification in notification_package:
|
||||||
@@ -462,8 +464,8 @@ class SlotManager(SientiaMonitoring):
|
|||||||
and self._check_keys_and_notif_id(
|
and self._check_keys_and_notif_id(
|
||||||
notification_id, ignore_list, key, already_added_keys
|
notification_id, ignore_list, key, already_added_keys
|
||||||
)
|
)
|
||||||
and self._check_levels_and_ignore_models_id(
|
and self._check_levels_and_model_filters(
|
||||||
notification, levels_list, ignore_models_id_list
|
notification, levels_list, ignore_models_id_list, model_ids_list
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
notifications.append(notification)
|
notifications.append(notification)
|
||||||
@@ -493,23 +495,29 @@ class SlotManager(SientiaMonitoring):
|
|||||||
return 'do_nothing'
|
return 'do_nothing'
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _check_levels_and_ignore_models_id(notification, levels_list, ignore_models_id_list):
|
def _check_levels_and_model_filters(
|
||||||
|
notification, levels_list, ignore_models_id_list, model_ids_list
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Check if the notification's level is in the levels_list and its model_id is not in the ignore_models_id_list.
|
Check the notification's level and model_id against a group's filters.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
notification (dict): The notification to check.
|
notification (dict): The notification to check.
|
||||||
levels_list (list, optional): List of levels to include. If None, all levels are included.
|
levels_list (list, optional): List of levels to include. If None, all levels are included.
|
||||||
ignore_models_id_list (list, optional): List of model_ids to exclude. If None, no models are excluded.
|
ignore_models_id_list (list, optional): List of model_ids to exclude. If None, no models are excluded.
|
||||||
|
model_ids_list (list, optional): List of model_ids to allow (SIENTIAPDE-2149).
|
||||||
|
If None/empty, all models are allowed. Independent of ignore_models_id_list --
|
||||||
|
both apply together when both are configured.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the notification passes the checks, False otherwise.
|
bool: True if the notification passes the checks, False otherwise.
|
||||||
"""
|
"""
|
||||||
level_check = not levels_list or notification.get('level') in levels_list
|
level_check = not levels_list or notification.get('level') in levels_list
|
||||||
model_id_check = (
|
ignore_models_id_check = (
|
||||||
not ignore_models_id_list or notification.get('model_id') not in ignore_models_id_list
|
not ignore_models_id_list or notification.get('model_id') not in ignore_models_id_list
|
||||||
)
|
)
|
||||||
return level_check and model_id_check
|
model_ids_check = not model_ids_list or notification.get('model_id') in model_ids_list
|
||||||
|
return level_check and ignore_models_id_check and model_ids_check
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _check_keys_and_notif_id(
|
def _check_keys_and_notif_id(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
from orchestrator.utils.converters import parse_frequency
|
from orchestrator.utils.converters import parse_frequency
|
||||||
|
|
||||||
@@ -174,8 +174,10 @@ def scouter(config: dict[str, Any]):
|
|||||||
Args:
|
Args:
|
||||||
config (dict[str, Any]): Pipeline configuration containing:
|
config (dict[str, Any]): Pipeline configuration containing:
|
||||||
- schedule_name (str): Name of the schedule (used for topic generation)
|
- schedule_name (str): Name of the schedule (used for topic generation)
|
||||||
- read_tags (list[dict]): List of tag configurations with:
|
- read_tags (list[dict]): List of tag configurations, already passed through
|
||||||
- tag_name (str): Name of the tag to read
|
resolve_tag_names (process_schedules does it), with:
|
||||||
|
- tag_name (str): Name of the tag to read. A tag without one (a tag_id the
|
||||||
|
tag database cannot resolve) is left out; process_slots notifies it
|
||||||
- aggr_func (str, optional): Aggregation function for data collection (default: 'lts')
|
- aggr_func (str, optional): Aggregation function for data collection (default: 'lts')
|
||||||
Common values: 'lts' (last), 'avg' (average), 'min', 'max', 'sum'
|
Common values: 'lts' (last), 'avg' (average), 'min', 'max', 'sum'
|
||||||
- data_range (list[int], optional): Valid data range [min, max] (default: [-100, 100])
|
- data_range (list[int], optional): Valid data range [min, max] (default: [-100, 100])
|
||||||
@@ -188,6 +190,8 @@ def scouter(config: dict[str, Any]):
|
|||||||
|
|
||||||
tags = {}
|
tags = {}
|
||||||
for tag in config['read_tags']:
|
for tag in config['read_tags']:
|
||||||
|
if not tag.get('tag_name'):
|
||||||
|
continue
|
||||||
tags[tag['tag_name']] = {
|
tags[tag['tag_name']] = {
|
||||||
'aggr_func': tag.get('aggr_func', 'lts'),
|
'aggr_func': tag.get('aggr_func', 'lts'),
|
||||||
'data_range': tag.get('data_range', [-100, 100]),
|
'data_range': tag.get('data_range', [-100, 100]),
|
||||||
@@ -396,110 +400,408 @@ def predictions_batch(config: dict[str, Any]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def gather_read_tags(pipelines: list[dict[str, Any]]) -> dict[str, Any]:
|
# Fields a tag database document needs to take part in a slot. Documents missing any
|
||||||
"""
|
# of them are left out of the index, so their read tags are notified as not found
|
||||||
Gather all read tags from scouter pipeline configurations.
|
# instead of failing the whole run.
|
||||||
|
CONNECTOR_TAG_FIELDS = ('tag_id', 'connector_id', 'address', 'tag_name')
|
||||||
|
CONNECTOR_FIELDS = ('connector_id', 'name', 'url', 'server_uri')
|
||||||
|
|
||||||
Collects all read tags from scouter-type pipelines and organizes them by
|
|
||||||
server_id and tag_address, tracking which Kafka topics each tag is associated with.
|
def _complete(document: dict[str, Any], fields: tuple[str, ...]) -> bool:
|
||||||
This function is used during slot configuration to aggregate tags across multiple
|
return all(document.get(field) not in (None, '') for field in fields)
|
||||||
scouter pipelines for efficient OPC server slot allocation.
|
|
||||||
|
|
||||||
|
def _tag_id(tag: dict[str, Any]) -> str | None:
|
||||||
|
"""The read tag's tag_id as text, or None when it has none (missing, null or empty)."""
|
||||||
|
tag_id = tag.get('tag_id')
|
||||||
|
return None if tag_id in (None, '') else str(tag_id)
|
||||||
|
|
||||||
|
|
||||||
|
def referenced_tag_ids(pipelines: list[dict[str, Any]]) -> list[Any]:
|
||||||
|
"""
|
||||||
|
The tag_ids the scouter pipelines reference, for the connector_tags query.
|
||||||
|
|
||||||
|
Each id goes in both forms, int and text, because the tag database stores int and
|
||||||
|
pipelines may hold either. Sorted, so the query is the same on every run.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pipelines (list[dict[str, Any]]): List of pipeline configurations to process.
|
pipelines (list[dict[str, Any]]): Pipeline configurations
|
||||||
Only pipelines with workflow_type 'scouter' are processed. Each scouter
|
|
||||||
pipeline should contain a 'read_tags' list with tag configurations.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: Dictionary of read tags keyed by "server_id:tag_address",
|
list[Any]: The referenced tag_ids (empty when no read tag carries a tag_id)
|
||||||
where each entry contains:
|
|
||||||
- All original tag configuration fields
|
|
||||||
- topics (list[str]): List of Kafka topic names associated with this tag
|
|
||||||
(format: 'raw_{schedule_name}')
|
|
||||||
"""
|
"""
|
||||||
|
ids: set[Any] = set()
|
||||||
|
for pipeline in pipelines:
|
||||||
|
if pipeline.get('workflow_type') != 'scouter':
|
||||||
|
continue
|
||||||
|
for tag in pipeline.get('read_tags') or []:
|
||||||
|
tag_id = _tag_id(tag)
|
||||||
|
if tag_id is None:
|
||||||
|
continue
|
||||||
|
ids.add(tag_id)
|
||||||
|
if tag_id.lstrip('-').isdigit():
|
||||||
|
ids.add(int(tag_id))
|
||||||
|
return sorted(ids, key=lambda value: (isinstance(value, str), str(value)))
|
||||||
|
|
||||||
tags = {}
|
|
||||||
|
|
||||||
# Get all read tags from pipelines
|
def filter_by_tag_ids(query: dict[str, Any], tag_ids: list[Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Restrict a connector_tags aggregation to the referenced tag_ids.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query (dict[str, Any]): The connector_tags query ({'collection', 'aggregation'})
|
||||||
|
tag_ids (list[Any]): The ids from referenced_tag_ids
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: A new query whose aggregation starts with a tag_id $match. The
|
||||||
|
aggregation list is new too: aggregate_documents_in_mongodb appends a stage
|
||||||
|
to the list it gets.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
**query,
|
||||||
|
'aggregation': [{'$match': {'tag_id': {'$in': tag_ids}}}, *query['aggregation']],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def index_connector_tags(connector_tags: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Index the tag database's connector_tags by tag_id.
|
||||||
|
|
||||||
|
The key is the tag_id as text, because pipelines may store it as int or str.
|
||||||
|
Documents without tag_id, connector_id, address or tag_name are left out.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connector_tags (list[dict[str, Any]]): connector_tags documents (SIENTIAPDE-1569),
|
||||||
|
each with tag_id, connector_id, address and tag_name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, dict[str, Any]]: connector_tags documents keyed by str(tag_id)
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
str(tag['tag_id']): tag for tag in connector_tags if _complete(tag, CONNECTOR_TAG_FIELDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def index_connectors(connectors: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Index the tag database's connectors by connector_id (as text).
|
||||||
|
|
||||||
|
Documents without connector_id, name, url or server_uri are left out.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connectors (list[dict[str, Any]]): connectors documents (SIENTIAPDE-1569)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, dict[str, Any]]: connectors documents keyed by str(connector_id)
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
str(connector['connector_id']): connector
|
||||||
|
for connector in connectors
|
||||||
|
if _complete(connector, CONNECTOR_FIELDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_tag_names(
|
||||||
|
read_tags: list[dict[str, Any]], tags_by_id: dict[str, dict[str, Any]]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Fill in the tag_name of each read tag of a scouter pipeline.
|
||||||
|
|
||||||
|
The name is the pipeline's own tag_name when it has one, else the connector_tags
|
||||||
|
name of its tag_id. The scouter's model_tags (scouter()) and the slot's names[]
|
||||||
|
(gather_read_tags()) both take the name from here, so the name the gateway writes
|
||||||
|
is always the one the scouter looks for (SIENTIAPDE-1974, SC1).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
read_tags (list[dict[str, Any]]): The pipeline's read_tags
|
||||||
|
tags_by_id (dict[str, dict[str, Any]]): connector_tags indexed by index_connector_tags
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict[str, Any]]: The read tags, each with tag_name set when it could be
|
||||||
|
resolved. A tag_id missing from tags_by_id leaves the tag without a name.
|
||||||
|
"""
|
||||||
|
resolved = []
|
||||||
|
for tag in read_tags:
|
||||||
|
name = tag.get('tag_name')
|
||||||
|
tag_id = _tag_id(tag)
|
||||||
|
if not name and tag_id is not None:
|
||||||
|
name = tags_by_id.get(tag_id, {}).get('tag_name')
|
||||||
|
resolved.append({**tag, 'tag_name': name} if name else tag)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _connector_server(connector: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Slot server fields of a tag database connector (connectors collection)."""
|
||||||
|
return {
|
||||||
|
'server_id': str(connector['connector_id']),
|
||||||
|
'name': connector['name'],
|
||||||
|
'url': connector['url'],
|
||||||
|
'server_uri': connector['server_uri'],
|
||||||
|
'use_security': bool(connector.get('use_security', False)),
|
||||||
|
'subscription_period_ms': connector.get('subscription_period_ms'),
|
||||||
|
'from_tag_database': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _opc_server(server_id: str, opc_server: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Slot server fields of a legacy opc-servers document.
|
||||||
|
|
||||||
|
The certificate now always comes from MinIO on the gateway side, so the file paths
|
||||||
|
are not carried: a cert_path only tells that the server uses security (RF4). The
|
||||||
|
subscription period is left to build_tag_config (half the lowest tag frequency).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'server_id': server_id,
|
||||||
|
'name': opc_server['server_name'],
|
||||||
|
'url': opc_server['url'],
|
||||||
|
'server_uri': opc_server['uri'],
|
||||||
|
'use_security': bool(opc_server.get('cert_path')),
|
||||||
|
'subscription_period_ms': None,
|
||||||
|
'from_tag_database': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _frequency(tag: dict[str, Any]) -> int | None:
|
||||||
|
"""The read tag's frequency as int, or None when it is missing or not an integer."""
|
||||||
|
try:
|
||||||
|
return int(tag['frequency'])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _locate_read_tag(
|
||||||
|
tag: dict[str, Any],
|
||||||
|
opc_servers: dict[str, dict[str, Any]],
|
||||||
|
connectors: dict[str, dict[str, Any]],
|
||||||
|
tags_by_id: dict[str, dict[str, Any]],
|
||||||
|
issues: dict[str, set[str]],
|
||||||
|
) -> tuple[dict[str, Any], str] | None:
|
||||||
|
"""
|
||||||
|
Server and address of a read tag, or None when they cannot be resolved.
|
||||||
|
|
||||||
|
A tag with tag_id takes its address and connector from connector_tags and its server
|
||||||
|
from connectors (RF3). A tag without one keeps the current fields: server_id +
|
||||||
|
tag_address, with the server from opc-servers (RF4). What cannot be resolved is
|
||||||
|
recorded in issues ('tags', 'connectors' or 'servers').
|
||||||
|
"""
|
||||||
|
tag_id = _tag_id(tag)
|
||||||
|
if tag_id is not None:
|
||||||
|
connector_tag = tags_by_id.get(tag_id)
|
||||||
|
if connector_tag is None:
|
||||||
|
issues['tags'].add(tag_id)
|
||||||
|
return None
|
||||||
|
connector_id = str(connector_tag['connector_id'])
|
||||||
|
connector = connectors.get(connector_id)
|
||||||
|
if connector is None:
|
||||||
|
issues['connectors'].add(connector_id)
|
||||||
|
return None
|
||||||
|
return _connector_server(connector), connector_tag['address']
|
||||||
|
|
||||||
|
server_id = str(tag['server_id'])
|
||||||
|
opc_server = opc_servers.get(server_id)
|
||||||
|
if opc_server is None:
|
||||||
|
issues['servers'].add(server_id)
|
||||||
|
return None
|
||||||
|
return _opc_server(server_id, opc_server), tag['tag_address']
|
||||||
|
|
||||||
|
|
||||||
|
def _invalid_read_tag(tag: dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
A read tag the slot cannot use: no integer frequency, or (without tag_id) no
|
||||||
|
server_id, tag_address or tag_name.
|
||||||
|
"""
|
||||||
|
if _frequency(tag) is None:
|
||||||
|
return True
|
||||||
|
if _tag_id(tag) is not None:
|
||||||
|
return False
|
||||||
|
return not _complete(tag, ('server_id', 'tag_address', 'tag_name'))
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_active_read(current: dict[str, Any], requested: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Active read of an address: on if any pipeline asks, with the lowest cycle_multiple (D3)."""
|
||||||
|
if not requested or not requested.get('enabled'):
|
||||||
|
return current
|
||||||
|
cycle_multiple = int(requested.get('cycle_multiple', 1))
|
||||||
|
if current.get('enabled'):
|
||||||
|
cycle_multiple = min(cycle_multiple, current['cycle_multiple'])
|
||||||
|
return {'enabled': True, 'cycle_multiple': cycle_multiple}
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_server_keys(servers: dict[str, dict[str, Any]]) -> None:
|
||||||
|
"""
|
||||||
|
Give each server a unique slot key: its name, or name:server_id (extended until
|
||||||
|
free) when another server already took it. Assigned in server_id order, so the keys
|
||||||
|
are the same on every run and in every slot.
|
||||||
|
"""
|
||||||
|
taken: set[str] = set()
|
||||||
|
for server_id in sorted(servers):
|
||||||
|
server = servers[server_id]
|
||||||
|
key = server['name']
|
||||||
|
while key in taken:
|
||||||
|
key = f'{key}:{server_id}'
|
||||||
|
server['key'] = key
|
||||||
|
taken.add(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_read_tag(
|
||||||
|
entries: dict[tuple[str, str], dict[str, Any]],
|
||||||
|
servers: dict[str, dict[str, Any]],
|
||||||
|
pipeline: dict[str, Any],
|
||||||
|
tag: dict[str, Any],
|
||||||
|
server: dict[str, Any],
|
||||||
|
address: str,
|
||||||
|
) -> None:
|
||||||
|
"""Merge one resolved read tag into its (server, address) entry and its server."""
|
||||||
|
server_id = server['server_id']
|
||||||
|
known = servers.get(server_id)
|
||||||
|
if known is None or (server['from_tag_database'] and not known['from_tag_database']):
|
||||||
|
servers[server_id] = server
|
||||||
|
|
||||||
|
# int, so the slot does not depend on how each pipeline stored the number
|
||||||
|
frequency = cast(int, _frequency(tag))
|
||||||
|
entry = entries.setdefault(
|
||||||
|
(server_id, address),
|
||||||
|
{
|
||||||
|
'address': address,
|
||||||
|
'frequency': frequency,
|
||||||
|
'active_read': {'enabled': False},
|
||||||
|
'names': [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry['frequency'] = min(entry['frequency'], frequency)
|
||||||
|
entry['active_read'] = _merge_active_read(entry['active_read'], tag.get('active_read'))
|
||||||
|
|
||||||
|
name = {
|
||||||
|
'collection': f'raw_{pipeline["schedule_name"]}',
|
||||||
|
'tag_name': tag['tag_name'],
|
||||||
|
'model_id': pipeline['model_id'],
|
||||||
|
'model_name': pipeline['model']['name'],
|
||||||
|
}
|
||||||
|
if name not in entry['names']:
|
||||||
|
entry['names'].append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def gather_read_tags(
|
||||||
|
pipelines: list[dict[str, Any]],
|
||||||
|
opc_servers: dict[str, dict[str, Any]],
|
||||||
|
connectors: dict[str, dict[str, Any]],
|
||||||
|
tags_by_id: dict[str, dict[str, Any]],
|
||||||
|
) -> tuple[list[dict[str, Any]], dict[str, set[str]]]:
|
||||||
|
"""
|
||||||
|
Group the read tags of all scouter pipelines by (server, address) for the slot v2.
|
||||||
|
|
||||||
|
An address read by N pipelines becomes ONE entry with N items in names[]: the
|
||||||
|
gateway reads it once and writes it to N collections (SIENTIAPDE-1974, RF2). Each
|
||||||
|
read tag is resolved through the tag database when it has a tag_id (RF3), or through
|
||||||
|
the current server_id + tag_address + opc-servers fields when it has not (RF4).
|
||||||
|
|
||||||
|
A server known through both connectors and opc-servers under the same server_id is
|
||||||
|
the same server (the gateway keys its connections by server_id); the connectors
|
||||||
|
fields win.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pipelines (list[dict[str, Any]]): Pipeline configurations. Only workflow_type
|
||||||
|
'scouter' is read; each needs schedule_name, model_id, model.name and
|
||||||
|
read_tags (with frequency, and optionally tag_id and active_read)
|
||||||
|
opc_servers (dict[str, dict[str, Any]]): opc-servers documents keyed by str(id)
|
||||||
|
connectors (dict[str, dict[str, Any]]): Active OPC UA connectors indexed by
|
||||||
|
index_connectors
|
||||||
|
tags_by_id (dict[str, dict[str, Any]]): Active Read connector_tags indexed by
|
||||||
|
index_connector_tags
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[list[dict[str, Any]], dict[str, set[str]]]:
|
||||||
|
- One entry per (server, address), sorted by server_id then address, with:
|
||||||
|
- server (dict): server_id, name, url, server_uri, use_security,
|
||||||
|
subscription_period_ms (None = derive from the frequencies) and key
|
||||||
|
(the server's unique slot key)
|
||||||
|
- address (str): The OPC node address
|
||||||
|
- frequency (int): The lowest frequency among the pipelines (RF6)
|
||||||
|
- active_read (dict): {'enabled': False} unless a pipeline enables it (D3)
|
||||||
|
- names (list[dict]): collection ('raw_{schedule_name}'), tag_name,
|
||||||
|
model_id and model_name, one per pipeline name, sorted
|
||||||
|
- What was left out: 'tags' (tag_id), 'connectors' (connector_id), 'servers'
|
||||||
|
(opc-servers server_id) and 'invalid' (schedule_name/read tag without a
|
||||||
|
usable frequency or legacy fields)
|
||||||
|
"""
|
||||||
|
issues: dict[str, set[str]] = {
|
||||||
|
'tags': set(),
|
||||||
|
'connectors': set(),
|
||||||
|
'servers': set(),
|
||||||
|
'invalid': set(),
|
||||||
|
}
|
||||||
|
servers: dict[str, dict[str, Any]] = {}
|
||||||
|
entries: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
|
|
||||||
for pipeline in pipelines:
|
for pipeline in pipelines:
|
||||||
if pipeline['workflow_type'] != 'scouter':
|
if pipeline['workflow_type'] != 'scouter':
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for tag in pipeline.get('read_tags', []):
|
for tag in resolve_tag_names(pipeline.get('read_tags') or [], tags_by_id):
|
||||||
tag_string = f'{tag["server_id"]}:{tag["tag_address"]}'
|
if _invalid_read_tag(tag):
|
||||||
if tag_string not in tags:
|
ref = _tag_id(tag) or tag.get('tag_address') or tag.get('tag_name')
|
||||||
tags[tag_string] = {**tag, 'topics': []}
|
issues['invalid'].add(f'{pipeline["schedule_name"]}/{ref}')
|
||||||
|
continue
|
||||||
|
|
||||||
tags[tag_string]['topics'].append(f'raw_{pipeline["schedule_name"]}')
|
located = _locate_read_tag(tag, opc_servers, connectors, tags_by_id, issues)
|
||||||
|
if located is None:
|
||||||
|
continue
|
||||||
|
_add_read_tag(entries, servers, pipeline, tag, *located)
|
||||||
|
|
||||||
return tags
|
_assign_server_keys(servers)
|
||||||
|
|
||||||
|
gathered = []
|
||||||
|
for (server_id, _address), entry in sorted(entries.items()):
|
||||||
|
# A stable order keeps the slot identical between runs, so the gateway does
|
||||||
|
# not re-subscribe an unchanged slot (O6)
|
||||||
|
entry['names'].sort(key=lambda n: (n['collection'], n['tag_name'], str(n['model_id'])))
|
||||||
|
gathered.append({'server': servers[server_id], **entry})
|
||||||
|
|
||||||
|
return gathered, issues
|
||||||
|
|
||||||
|
|
||||||
def build_tag_config(
|
def build_tag_config(entries: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
tags: list[dict[str, Any]], opc_servers: dict[str, Any]
|
|
||||||
) -> tuple[dict[str, Any], list]:
|
|
||||||
"""
|
"""
|
||||||
Build tag configuration for a specific slot and OPC server.
|
Build one slot in the slot v2 format (contract C1) from address entries.
|
||||||
|
|
||||||
Organizes tags by OPC server name and calculates the minimum subscription period
|
The slot no longer carries cert_path, private_key_path or server_cert_path: the
|
||||||
based on tag frequencies. Validates that all server IDs exist in the OPC
|
gateway takes the certificate from MinIO (SIENTIAPDE-1974, O4).
|
||||||
servers configuration. The subscription period is set to half of the minimum
|
|
||||||
tag frequency to ensure efficient data collection.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
tags (list[dict[str, Any]]): List of tag configurations containing:
|
entries (list[dict[str, Any]]): Address entries from gather_read_tags for this slot
|
||||||
- server_id (str): ID of the OPC server
|
|
||||||
- tag_address (str): Address/path of the OPC tag
|
|
||||||
- frequency (int): Tag read frequency in milliseconds
|
|
||||||
- Additional tag-specific configuration fields
|
|
||||||
opc_servers (dict[str, Any]): Dictionary of OPC server configurations keyed by server_id.
|
|
||||||
Each server configuration should contain:
|
|
||||||
- server_name (str): Human-readable server name
|
|
||||||
- url (str): OPC server URL
|
|
||||||
- uri (str): OPC server URI
|
|
||||||
- cert_path (str, optional): Certificate file path
|
|
||||||
- private_key_path (str, optional): Private key file path
|
|
||||||
- server_cert_path (str, optional): Server certificate file path
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple[dict[str, Any], list]: A tuple containing:
|
dict[str, Any]: The slot keyed by server key, each server with server_id, name,
|
||||||
- Slot configuration dictionary organized by server_name, where each server
|
url, server_uri, use_security, subscription_period_ms and tags. Each tag (keyed
|
||||||
contains connection details, tags dictionary, and subscription_period_ms
|
by address) has frequency, active_read and names. A server without a
|
||||||
- List of server IDs (str) that were not found in opc_servers configuration
|
subscription period of its own (opc-servers) gets half its lowest tag
|
||||||
|
frequency in this slot, as before.
|
||||||
"""
|
"""
|
||||||
|
slot_config: dict[str, Any] = {}
|
||||||
|
|
||||||
slot_config = {}
|
for entry in entries:
|
||||||
notifications = []
|
server = entry['server']
|
||||||
|
if server['key'] not in slot_config:
|
||||||
for tag in tags:
|
slot_config[server['key']] = {
|
||||||
server_id = tag['server_id']
|
'server_id': server['server_id'],
|
||||||
|
'name': server['name'],
|
||||||
if server_id not in opc_servers:
|
'url': server['url'],
|
||||||
notifications.append(server_id)
|
'server_uri': server['server_uri'],
|
||||||
continue
|
'use_security': server['use_security'],
|
||||||
|
'subscription_period_ms': server['subscription_period_ms'],
|
||||||
server_name = opc_servers[server_id]['server_name']
|
|
||||||
if server_name not in slot_config:
|
|
||||||
slot_config[server_name] = {
|
|
||||||
'server_id': server_id,
|
|
||||||
'name': server_name,
|
|
||||||
'url': opc_servers[server_id]['url'],
|
|
||||||
'server_uri': opc_servers[server_id]['uri'],
|
|
||||||
'cert_path': opc_servers[server_id].get('cert_path', None),
|
|
||||||
'private_key_path': opc_servers[server_id].get('private_key_path', None),
|
|
||||||
'server_cert_path': opc_servers[server_id].get('server_cert_path', None),
|
|
||||||
'tags': {},
|
'tags': {},
|
||||||
}
|
}
|
||||||
|
|
||||||
slot_config[server_name]['tags'][tag['tag_address']] = {
|
slot_config[server['key']]['tags'][entry['address']] = {
|
||||||
**tag,
|
'frequency': entry['frequency'],
|
||||||
|
'active_read': entry['active_read'],
|
||||||
|
'names': entry['names'],
|
||||||
}
|
}
|
||||||
|
|
||||||
for server_name in slot_config:
|
for server_config in slot_config.values():
|
||||||
frequencies = [int(x['frequency']) for x in slot_config[server_name]['tags'].values()]
|
if server_config['subscription_period_ms'] is None:
|
||||||
|
frequencies = [tag['frequency'] for tag in server_config['tags'].values()]
|
||||||
|
server_config['subscription_period_ms'] = min(frequencies) / 2
|
||||||
|
|
||||||
min_frequency = min(frequencies) if frequencies else 1000
|
return slot_config
|
||||||
|
|
||||||
slot_config[server_name]['subscription_period_ms'] = min_frequency / 2
|
|
||||||
|
|
||||||
return slot_config, notifications
|
|
||||||
|
|||||||
@@ -7,6 +7,39 @@ with workflow.unsafe.imports_passed_through():
|
|||||||
from sientia_do.temporal.policies import retry_policy
|
from sientia_do.temporal.policies import retry_policy
|
||||||
|
|
||||||
from orchestrator.activities.activities import Activities
|
from orchestrator.activities.activities import Activities
|
||||||
|
from orchestrator.utils.orchestrator_functions import filter_by_tag_ids, referenced_tag_ids
|
||||||
|
|
||||||
|
# Tag database (SIENTIAPDE-1569) read by the slot v2 (SIENTIAPDE-1974). The workflow
|
||||||
|
# input may override these aggregations (connectors_query / connector_tags_query); the
|
||||||
|
# workflow puts a tag_id $match in front of connector_tags_query. Only the fields the
|
||||||
|
# slot uses are projected: other attributes may hold BSON types a Temporal payload
|
||||||
|
# cannot carry.
|
||||||
|
DEFAULT_CONNECTORS_QUERY = {
|
||||||
|
'collection': 'connectors',
|
||||||
|
'aggregation': [
|
||||||
|
{'$match': {'type': 'opc_ua', 'status': 'Active'}},
|
||||||
|
{
|
||||||
|
'$project': {
|
||||||
|
'_id': 0,
|
||||||
|
'connector_id': 1,
|
||||||
|
'name': 1,
|
||||||
|
'url': 1,
|
||||||
|
'server_uri': 1,
|
||||||
|
'use_security': 1,
|
||||||
|
'subscription_period_ms': 1,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
DEFAULT_CONNECTOR_TAGS_QUERY = {
|
||||||
|
'collection': 'connector_tags',
|
||||||
|
'aggregation': [
|
||||||
|
{'$match': {'category': 'Read', 'status': 'Active'}},
|
||||||
|
{'$project': {'_id': 0, 'tag_id': 1, 'connector_id': 1, 'address': 1, 'tag_name': 1}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
# Runs started before the tag database loads existed replay without them
|
||||||
|
TAG_DATABASE_PATCH = 'SIENTIAPDE-1974-tag-database'
|
||||||
|
|
||||||
|
|
||||||
@workflow.defn(name='orchestrator')
|
@workflow.defn(name='orchestrator')
|
||||||
@@ -20,6 +53,57 @@ class Orchestrator:
|
|||||||
and Redis infrastructure.
|
and Redis infrastructure.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
async def _load_tag_database(
|
||||||
|
self,
|
||||||
|
input_data: dict[str, Any],
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
pipelines: list[dict[str, Any]],
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Load the tag database (SIENTIAPDE-1974) for the read tags that carry a tag_id.
|
||||||
|
|
||||||
|
Only the referenced tags are loaded, with the fields the slot uses, so an
|
||||||
|
environment without a tag database never depends on its content (P4) and the
|
||||||
|
payload stays bounded by the pipelines. Nothing is loaded when no scouter read
|
||||||
|
tag has a tag_id, or when replaying a run started before TAG_DATABASE_PATCH.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data (dict[str, Any]): Workflow input (optional connectors_query and
|
||||||
|
connector_tags_query)
|
||||||
|
metadata (dict[str, Any]): Activity metadata ({'metadata': {...}})
|
||||||
|
pipelines (list[dict[str, Any]]): The loaded pipeline configurations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[list[dict[str, Any]], list[dict[str, Any]]]: connectors and connector_tags
|
||||||
|
"""
|
||||||
|
tag_ids = referenced_tag_ids(pipelines)
|
||||||
|
# patched() first: it must run on every execution to record its marker
|
||||||
|
if not workflow.patched(TAG_DATABASE_PATCH) or not tag_ids:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
connectors_query = input_data.get('connectors_query', DEFAULT_CONNECTORS_QUERY)
|
||||||
|
connector_tags_query = input_data.get('connector_tags_query', DEFAULT_CONNECTOR_TAGS_QUERY)
|
||||||
|
|
||||||
|
connectors_handler = workflow.start_local_activity_method(
|
||||||
|
Activities.aggregate_documents_in_mongodb,
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
# A fresh aggregation list: the activity appends a stage to the one it gets
|
||||||
|
'query': {**connectors_query, 'aggregation': list(connectors_query['aggregation'])},
|
||||||
|
},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=60),
|
||||||
|
)
|
||||||
|
|
||||||
|
connector_tags_handler = workflow.start_local_activity_method(
|
||||||
|
Activities.aggregate_documents_in_mongodb,
|
||||||
|
{**metadata, 'query': filter_by_tag_ids(connector_tags_query, tag_ids)},
|
||||||
|
retry_policy=retry_policy,
|
||||||
|
start_to_close_timeout=timedelta(seconds=60),
|
||||||
|
)
|
||||||
|
|
||||||
|
return await connectors_handler, await connector_tags_handler
|
||||||
|
|
||||||
@workflow.run
|
@workflow.run
|
||||||
async def run(self, input_data: dict[str, Any]):
|
async def run(self, input_data: dict[str, Any]):
|
||||||
"""
|
"""
|
||||||
@@ -36,6 +120,11 @@ class Orchestrator:
|
|||||||
- schedule_name (str): Name of the orchestration schedule
|
- schedule_name (str): Name of the orchestration schedule
|
||||||
- pipelines_query (dict[str, Any]): MongoDB query for pipeline configurations
|
- pipelines_query (dict[str, Any]): MongoDB query for pipeline configurations
|
||||||
- opc_servers_query (dict[str, Any]): MongoDB query for OPC server data
|
- opc_servers_query (dict[str, Any]): MongoDB query for OPC server data
|
||||||
|
Optional fields (used only when a scouter read tag carries a tag_id):
|
||||||
|
- connectors_query (dict[str, Any]): MongoDB aggregation for the tag
|
||||||
|
database connectors (default: DEFAULT_CONNECTORS_QUERY)
|
||||||
|
- connector_tags_query (dict[str, Any]): MongoDB aggregation for the tag
|
||||||
|
database tags (default: DEFAULT_CONNECTOR_TAGS_QUERY)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
None: Workflow completes without return value
|
None: Workflow completes without return value
|
||||||
@@ -103,6 +192,11 @@ class Orchestrator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
pipeline_config = await pipeline_config_handler
|
pipeline_config = await pipeline_config_handler
|
||||||
|
|
||||||
|
connectors, connector_tags = await self._load_tag_database(
|
||||||
|
input_data, metadata, pipeline_config
|
||||||
|
)
|
||||||
|
|
||||||
orchestrated_schedules = await orchestrated_schedules_handler
|
orchestrated_schedules = await orchestrated_schedules_handler
|
||||||
current_slot_config = await current_slot_config_handler
|
current_slot_config = await current_slot_config_handler
|
||||||
opc_servers = await opc_servers_handler
|
opc_servers = await opc_servers_handler
|
||||||
@@ -117,7 +211,7 @@ class Orchestrator:
|
|||||||
|
|
||||||
schedules_config_handler = workflow.start_local_activity_method(
|
schedules_config_handler = workflow.start_local_activity_method(
|
||||||
Activities.process_schedules,
|
Activities.process_schedules,
|
||||||
{**metadata, 'pipelines': pipeline_config},
|
{**metadata, 'pipelines': pipeline_config, 'connector_tags': connector_tags},
|
||||||
retry_policy=retry_policy,
|
retry_policy=retry_policy,
|
||||||
start_to_close_timeout=timedelta(seconds=60),
|
start_to_close_timeout=timedelta(seconds=60),
|
||||||
)
|
)
|
||||||
@@ -127,6 +221,8 @@ class Orchestrator:
|
|||||||
{
|
{
|
||||||
**metadata,
|
**metadata,
|
||||||
'opc_servers': opc_servers,
|
'opc_servers': opc_servers,
|
||||||
|
'connectors': connectors,
|
||||||
|
'connector_tags': connector_tags,
|
||||||
'active_ingestors': active_ingestors,
|
'active_ingestors': active_ingestors,
|
||||||
'pipelines': pipeline_config,
|
'pipelines': pipeline_config,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -137,18 +137,21 @@ class LoadNotificationPackage:
|
|||||||
is only safe to drop from the fetch if EVERY active group ignores it.
|
is only safe to drop from the fetch if EVERY active group ignores it.
|
||||||
A group with no `ignore_models_id` is treated as "ignores nothing" (empty
|
A group with no `ignore_models_id` is treated as "ignores nothing" (empty
|
||||||
set), which naturally zeroes out the intersection via set math.
|
set), which naturally zeroes out the intersection via set math.
|
||||||
|
- model_ids (SIENTIAPDE-2149): union of every group's `model_ids` allow-list,
|
||||||
|
mirroring `levels`' own union-or-nothing logic. If ANY active group has no
|
||||||
|
restriction (missing/empty `model_ids`), that group wants every model, so
|
||||||
|
the query can't restrict to an allow-list at all. `model_ids` and
|
||||||
|
`ignore_models_id` are independent axes and can both narrow the same
|
||||||
|
`model_id` field at once (Mongo supports multiple operators on one field).
|
||||||
"""
|
"""
|
||||||
if not sending_configs:
|
if not sending_configs:
|
||||||
return default_filter
|
return default_filter
|
||||||
|
|
||||||
sending_configs_levels = [group.get('levels') for group in sending_configs]
|
levels_union = self._union_when_all_restrict(
|
||||||
|
[group.get('levels') for group in sending_configs]
|
||||||
if all(sending_configs_levels):
|
)
|
||||||
levels_union: set[str] = set()
|
if levels_union is not None:
|
||||||
for levels in sending_configs_levels:
|
query_filter = {**default_filter, 'level': {'$in': levels_union}}
|
||||||
if levels:
|
|
||||||
levels_union.update(levels)
|
|
||||||
query_filter = {**default_filter, 'level': {'$in': sorted(levels_union)}}
|
|
||||||
else:
|
else:
|
||||||
query_filter = dict(default_filter)
|
query_filter = dict(default_filter)
|
||||||
|
|
||||||
@@ -160,7 +163,40 @@ class LoadNotificationPackage:
|
|||||||
]
|
]
|
||||||
models_intersection = set.intersection(*sending_configs_ignore_models_id)
|
models_intersection = set.intersection(*sending_configs_ignore_models_id)
|
||||||
|
|
||||||
|
model_ids_union = self._union_when_all_restrict(
|
||||||
|
[group.get('model_ids') for group in sending_configs]
|
||||||
|
)
|
||||||
|
|
||||||
|
model_id_filter: dict[str, Any] = {}
|
||||||
|
if model_ids_union is not None:
|
||||||
|
model_id_filter['$in'] = model_ids_union
|
||||||
if models_intersection:
|
if models_intersection:
|
||||||
query_filter['model_id'] = {'$nin': sorted(models_intersection)}
|
model_id_filter['$nin'] = sorted(models_intersection)
|
||||||
|
if model_id_filter:
|
||||||
|
query_filter['model_id'] = model_id_filter
|
||||||
|
|
||||||
return query_filter
|
return query_filter
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _union_when_all_restrict(per_group_values: list[list[str] | None]) -> list[str] | None:
|
||||||
|
"""
|
||||||
|
Union a list of per-group optional restrictions, but only when every group has one.
|
||||||
|
|
||||||
|
Shared by `levels` and `model_ids` (SIENTIAPDE-2149) in `_compute_query_time_filter`,
|
||||||
|
which both follow the same rule: if ANY active group is unrestricted on that axis
|
||||||
|
(missing/empty value), that group wants everything, so the union can't restrict the
|
||||||
|
query either — the caller falls back to no filter on that axis in that case (`None`).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
per_group_values: Each active group's raw value for the axis being unioned
|
||||||
|
(e.g. `group.get('levels')` for every group).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sorted union of every group's values, or None if any group was unrestricted.
|
||||||
|
"""
|
||||||
|
if not all(per_group_values):
|
||||||
|
return None
|
||||||
|
union: set[str] = set()
|
||||||
|
for values in per_group_values:
|
||||||
|
union.update(values or [])
|
||||||
|
return sorted(union)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "orchestrator"
|
name = "orchestrator"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
description = "Sientia DataOps Orchestrator - ML Model Orchestration System"
|
description = "Sientia DataOps Orchestrator - ML Model Orchestration System"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ def test_close(sientia_monitoring_mock, email):
|
|||||||
email.close()
|
email.close()
|
||||||
|
|
||||||
email.server.quit.assert_called_once()
|
email.server.quit.assert_called_once()
|
||||||
sientia_monitoring_mock.shutdown.assert_called_once()
|
# Count only this instance: an Email left by another test may be garbage-collected
|
||||||
|
# (Email.__del__ -> close) while the patch is active
|
||||||
|
assert sientia_monitoring_mock.shutdown.call_args_list.count(call(email)) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_build_email_html(email):
|
def test_build_email_html(email):
|
||||||
|
|||||||
@@ -218,110 +218,395 @@ def test_process_schedules_with_invalid_workflow_type(formatters):
|
|||||||
mock_scouter.assert_any_call(pipelines[2])
|
mock_scouter.assert_any_call(pipelines[2])
|
||||||
|
|
||||||
|
|
||||||
@patch(
|
def _scouter_pipeline(schedule_name, model_id, read_tags):
|
||||||
'orchestrator.activities.formatters.gather_read_tags',
|
return {
|
||||||
return_value={
|
'workflow_type': 'scouter',
|
||||||
'1:test_tag_address': {
|
'schedule_name': schedule_name,
|
||||||
'server_id': '1',
|
'model_id': model_id,
|
||||||
'server_name': 'test_server_name',
|
'model': {'name': f'model {model_id}'},
|
||||||
'tag_address': 'test_tag_address',
|
'read_tags': read_tags,
|
||||||
'topics': ['raw_test_schedule'],
|
}
|
||||||
'frequency': 1000,
|
|
||||||
},
|
|
||||||
'2:test_tag_address2': {
|
slot_opc_servers = [
|
||||||
'server_id': '2',
|
{'id': '1', 'server_name': 'kepserver', 'url': 'opc.tcp://kep:49320', 'uri': 'urn:kep'},
|
||||||
'server_name': 'test_server_name2',
|
]
|
||||||
'tag_address': 'test_tag_address2',
|
|
||||||
'topics': ['raw_test_schedule2'],
|
|
||||||
'frequency': 1000,
|
def test_process_slots(formatters):
|
||||||
},
|
"""Slot v2 split by address: the names of an address never land in different slots"""
|
||||||
'2:test_tag_address3': {
|
pipelines = [
|
||||||
'server_id': '2',
|
_scouter_pipeline(
|
||||||
'server_name': 'test_server_name2',
|
'sin',
|
||||||
'tag_address': 'test_tag_address3',
|
'm1',
|
||||||
'topics': ['raw_test_schedule2'],
|
[
|
||||||
'frequency': 1000,
|
{'server_id': '1', 'tag_address': 'ns=2;s=A', 'tag_name': 'a', 'frequency': 1000},
|
||||||
},
|
{'server_id': '1', 'tag_address': 'ns=2;s=B', 'tag_name': 'b', 'frequency': 1000},
|
||||||
},
|
],
|
||||||
)
|
),
|
||||||
@patch('orchestrator.activities.formatters.build_tag_config')
|
_scouter_pipeline(
|
||||||
def test_process_slots(mock_build_tag_config, mock_gather_read_tags, formatters):
|
'cos',
|
||||||
|
'm2',
|
||||||
|
[
|
||||||
|
{'server_id': '1', 'tag_address': 'ns=2;s=A', 'tag_name': 'x', 'frequency': 500},
|
||||||
|
{'server_id': '1', 'tag_address': 'ns=2;s=C', 'tag_name': 'c', 'frequency': 4000},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
input_data = {
|
input_data = {
|
||||||
'opc_servers': [
|
'opc_servers': slot_opc_servers,
|
||||||
{'id': '1', 'server_name': 'test_server_name', 'url': 'test_url', 'uri': 'test_uri'},
|
'active_ingestors': ['heartbeat:ingestor:1', 'heartbeat:ingestor:2'],
|
||||||
{'id': '2', 'server_name': 'test_server_name2', 'url': 'test_url2', 'uri': 'test_uri2'},
|
'pipelines': pipelines,
|
||||||
],
|
|
||||||
'active_ingestors': ['test_active_ingestor1', 'test_active_ingestor2'],
|
|
||||||
'pipelines': 'test_gather_read_tags',
|
|
||||||
**metadata,
|
**metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
expected_opc_servers = {
|
result = formatters.process_slots(input_data)
|
||||||
|
|
||||||
|
server = {
|
||||||
|
'server_id': '1',
|
||||||
|
'name': 'kepserver',
|
||||||
|
'url': 'opc.tcp://kep:49320',
|
||||||
|
'server_uri': 'urn:kep',
|
||||||
|
'use_security': False,
|
||||||
|
}
|
||||||
|
assert result == {
|
||||||
'1': {
|
'1': {
|
||||||
'id': '1',
|
'kepserver': {
|
||||||
'server_name': 'test_server_name',
|
**server,
|
||||||
'url': 'test_url',
|
'subscription_period_ms': 250,
|
||||||
'uri': 'test_uri',
|
'tags': {
|
||||||
|
'ns=2;s=A': {
|
||||||
|
'frequency': 500,
|
||||||
|
'active_read': {'enabled': False},
|
||||||
|
'names': [
|
||||||
|
{
|
||||||
|
'collection': 'raw_cos',
|
||||||
|
'tag_name': 'x',
|
||||||
|
'model_id': 'm2',
|
||||||
|
'model_name': 'model m2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'collection': 'raw_sin',
|
||||||
|
'tag_name': 'a',
|
||||||
|
'model_id': 'm1',
|
||||||
|
'model_name': 'model m1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'ns=2;s=B': {
|
||||||
|
'frequency': 1000,
|
||||||
|
'active_read': {'enabled': False},
|
||||||
|
'names': [
|
||||||
|
{
|
||||||
|
'collection': 'raw_sin',
|
||||||
|
'tag_name': 'b',
|
||||||
|
'model_id': 'm1',
|
||||||
|
'model_name': 'model m1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
'2': {
|
'2': {
|
||||||
'id': '2',
|
'kepserver': {
|
||||||
'server_name': 'test_server_name2',
|
**server,
|
||||||
'url': 'test_url2',
|
'subscription_period_ms': 2000,
|
||||||
'uri': 'test_uri2',
|
'tags': {
|
||||||
|
'ns=2;s=C': {
|
||||||
|
'frequency': 4000,
|
||||||
|
'active_read': {'enabled': False},
|
||||||
|
'names': [
|
||||||
|
{
|
||||||
|
'collection': 'raw_cos',
|
||||||
|
'tag_name': 'c',
|
||||||
|
'model_id': 'm2',
|
||||||
|
'model_name': 'model m2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
formatters.send_notification.assert_not_called()
|
||||||
|
|
||||||
slot_mock = {
|
|
||||||
'test_server_name': {
|
def test_process_slots_without_ingestors_builds_one_slot(formatters):
|
||||||
'server_id': '1',
|
pipelines = [
|
||||||
'name': 'test_server_name',
|
_scouter_pipeline(
|
||||||
'url': 'test_url',
|
'sin',
|
||||||
'server_uri': 'test_uri',
|
'm1',
|
||||||
'cert_path': None,
|
[{'server_id': '1', 'tag_address': 'ns=2;s=A', 'tag_name': 'a', 'frequency': 1000}],
|
||||||
'private_key_path': None,
|
)
|
||||||
'server_cert_path': None,
|
]
|
||||||
}
|
input_data = {
|
||||||
|
'opc_servers': slot_opc_servers,
|
||||||
|
'active_ingestors': [],
|
||||||
|
'pipelines': pipelines,
|
||||||
|
**metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_build_tag_config.return_value = (slot_mock, ['2'])
|
|
||||||
|
|
||||||
result = formatters.process_slots(input_data)
|
result = formatters.process_slots(input_data)
|
||||||
|
|
||||||
tags = list(mock_gather_read_tags.return_value.values())
|
assert list(result) == ['1']
|
||||||
|
assert list(result['1']['kepserver']['tags']) == ['ns=2;s=A']
|
||||||
|
|
||||||
mock_gather_read_tags.assert_called_once_with(input_data['pipelines'])
|
|
||||||
|
|
||||||
mock_build_tag_config.assert_has_calls(
|
def test_process_slots_more_ingestors_than_addresses(formatters):
|
||||||
[
|
"""Extra ingestors get empty slots, as before"""
|
||||||
call(tags[:2], expected_opc_servers),
|
pipelines = [
|
||||||
call(tags[2:], expected_opc_servers),
|
_scouter_pipeline(
|
||||||
]
|
'sin',
|
||||||
)
|
'm1',
|
||||||
|
[{'server_id': '1', 'tag_address': 'ns=2;s=A', 'tag_name': 'a', 'frequency': 1000}],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
input_data = {
|
||||||
|
'opc_servers': slot_opc_servers,
|
||||||
|
'active_ingestors': ['i1', 'i2', 'i3'],
|
||||||
|
'pipelines': pipelines,
|
||||||
|
**metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = formatters.process_slots(input_data)
|
||||||
|
|
||||||
|
assert list(result) == ['1', '2', '3']
|
||||||
|
assert list(result['1']['kepserver']['tags']) == ['ns=2;s=A']
|
||||||
|
assert result['2'] == {}
|
||||||
|
assert result['3'] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_slots_with_tag_database(formatters):
|
||||||
|
"""A read tag with tag_id takes address, name and server from the tag database"""
|
||||||
|
pipelines = [
|
||||||
|
_scouter_pipeline('sin', 'm1', [{'tag_id': 7, 'frequency': 1000}]),
|
||||||
|
]
|
||||||
|
input_data = {
|
||||||
|
'opc_servers': slot_opc_servers,
|
||||||
|
'connectors': [
|
||||||
|
{
|
||||||
|
'connector_id': 3,
|
||||||
|
'type': 'opc_ua',
|
||||||
|
'name': 'plant',
|
||||||
|
'url': 'opc.tcp://plant:4840',
|
||||||
|
'server_uri': 'urn:plant',
|
||||||
|
'use_security': True,
|
||||||
|
'subscription_period_ms': 1500,
|
||||||
|
'status': 'Active',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'connector_tags': [
|
||||||
|
{'tag_id': 7, 'connector_id': 3, 'address': 'ns=3;i=1001', 'tag_name': 'Pressure'}
|
||||||
|
],
|
||||||
|
'active_ingestors': ['i1'],
|
||||||
|
'pipelines': pipelines,
|
||||||
|
**metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = formatters.process_slots(input_data)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
'1': {
|
||||||
|
'plant': {
|
||||||
|
'server_id': '3',
|
||||||
|
'name': 'plant',
|
||||||
|
'url': 'opc.tcp://plant:4840',
|
||||||
|
'server_uri': 'urn:plant',
|
||||||
|
'use_security': True,
|
||||||
|
'subscription_period_ms': 1500,
|
||||||
|
'tags': {
|
||||||
|
'ns=3;i=1001': {
|
||||||
|
'frequency': 1000,
|
||||||
|
'active_read': {'enabled': False},
|
||||||
|
'names': [
|
||||||
|
{
|
||||||
|
'collection': 'raw_sin',
|
||||||
|
'tag_name': 'Pressure',
|
||||||
|
'model_id': 'm1',
|
||||||
|
'model_name': 'model m1',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_slots_notifies_unresolved_tags_once(formatters):
|
||||||
|
"""Each kind of unresolved read tag is notified once per run, not once per slot"""
|
||||||
|
pipelines = [
|
||||||
|
_scouter_pipeline(
|
||||||
|
'sin',
|
||||||
|
'm1',
|
||||||
|
[
|
||||||
|
{'server_id': '1', 'tag_address': 'ns=2;s=A', 'tag_name': 'a', 'frequency': 1000},
|
||||||
|
{'server_id': '9', 'tag_address': 'ns=2;s=B', 'tag_name': 'b', 'frequency': 1000},
|
||||||
|
{'tag_id': 404, 'frequency': 1000},
|
||||||
|
{'tag_id': 8, 'frequency': 1000},
|
||||||
|
{'server_id': '1', 'tag_address': 'ns=2;s=C', 'tag_name': 'c'},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
input_data = {
|
||||||
|
'opc_servers': slot_opc_servers,
|
||||||
|
'connectors': [],
|
||||||
|
'connector_tags': [
|
||||||
|
{'tag_id': 8, 'connector_id': 5, 'address': 'ns=3;i=8', 'tag_name': 'Level'}
|
||||||
|
],
|
||||||
|
'active_ingestors': ['i1', 'i2'],
|
||||||
|
'pipelines': pipelines,
|
||||||
|
**metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = formatters.process_slots(input_data)
|
||||||
|
|
||||||
|
assert list(result['1']['kepserver']['tags']) == ['ns=2;s=A']
|
||||||
|
assert result['2'] == {}
|
||||||
formatters.send_notification.assert_has_calls(
|
formatters.send_notification.assert_has_calls(
|
||||||
[
|
[
|
||||||
call(
|
call(
|
||||||
metadata=metadata['metadata'],
|
metadata=metadata['metadata'],
|
||||||
notification_id='ORCHESTRATOR_SERVER_NOT_FOUND_DURING_SLOT_CONFIGURATION',
|
notification_id='ORCHESTRATOR_SERVER_NOT_FOUND_DURING_SLOT_CONFIGURATION',
|
||||||
message='Servers 2 not found in opc_servers',
|
message='Servers 9 not found in opc_servers',
|
||||||
block='orchestrator',
|
block='orchestrator',
|
||||||
level=NotificationLevel.ERROR,
|
level=NotificationLevel.ERROR,
|
||||||
),
|
),
|
||||||
call(
|
call(
|
||||||
metadata=metadata['metadata'],
|
metadata=metadata['metadata'],
|
||||||
notification_id='ORCHESTRATOR_SERVER_NOT_FOUND_DURING_SLOT_CONFIGURATION',
|
notification_id='ORCHESTRATOR_TAG_NOT_FOUND_DURING_SLOT_CONFIGURATION',
|
||||||
message='Servers 2 not found in opc_servers',
|
message='Tags 404 not found among the active, complete Read tags of connector_tags',
|
||||||
|
block='orchestrator',
|
||||||
|
level=NotificationLevel.ERROR,
|
||||||
|
),
|
||||||
|
call(
|
||||||
|
metadata=metadata['metadata'],
|
||||||
|
notification_id='ORCHESTRATOR_CONNECTOR_NOT_FOUND_DURING_SLOT_CONFIGURATION',
|
||||||
|
message='Connectors 5 not found, not active or incomplete in connectors',
|
||||||
|
block='orchestrator',
|
||||||
|
level=NotificationLevel.ERROR,
|
||||||
|
),
|
||||||
|
call(
|
||||||
|
metadata=metadata['metadata'],
|
||||||
|
notification_id='ORCHESTRATOR_INVALID_READ_TAG_DURING_SLOT_CONFIGURATION',
|
||||||
|
message=(
|
||||||
|
'Read tags sin/ns=2;s=C have no integer frequency '
|
||||||
|
'or lack server_id, tag_address or tag_name'
|
||||||
|
),
|
||||||
block='orchestrator',
|
block='orchestrator',
|
||||||
level=NotificationLevel.ERROR,
|
level=NotificationLevel.ERROR,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
assert formatters.send_notification.call_count == 4
|
||||||
|
|
||||||
assert result == {
|
|
||||||
'1': slot_mock,
|
def test_process_slots_survives_malformed_tag_database(formatters):
|
||||||
'2': slot_mock,
|
"""An incomplete connector or tag leaves only its own read tags out of the slot"""
|
||||||
|
pipelines = [
|
||||||
|
_scouter_pipeline(
|
||||||
|
'sin',
|
||||||
|
'm1',
|
||||||
|
[
|
||||||
|
{'server_id': '1', 'tag_address': 'ns=2;s=A', 'tag_name': 'a', 'frequency': 1000},
|
||||||
|
{'tag_id': 7, 'frequency': 1000},
|
||||||
|
{'tag_id': 8, 'frequency': 1000},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
input_data = {
|
||||||
|
'opc_servers': slot_opc_servers,
|
||||||
|
'connectors': [{'connector_id': 3, 'name': 'plant', 'server_uri': 'urn:plant'}],
|
||||||
|
'connector_tags': [
|
||||||
|
{'tag_id': 7, 'connector_id': 3, 'address': 'ns=3;i=1', 'tag_name': 'P'},
|
||||||
|
{'tag_id': 8, 'connector_id': 3, 'tag_name': 'no address'},
|
||||||
|
],
|
||||||
|
'active_ingestors': [],
|
||||||
|
'pipelines': pipelines,
|
||||||
|
**metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result = formatters.process_slots(input_data)
|
||||||
|
|
||||||
|
assert list(result['1']) == ['kepserver']
|
||||||
|
notified = {c.kwargs['notification_id'] for c in formatters.send_notification.call_args_list}
|
||||||
|
assert notified == {
|
||||||
|
'ORCHESTRATOR_TAG_NOT_FOUND_DURING_SLOT_CONFIGURATION',
|
||||||
|
'ORCHESTRATOR_CONNECTOR_NOT_FOUND_DURING_SLOT_CONFIGURATION',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_slots_is_stable_across_pipeline_order(formatters):
|
||||||
|
"""The same pipelines in another order give the same slots (O6)"""
|
||||||
|
pipelines = [
|
||||||
|
_scouter_pipeline(
|
||||||
|
'sin',
|
||||||
|
'm1',
|
||||||
|
[{'server_id': '1', 'tag_address': 'ns=2;s=A', 'tag_name': 'a', 'frequency': 1000}],
|
||||||
|
),
|
||||||
|
_scouter_pipeline(
|
||||||
|
'cos',
|
||||||
|
'm2',
|
||||||
|
[
|
||||||
|
{'server_id': '1', 'tag_address': 'ns=2;s=B', 'tag_name': 'b', 'frequency': 1000},
|
||||||
|
{'server_id': '1', 'tag_address': 'ns=2;s=A', 'tag_name': 'x', 'frequency': 1000},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def run(order):
|
||||||
|
return formatters.process_slots(
|
||||||
|
{
|
||||||
|
'opc_servers': slot_opc_servers,
|
||||||
|
'active_ingestors': ['i1', 'i2'],
|
||||||
|
'pipelines': order,
|
||||||
|
**metadata,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.dumps(run(pipelines)) == json.dumps(run(list(reversed(pipelines))))
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_schedules_names_scouter_tags_like_the_slot(formatters):
|
||||||
|
"""model_tags and the slot's names[] take the tag name from the same source (SC1)"""
|
||||||
|
pipeline = {
|
||||||
|
**_scouter_pipeline('sin', 'm1', [{'tag_id': 7, 'frequency': 1000, 'aggr_func': 'avg'}]),
|
||||||
|
'updated_at': '2021-01-01',
|
||||||
|
}
|
||||||
|
connector_tags = [
|
||||||
|
{'tag_id': 7, 'connector_id': 3, 'address': 'ns=3;i=1001', 'tag_name': 'Pressure'}
|
||||||
|
]
|
||||||
|
connectors = [
|
||||||
|
{
|
||||||
|
'connector_id': 3,
|
||||||
|
'name': 'plant',
|
||||||
|
'url': 'opc.tcp://plant:4840',
|
||||||
|
'server_uri': 'urn:plant',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
schedules = formatters.process_schedules(
|
||||||
|
{'pipelines': [pipeline], 'connector_tags': connector_tags, **metadata}
|
||||||
|
)
|
||||||
|
slots = formatters.process_slots(
|
||||||
|
{
|
||||||
|
'opc_servers': [],
|
||||||
|
'connectors': connectors,
|
||||||
|
'connector_tags': connector_tags,
|
||||||
|
'active_ingestors': [],
|
||||||
|
'pipelines': [pipeline],
|
||||||
|
**metadata,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
model_tags = schedules['scouter']['sin']['model_tags']
|
||||||
|
names = slots['1']['plant']['tags']['ns=3;i=1001']['names']
|
||||||
|
assert list(model_tags) == ['Pressure']
|
||||||
|
assert model_tags['Pressure']['aggr_func'] == 'avg'
|
||||||
|
assert [name['tag_name'] for name in names] == ['Pressure']
|
||||||
|
assert names[0]['collection'] == schedules['scouter']['sin']['topic']
|
||||||
|
|
||||||
|
|
||||||
def test_format_schedule_config(formatters):
|
def test_format_schedule_config(formatters):
|
||||||
input_data = {
|
input_data = {
|
||||||
@@ -410,6 +695,31 @@ def test_create_slot_config(formatters):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_slot_config_skips_unchanged_slots(formatters):
|
||||||
|
"""Only new or changed slots are written (O6); current slots come keyed by Redis key"""
|
||||||
|
slot_v2 = {'kepserver': {'server_id': '1', 'tags': {'ns=2;s=A': {'names': []}}}}
|
||||||
|
slot_v1 = {'kepserver': {'server_id': '1', 'tags': {'ns=2;s=A': {'topics': ['raw_x']}}}}
|
||||||
|
input_data = {
|
||||||
|
'current_slot_config': {
|
||||||
|
'slot:opc_tags:1': slot_v2,
|
||||||
|
'slot:opc_tags:2': slot_v1,
|
||||||
|
},
|
||||||
|
'slot_config': {'1': slot_v2, '2': slot_v2, '3': {}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = formatters.create_slot_config(input_data)
|
||||||
|
|
||||||
|
assert result == {'to_delete': [], 'to_insert': {'2': slot_v2, '3': {}}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_slot_config_without_current_slots(formatters):
|
||||||
|
slot_config = {'1': {'kepserver': {'tags': {}}}, '2': {}}
|
||||||
|
|
||||||
|
result = formatters.create_slot_config({'current_slot_config': {}, 'slot_config': slot_config})
|
||||||
|
|
||||||
|
assert result == {'to_delete': [], 'to_insert': slot_config}
|
||||||
|
|
||||||
|
|
||||||
def test_send_success_report(formatters):
|
def test_send_success_report(formatters):
|
||||||
formatters.send_success_report(
|
formatters.send_success_report(
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
@@ -852,6 +1162,123 @@ def test_filter_notification_reports_ignore_models_id_restriction(formatters):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_notification_reports_model_ids_allow_list_restriction(formatters):
|
||||||
|
# SIENTIAPDE-2149: a group with `model_ids` set only receives notifications
|
||||||
|
# from those models, unlike `ignore_models_id` which is a deny-list.
|
||||||
|
input_data = {
|
||||||
|
**metadata,
|
||||||
|
'notification_package': [
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_1',
|
||||||
|
'notification_id': 'test_notification_id_1',
|
||||||
|
'model_id': 'model_x',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_2',
|
||||||
|
'notification_id': 'test_notification_id_2',
|
||||||
|
'model_id': 'model_y',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'sending_configs': [
|
||||||
|
{
|
||||||
|
'group_name': 'test_group_1',
|
||||||
|
'contents': ['reports'],
|
||||||
|
'model_ids': ['model_x'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
response = formatters.filter_notification_reports(input_data)
|
||||||
|
|
||||||
|
assert response == {
|
||||||
|
'test_group_1': {
|
||||||
|
'group_name': 'test_group_1',
|
||||||
|
'contents': ['reports'],
|
||||||
|
'model_ids': ['model_x'],
|
||||||
|
'notifications': [
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_1',
|
||||||
|
'notification_id': 'test_notification_id_1',
|
||||||
|
'model_id': 'model_x',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_notification_reports_model_ids_and_ignore_models_id_together(formatters):
|
||||||
|
# SIENTIAPDE-2149: the allow-list and deny-list apply together (AND), not
|
||||||
|
# as alternatives -- a model must pass both checks.
|
||||||
|
input_data = {
|
||||||
|
**metadata,
|
||||||
|
'notification_package': [
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_1',
|
||||||
|
'notification_id': 'test_notification_id_1',
|
||||||
|
'model_id': 'model_x',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_2',
|
||||||
|
'notification_id': 'test_notification_id_2',
|
||||||
|
'model_id': 'model_y',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_3',
|
||||||
|
'notification_id': 'test_notification_id_3',
|
||||||
|
'model_id': 'model_z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'sending_configs': [
|
||||||
|
{
|
||||||
|
'group_name': 'test_group_1',
|
||||||
|
'contents': ['reports'],
|
||||||
|
'model_ids': ['model_x', 'model_y'],
|
||||||
|
'ignore_models_id': ['model_x'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
response = formatters.filter_notification_reports(input_data)
|
||||||
|
|
||||||
|
assert response == {
|
||||||
|
'test_group_1': {
|
||||||
|
'group_name': 'test_group_1',
|
||||||
|
'contents': ['reports'],
|
||||||
|
'model_ids': ['model_x', 'model_y'],
|
||||||
|
'ignore_models_id': ['model_x'],
|
||||||
|
'notifications': [
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_2',
|
||||||
|
'notification_id': 'test_notification_id_2',
|
||||||
|
'model_id': 'model_y',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_notification_reports_model_ids_explicit_none_does_not_raise(formatters):
|
||||||
|
# Same defensive concern as ignore_models_id: receiver_groups has no schema
|
||||||
|
# validation, so model_ids can be explicitly null instead of omitted.
|
||||||
|
input_data = {
|
||||||
|
**metadata,
|
||||||
|
'notification_package': [
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_1',
|
||||||
|
'notification_id': 'test_notification_id_1',
|
||||||
|
'model_id': 'model_x',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'sending_configs': [
|
||||||
|
{'group_name': 'test_group_1', 'contents': ['reports'], 'model_ids': None},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
response = formatters.filter_notification_reports(input_data)
|
||||||
|
|
||||||
|
assert response['test_group_1']['notifications'] == input_data['notification_package']
|
||||||
|
|
||||||
|
|
||||||
def test_filter_notification_reports_no_levels_or_ignore_models_id_passes_everything(
|
def test_filter_notification_reports_no_levels_or_ignore_models_id_passes_everything(
|
||||||
formatters,
|
formatters,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -481,6 +481,137 @@ def test_filter_notification_alerts_ignore_models_id_restriction(slot_manager):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_notification_alerts_model_ids_allow_list_restriction(slot_manager):
|
||||||
|
# SIENTIAPDE-2149: a group with `model_ids` set only receives notifications
|
||||||
|
# from those models, unlike `ignore_models_id` which is a deny-list.
|
||||||
|
slot_manager.redis_repository.get = MagicMock(side_effect=[None, None])
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
**metadata,
|
||||||
|
'notification_package': [
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_1',
|
||||||
|
'notification_id': 'test_notification_id_1',
|
||||||
|
'model_id': 'model_x',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_2',
|
||||||
|
'notification_id': 'test_notification_id_2',
|
||||||
|
'model_id': 'model_y',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'sending_configs': [
|
||||||
|
{
|
||||||
|
'group_name': 'test_group_1',
|
||||||
|
'contents': ['core_alerts'],
|
||||||
|
'model_ids': ['model_x'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'notification_ttl': 300,
|
||||||
|
'mail_type': 'test_mail_type',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = slot_manager.filter_notification_alerts(input_data)
|
||||||
|
|
||||||
|
assert response == {
|
||||||
|
'test_group_1': {
|
||||||
|
'group_name': 'test_group_1',
|
||||||
|
'contents': ['core_alerts'],
|
||||||
|
'model_ids': ['model_x'],
|
||||||
|
'notifications': [
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_1',
|
||||||
|
'notification_id': 'test_notification_id_1',
|
||||||
|
'model_id': 'model_x',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_notification_alerts_model_ids_and_ignore_models_id_together(slot_manager):
|
||||||
|
# SIENTIAPDE-2149: the allow-list and deny-list apply together (AND), not
|
||||||
|
# as alternatives -- a model must pass both checks.
|
||||||
|
slot_manager.redis_repository.get = MagicMock(side_effect=[None, None, None])
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
**metadata,
|
||||||
|
'notification_package': [
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_1',
|
||||||
|
'notification_id': 'test_notification_id_1',
|
||||||
|
'model_id': 'model_x',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_2',
|
||||||
|
'notification_id': 'test_notification_id_2',
|
||||||
|
'model_id': 'model_y',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_3',
|
||||||
|
'notification_id': 'test_notification_id_3',
|
||||||
|
'model_id': 'model_z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'sending_configs': [
|
||||||
|
{
|
||||||
|
'group_name': 'test_group_1',
|
||||||
|
'contents': ['core_alerts'],
|
||||||
|
'model_ids': ['model_x', 'model_y'],
|
||||||
|
'ignore_models_id': ['model_x'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'notification_ttl': 300,
|
||||||
|
'mail_type': 'test_mail_type',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = slot_manager.filter_notification_alerts(input_data)
|
||||||
|
|
||||||
|
# model_x: allowed but ignored -> excluded. model_y: allowed and not
|
||||||
|
# ignored -> included. model_z: not in the allow-list -> excluded.
|
||||||
|
assert response == {
|
||||||
|
'test_group_1': {
|
||||||
|
'group_name': 'test_group_1',
|
||||||
|
'contents': ['core_alerts'],
|
||||||
|
'model_ids': ['model_x', 'model_y'],
|
||||||
|
'ignore_models_id': ['model_x'],
|
||||||
|
'notifications': [
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_2',
|
||||||
|
'notification_id': 'test_notification_id_2',
|
||||||
|
'model_id': 'model_y',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_notification_alerts_model_ids_explicit_none_does_not_raise(slot_manager):
|
||||||
|
# Same defensive concern as ignore_models_id: receiver_groups has no schema
|
||||||
|
# validation, so model_ids can be explicitly null instead of omitted.
|
||||||
|
slot_manager.redis_repository.get = MagicMock(side_effect=[None])
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
**metadata,
|
||||||
|
'notification_package': [
|
||||||
|
{
|
||||||
|
'trigger': 'test_trigger_1',
|
||||||
|
'notification_id': 'test_notification_id_1',
|
||||||
|
'model_id': 'model_x',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'sending_configs': [
|
||||||
|
{'group_name': 'test_group_1', 'contents': ['core_alerts'], 'model_ids': None},
|
||||||
|
],
|
||||||
|
'notification_ttl': 300,
|
||||||
|
'mail_type': 'test_mail_type',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = slot_manager.filter_notification_alerts(input_data)
|
||||||
|
|
||||||
|
assert response['test_group_1']['notifications'] == input_data['notification_package']
|
||||||
|
|
||||||
|
|
||||||
def test_filter_notification_alerts_no_levels_or_ignore_models_id_passes_everything(
|
def test_filter_notification_alerts_no_levels_or_ignore_models_id_passes_everything(
|
||||||
slot_manager,
|
slot_manager,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ from orchestrator.utils.orchestrator_functions import (
|
|||||||
build_tag_config,
|
build_tag_config,
|
||||||
common_config,
|
common_config,
|
||||||
drift,
|
drift,
|
||||||
|
filter_by_tag_ids,
|
||||||
gather_read_tags,
|
gather_read_tags,
|
||||||
|
index_connector_tags,
|
||||||
|
index_connectors,
|
||||||
minimal_retrain,
|
minimal_retrain,
|
||||||
overlap_filter_config,
|
overlap_filter_config,
|
||||||
pi_web_api_scouter,
|
pi_web_api_scouter,
|
||||||
predictions_batch,
|
predictions_batch,
|
||||||
process_path_priority,
|
process_path_priority,
|
||||||
|
referenced_tag_ids,
|
||||||
|
resolve_tag_names,
|
||||||
scouter,
|
scouter,
|
||||||
simple_metrics,
|
simple_metrics,
|
||||||
)
|
)
|
||||||
@@ -311,249 +316,562 @@ def test_predictions_batch(mock_process_path_priority, mock_overlap_filter_confi
|
|||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
def test_gather_read_tags():
|
def _pipeline(schedule_name, model_id, read_tags, workflow_type='scouter'):
|
||||||
pipelines = [
|
return {
|
||||||
{
|
'workflow_type': workflow_type,
|
||||||
'workflow_type': 'scouter',
|
'schedule_name': schedule_name,
|
||||||
'schedule_name': 'test_schedule',
|
'model_id': model_id,
|
||||||
'read_tags': [
|
'model': {'name': f'model {model_id}'},
|
||||||
{
|
'read_tags': read_tags,
|
||||||
'server_id': '1',
|
|
||||||
'server_name': 'test_server_name',
|
|
||||||
'tag_address': 'test_tag_address',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'workflow_type': 'scouter',
|
|
||||||
'schedule_name': 'test_schedule2',
|
|
||||||
'read_tags': [
|
|
||||||
{
|
|
||||||
'server_id': '2',
|
|
||||||
'server_name': 'test_server_name2',
|
|
||||||
'tag_address': 'test_tag_address2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'server_id': '2',
|
|
||||||
'server_name': 'test_server_name2',
|
|
||||||
'tag_address': 'test_tag_address3',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
result = gather_read_tags(pipelines)
|
|
||||||
|
|
||||||
expected = {
|
|
||||||
'1:test_tag_address': {
|
|
||||||
'server_id': '1',
|
|
||||||
'server_name': 'test_server_name',
|
|
||||||
'tag_address': 'test_tag_address',
|
|
||||||
'topics': ['raw_test_schedule'],
|
|
||||||
},
|
|
||||||
'2:test_tag_address2': {
|
|
||||||
'server_id': '2',
|
|
||||||
'server_name': 'test_server_name2',
|
|
||||||
'tag_address': 'test_tag_address2',
|
|
||||||
'topics': ['raw_test_schedule2'],
|
|
||||||
},
|
|
||||||
'2:test_tag_address3': {
|
|
||||||
'server_id': '2',
|
|
||||||
'server_name': 'test_server_name2',
|
|
||||||
'tag_address': 'test_tag_address3',
|
|
||||||
'topics': ['raw_test_schedule2'],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assert result == expected
|
|
||||||
|
def _legacy_tag(address, name, frequency=1000, server_id='1', **extra):
|
||||||
|
return {
|
||||||
|
'server_id': server_id,
|
||||||
|
'tag_address': address,
|
||||||
|
'tag_name': name,
|
||||||
|
'frequency': frequency,
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _name(schedule_name, tag_name, model_id):
|
||||||
|
return {
|
||||||
|
'collection': f'raw_{schedule_name}',
|
||||||
|
'tag_name': tag_name,
|
||||||
|
'model_id': model_id,
|
||||||
|
'model_name': f'model {model_id}',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
opc_servers = {
|
||||||
|
'1': {
|
||||||
|
'id': '1',
|
||||||
|
'server_name': 'kepserver',
|
||||||
|
'url': 'opc.tcp://kep:49320',
|
||||||
|
'uri': 'urn:kep',
|
||||||
|
'cert_path': 'certs/client.pem',
|
||||||
|
},
|
||||||
|
'2': {'id': '2', 'server_name': 'simulator', 'url': 'opc.tcp://sim:4840', 'uri': 'urn:sim'},
|
||||||
|
}
|
||||||
|
|
||||||
|
connectors = {
|
||||||
|
'3': {
|
||||||
|
'connector_id': 3,
|
||||||
|
'name': 'plant',
|
||||||
|
'url': 'opc.tcp://plant:4840',
|
||||||
|
'server_uri': 'urn:plant',
|
||||||
|
'use_security': True,
|
||||||
|
'subscription_period_ms': 1500,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags_by_id = {
|
||||||
|
'7': {'tag_id': 7, 'connector_id': 3, 'address': 'ns=3;i=1001', 'tag_name': 'Pressure'},
|
||||||
|
'8': {'tag_id': 8, 'connector_id': 5, 'address': 'ns=3;i=1002', 'tag_name': 'Level'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _gather(pipelines, servers=None, connector_index=None, tag_index=None):
|
||||||
|
return gather_read_tags(
|
||||||
|
pipelines,
|
||||||
|
opc_servers if servers is None else servers,
|
||||||
|
connectors if connector_index is None else connector_index,
|
||||||
|
tags_by_id if tag_index is None else tag_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_connector_tags():
|
||||||
|
tags = [
|
||||||
|
{'tag_id': 7, 'connector_id': 3, 'address': 'ns=3;i=1', 'tag_name': 'a'},
|
||||||
|
{'tag_id': '8', 'connector_id': 3, 'address': 'ns=3;i=2', 'tag_name': 'b'},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert index_connector_tags(tags) == {'7': tags[0], '8': tags[1]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_tag_names():
|
||||||
|
read_tags = [
|
||||||
|
{'tag_id': 7},
|
||||||
|
{'tag_id': '7', 'tag_name': 'own name'},
|
||||||
|
{'tag_id': 404},
|
||||||
|
{'tag_name': 'legacy'},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert resolve_tag_names(read_tags, tags_by_id) == [
|
||||||
|
{'tag_id': 7, 'tag_name': 'Pressure'},
|
||||||
|
{'tag_id': '7', 'tag_name': 'own name'},
|
||||||
|
{'tag_id': 404},
|
||||||
|
{'tag_name': 'legacy'},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_multi_name():
|
||||||
|
"""One address read by two pipelines with different names: one entry, two names"""
|
||||||
|
pipelines = [
|
||||||
|
_pipeline('sin', 'm1', [_legacy_tag('ns=2;s=A', 'Counter', frequency=15000)]),
|
||||||
|
_pipeline('cos', 'm2', [_legacy_tag('ns=2;s=A', 'Contador', frequency=5000)]),
|
||||||
|
]
|
||||||
|
|
||||||
|
entries, issues = _gather(pipelines)
|
||||||
|
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0]['address'] == 'ns=2;s=A'
|
||||||
|
assert entries[0]['frequency'] == 5000
|
||||||
|
assert entries[0]['names'] == [_name('cos', 'Contador', 'm2'), _name('sin', 'Counter', 'm1')]
|
||||||
|
assert issues == {'tags': set(), 'connectors': set(), 'servers': set(), 'invalid': set()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_same_name_in_two_pipelines():
|
||||||
|
"""The same name read by two pipelines still goes to both collections"""
|
||||||
|
pipelines = [
|
||||||
|
_pipeline('sin', 'm1', [_legacy_tag('ns=2;s=A', 'Counter')]),
|
||||||
|
_pipeline('cos', 'm2', [_legacy_tag('ns=2;s=A', 'Counter')]),
|
||||||
|
]
|
||||||
|
|
||||||
|
entries, _issues = _gather(pipelines)
|
||||||
|
|
||||||
|
assert entries[0]['names'] == [_name('cos', 'Counter', 'm2'), _name('sin', 'Counter', 'm1')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_duplicate_tag_in_one_pipeline():
|
||||||
|
pipelines = [
|
||||||
|
_pipeline('sin', 'm1', [_legacy_tag('ns=2;s=A', 'a'), _legacy_tag('ns=2;s=A', 'a')]),
|
||||||
|
]
|
||||||
|
|
||||||
|
entries, _issues = _gather(pipelines)
|
||||||
|
|
||||||
|
assert entries[0]['names'] == [_name('sin', 'a', 'm1')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_same_address_on_two_servers():
|
||||||
|
pipelines = [
|
||||||
|
_pipeline(
|
||||||
|
'sin',
|
||||||
|
'm1',
|
||||||
|
[_legacy_tag('ns=2;s=A', 'a', server_id='2'), _legacy_tag('ns=2;s=A', 'a')],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
entries, _issues = _gather(pipelines)
|
||||||
|
|
||||||
|
assert [(e['server']['server_id'], e['address']) for e in entries] == [
|
||||||
|
('1', 'ns=2;s=A'),
|
||||||
|
('2', 'ns=2;s=A'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_legacy_server():
|
||||||
|
"""Without tag_id: opc-servers fields, no certificate paths, security from cert_path"""
|
||||||
|
entries, _issues = _gather([_pipeline('sin', 'm1', [_legacy_tag('ns=2;s=A', 'a')])])
|
||||||
|
|
||||||
|
assert entries[0]['server'] == {
|
||||||
|
'server_id': '1',
|
||||||
|
'name': 'kepserver',
|
||||||
|
'url': 'opc.tcp://kep:49320',
|
||||||
|
'server_uri': 'urn:kep',
|
||||||
|
'use_security': True,
|
||||||
|
'subscription_period_ms': None,
|
||||||
|
'from_tag_database': False,
|
||||||
|
'key': 'kepserver',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_numeric_server_id():
|
||||||
|
"""A numeric server_id in the pipeline still finds its opc-servers document"""
|
||||||
|
entries, issues = _gather([_pipeline('sin', 'm1', [_legacy_tag('ns=2;s=A', 'a', server_id=1)])])
|
||||||
|
|
||||||
|
assert entries[0]['server']['server_id'] == '1'
|
||||||
|
assert not issues['servers']
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_with_tag_database():
|
||||||
|
"""With tag_id: address, name and server come from connector_tags + connectors"""
|
||||||
|
entries, _issues = _gather([_pipeline('sin', 'm1', [{'tag_id': 7, 'frequency': 1000}])])
|
||||||
|
|
||||||
|
assert entries == [
|
||||||
|
{
|
||||||
|
'server': {
|
||||||
|
'server_id': '3',
|
||||||
|
'name': 'plant',
|
||||||
|
'url': 'opc.tcp://plant:4840',
|
||||||
|
'server_uri': 'urn:plant',
|
||||||
|
'use_security': True,
|
||||||
|
'subscription_period_ms': 1500,
|
||||||
|
'from_tag_database': True,
|
||||||
|
'key': 'plant',
|
||||||
|
},
|
||||||
|
'address': 'ns=3;i=1001',
|
||||||
|
'frequency': 1000,
|
||||||
|
'active_read': {'enabled': False},
|
||||||
|
'names': [_name('sin', 'Pressure', 'm1')],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_pipeline_name_wins_over_tag_database():
|
||||||
|
entries, _issues = _gather(
|
||||||
|
[_pipeline('sin', 'm1', [{'tag_id': 7, 'tag_name': 'P1', 'frequency': 1000}])]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert entries[0]['names'] == [_name('sin', 'P1', 'm1')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_unknown_tag_id():
|
||||||
|
entries, issues = _gather([_pipeline('sin', 'm1', [{'tag_id': 404, 'frequency': 1000}])])
|
||||||
|
|
||||||
|
assert entries == []
|
||||||
|
assert issues == {'tags': {'404'}, 'connectors': set(), 'servers': set(), 'invalid': set()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_inactive_connector():
|
||||||
|
"""Connector 5 is not among the active connectors: its tag is left out"""
|
||||||
|
entries, issues = _gather([_pipeline('sin', 'm1', [{'tag_id': 8, 'frequency': 1000}])])
|
||||||
|
|
||||||
|
assert entries == []
|
||||||
|
assert issues == {'tags': set(), 'connectors': {'5'}, 'servers': set(), 'invalid': set()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_unknown_server():
|
||||||
|
entries, issues = _gather(
|
||||||
|
[_pipeline('sin', 'm1', [_legacy_tag('ns=2;s=A', 'a', server_id='9')])]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert entries == []
|
||||||
|
assert issues == {'tags': set(), 'connectors': set(), 'servers': {'9'}, 'invalid': set()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_mixed_with_and_without_tag_id():
|
||||||
|
"""Both paths in one run; the same node via tag_id and via tag_address merges"""
|
||||||
|
connector_1 = {
|
||||||
|
'connector_id': 1,
|
||||||
|
'name': 'kepserver',
|
||||||
|
'url': 'opc.tcp://kep:49320',
|
||||||
|
'server_uri': 'urn:kep',
|
||||||
|
'use_security': True,
|
||||||
|
'subscription_period_ms': 2000,
|
||||||
|
}
|
||||||
|
tag_index = {
|
||||||
|
'20': {'tag_id': 20, 'connector_id': 1, 'address': 'ns=2;s=A', 'tag_name': 'A from db'},
|
||||||
|
}
|
||||||
|
pipelines = [
|
||||||
|
_pipeline('sin', 'm1', [_legacy_tag('ns=2;s=A', 'a'), _legacy_tag('ns=2;s=B', 'b')]),
|
||||||
|
_pipeline(
|
||||||
|
'cos', 'm2', [{'tag_id': 20, 'frequency': 500}, {'tag_id': 7, 'frequency': 1000}]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
entries, issues = _gather(
|
||||||
|
pipelines,
|
||||||
|
connector_index={**connectors, '1': connector_1},
|
||||||
|
tag_index={**tags_by_id, **tag_index},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [(e['server']['server_id'], e['address']) for e in entries] == [
|
||||||
|
('1', 'ns=2;s=A'),
|
||||||
|
('1', 'ns=2;s=B'),
|
||||||
|
('3', 'ns=3;i=1001'),
|
||||||
|
]
|
||||||
|
assert entries[0]['names'] == [_name('cos', 'A from db', 'm2'), _name('sin', 'a', 'm1')]
|
||||||
|
assert entries[0]['frequency'] == 500
|
||||||
|
# Same server_id in connectors and opc-servers: the connectors fields win
|
||||||
|
assert entries[0]['server']['subscription_period_ms'] == 2000
|
||||||
|
assert entries[1]['server'] is entries[0]['server']
|
||||||
|
assert issues == {'tags': set(), 'connectors': set(), 'servers': set(), 'invalid': set()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_server_name_clash():
|
||||||
|
"""Two servers with the same name get distinct slot keys, stable by server_id"""
|
||||||
|
servers = {
|
||||||
|
'1': opc_servers['1'],
|
||||||
|
'2': {**opc_servers['2'], 'server_name': 'kepserver'},
|
||||||
|
}
|
||||||
|
pipelines = [
|
||||||
|
_pipeline(
|
||||||
|
'sin',
|
||||||
|
'm1',
|
||||||
|
[_legacy_tag('ns=2;s=A', 'a', server_id='2'), _legacy_tag('ns=2;s=B', 'b')],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
entries, _issues = _gather(pipelines, servers=servers)
|
||||||
|
|
||||||
|
assert [e['server']['key'] for e in entries] == ['kepserver', 'kepserver:2']
|
||||||
|
assert [e['server']['name'] for e in entries] == ['kepserver', 'kepserver']
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_active_read():
|
||||||
|
"""Active read: on if any pipeline asks, with the lowest cycle_multiple (D3)"""
|
||||||
|
pipelines = [
|
||||||
|
_pipeline('p1', 'm1', [_legacy_tag('ns=2;s=A', 'a')]),
|
||||||
|
_pipeline(
|
||||||
|
'p2',
|
||||||
|
'm2',
|
||||||
|
[_legacy_tag('ns=2;s=A', 'a', active_read={'enabled': True, 'cycle_multiple': 4})],
|
||||||
|
),
|
||||||
|
_pipeline(
|
||||||
|
'p3',
|
||||||
|
'm3',
|
||||||
|
[_legacy_tag('ns=2;s=A', 'a', active_read={'enabled': True, 'cycle_multiple': 2})],
|
||||||
|
),
|
||||||
|
_pipeline(
|
||||||
|
'p4',
|
||||||
|
'm4',
|
||||||
|
[_legacy_tag('ns=2;s=B', 'b', active_read={'enabled': False, 'cycle_multiple': 1})],
|
||||||
|
),
|
||||||
|
_pipeline('p5', 'm5', [_legacy_tag('ns=2;s=C', 'c', active_read={'enabled': True})]),
|
||||||
|
]
|
||||||
|
|
||||||
|
entries, _issues = _gather(pipelines)
|
||||||
|
|
||||||
|
assert [e['active_read'] for e in entries] == [
|
||||||
|
{'enabled': True, 'cycle_multiple': 2},
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True, 'cycle_multiple': 1},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_gather_read_tags_filters_non_scouter_workflows():
|
def test_gather_read_tags_filters_non_scouter_workflows():
|
||||||
"""Test that gather_read_tags only processes scouter workflow types and ignores others"""
|
"""Only scouter pipelines feed the slots"""
|
||||||
pipelines = [
|
pipelines = [
|
||||||
{
|
_pipeline('sin', 'm1', [_legacy_tag('ns=2;s=A', 'a')]),
|
||||||
'workflow_type': 'scouter',
|
_pipeline('pred', 'm2', [_legacy_tag('ns=2;s=P', 'p')], workflow_type='predictions_batch'),
|
||||||
'schedule_name': 'test_scouter_schedule',
|
_pipeline('retrain', 'm3', [_legacy_tag('ns=2;s=R', 'r')], workflow_type='minimal_retrain'),
|
||||||
'read_tags': [
|
_pipeline('drift', 'm4', [_legacy_tag('ns=2;s=D', 'd')], workflow_type='drift'),
|
||||||
{
|
|
||||||
'server_id': '1',
|
|
||||||
'server_name': 'test_server_name',
|
|
||||||
'tag_address': 'test_tag_address',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'workflow_type': 'predictions_batch',
|
|
||||||
'schedule_name': 'test_predictions_schedule',
|
|
||||||
'read_tags': [
|
|
||||||
{
|
|
||||||
'server_id': '2',
|
|
||||||
'server_name': 'test_server_name2',
|
|
||||||
'tag_address': 'test_tag_address_predictions',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'workflow_type': 'minimal_retrain',
|
|
||||||
'schedule_name': 'test_retrain_schedule',
|
|
||||||
'read_tags': [
|
|
||||||
{
|
|
||||||
'server_id': '3',
|
|
||||||
'server_name': 'test_server_name3',
|
|
||||||
'tag_address': 'test_tag_address_retrain',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'workflow_type': 'pi_web_api_scouter',
|
|
||||||
'schedule_name': 'test_pi_web_api_schedule',
|
|
||||||
'read_tags': [
|
|
||||||
{
|
|
||||||
'server_id': '4',
|
|
||||||
'server_name': 'test_server_name4',
|
|
||||||
'tag_address': 'test_tag_address_pi_web_api',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'workflow_type': 'drift',
|
|
||||||
'schedule_name': 'test_drift_schedule',
|
|
||||||
'read_tags': [
|
|
||||||
{
|
|
||||||
'server_id': '5',
|
|
||||||
'server_name': 'test_server_name5',
|
|
||||||
'tag_address': 'test_tag_address_drift',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'workflow_type': 'scouter',
|
|
||||||
'schedule_name': 'test_scouter_schedule2',
|
|
||||||
'read_tags': [
|
|
||||||
{
|
|
||||||
'server_id': '1',
|
|
||||||
'server_name': 'test_server_name',
|
|
||||||
'tag_address': 'test_tag_address2',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
result = gather_read_tags(pipelines)
|
entries, _issues = _gather(pipelines)
|
||||||
|
|
||||||
# Only scouter workflow types should be included
|
assert [e['address'] for e in entries] == ['ns=2;s=A']
|
||||||
expected = {
|
|
||||||
'1:test_tag_address': {
|
|
||||||
'server_id': '1',
|
|
||||||
'server_name': 'test_server_name',
|
|
||||||
'tag_address': 'test_tag_address',
|
|
||||||
'topics': ['raw_test_scouter_schedule'],
|
|
||||||
},
|
|
||||||
'1:test_tag_address2': {
|
|
||||||
'server_id': '1',
|
|
||||||
'server_name': 'test_server_name',
|
|
||||||
'tag_address': 'test_tag_address2',
|
|
||||||
'topics': ['raw_test_scouter_schedule2'],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
assert result == expected
|
|
||||||
# Ensure non-scouter pipelines are not included
|
def test_gather_read_tags_without_read_tags():
|
||||||
assert '2:test_tag_address_predictions' not in result
|
pipeline = _pipeline('sin', 'm1', [])
|
||||||
assert '3:test_tag_address_retrain' not in result
|
del pipeline['read_tags']
|
||||||
assert '4:test_tag_address_pi_web_api' not in result
|
|
||||||
assert '5:test_tag_address_drift' not in result
|
assert _gather([pipeline]) == (
|
||||||
|
[],
|
||||||
|
{'tags': set(), 'connectors': set(), 'servers': set(), 'invalid': set()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_build_tag_config():
|
def test_build_tag_config():
|
||||||
tags = [
|
"""Slot v2 (contract C1): no certificate paths, subscription period per server"""
|
||||||
{
|
pipelines = [
|
||||||
'server_id': '1',
|
_pipeline(
|
||||||
'server_name': 'test_server_name',
|
'sin',
|
||||||
'tag_address': 'test_tag_address',
|
'm1',
|
||||||
'frequency': 1000,
|
[
|
||||||
},
|
_legacy_tag('ns=2;s=A', 'a', frequency=1000),
|
||||||
{
|
_legacy_tag('ns=2;s=B', 'b', frequency='300'),
|
||||||
'server_id': '2',
|
_legacy_tag('ns=2;s=C', 'c', frequency=200, server_id='2'),
|
||||||
'server_name': 'test_server_name2',
|
{'tag_id': 7, 'frequency': 100},
|
||||||
'tag_address': 'test_tag_address2',
|
],
|
||||||
'frequency': 1000,
|
)
|
||||||
},
|
|
||||||
{
|
|
||||||
'server_id': '2',
|
|
||||||
'server_name': 'test_server_name2',
|
|
||||||
'tag_address': 'test_tag_address3',
|
|
||||||
'frequency': 300,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'server_id': '3',
|
|
||||||
'server_name': 'test_server_name3',
|
|
||||||
'tag_address': 'test_tag_address4',
|
|
||||||
'frequency': 200,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
entries, _issues = _gather(pipelines)
|
||||||
|
|
||||||
opc_servers = {
|
result = build_tag_config(entries)
|
||||||
'1': {
|
|
||||||
'server_name': 'test_server_name',
|
|
||||||
'url': 'test_url',
|
|
||||||
'uri': 'test_uri',
|
|
||||||
},
|
|
||||||
'2': {
|
|
||||||
'server_name': 'test_server_name2',
|
|
||||||
'url': 'test_url2',
|
|
||||||
'uri': 'test_uri2',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result = build_tag_config(tags, opc_servers)
|
assert result == {
|
||||||
|
'kepserver': {
|
||||||
expected = {
|
|
||||||
'test_server_name': {
|
|
||||||
'server_id': '1',
|
'server_id': '1',
|
||||||
'name': 'test_server_name',
|
'name': 'kepserver',
|
||||||
'url': 'test_url',
|
'url': 'opc.tcp://kep:49320',
|
||||||
'server_uri': 'test_uri',
|
'server_uri': 'urn:kep',
|
||||||
'cert_path': None,
|
'use_security': True,
|
||||||
'private_key_path': None,
|
|
||||||
'server_cert_path': None,
|
|
||||||
'subscription_period_ms': 500,
|
|
||||||
'tags': {
|
|
||||||
'test_tag_address': {
|
|
||||||
'server_id': '1',
|
|
||||||
'server_name': 'test_server_name',
|
|
||||||
'tag_address': 'test_tag_address',
|
|
||||||
'frequency': 1000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'test_server_name2': {
|
|
||||||
'server_id': '2',
|
|
||||||
'name': 'test_server_name2',
|
|
||||||
'url': 'test_url2',
|
|
||||||
'server_uri': 'test_uri2',
|
|
||||||
'cert_path': None,
|
|
||||||
'private_key_path': None,
|
|
||||||
'server_cert_path': None,
|
|
||||||
'subscription_period_ms': 150,
|
'subscription_period_ms': 150,
|
||||||
'tags': {
|
'tags': {
|
||||||
'test_tag_address2': {
|
'ns=2;s=A': {
|
||||||
'server_id': '2',
|
|
||||||
'server_name': 'test_server_name2',
|
|
||||||
'tag_address': 'test_tag_address2',
|
|
||||||
'frequency': 1000,
|
'frequency': 1000,
|
||||||
|
'active_read': {'enabled': False},
|
||||||
|
'names': [_name('sin', 'a', 'm1')],
|
||||||
},
|
},
|
||||||
'test_tag_address3': {
|
'ns=2;s=B': {
|
||||||
'server_id': '2',
|
|
||||||
'server_name': 'test_server_name2',
|
|
||||||
'tag_address': 'test_tag_address3',
|
|
||||||
'frequency': 300,
|
'frequency': 300,
|
||||||
|
'active_read': {'enabled': False},
|
||||||
|
'names': [_name('sin', 'b', 'm1')],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'simulator': {
|
||||||
|
'server_id': '2',
|
||||||
|
'name': 'simulator',
|
||||||
|
'url': 'opc.tcp://sim:4840',
|
||||||
|
'server_uri': 'urn:sim',
|
||||||
|
'use_security': False,
|
||||||
|
'subscription_period_ms': 100,
|
||||||
|
'tags': {
|
||||||
|
'ns=2;s=C': {
|
||||||
|
'frequency': 200,
|
||||||
|
'active_read': {'enabled': False},
|
||||||
|
'names': [_name('sin', 'c', 'm1')],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'plant': {
|
||||||
|
'server_id': '3',
|
||||||
|
'name': 'plant',
|
||||||
|
'url': 'opc.tcp://plant:4840',
|
||||||
|
'server_uri': 'urn:plant',
|
||||||
|
'use_security': True,
|
||||||
|
'subscription_period_ms': 1500,
|
||||||
|
'tags': {
|
||||||
|
'ns=3;i=1001': {
|
||||||
|
'frequency': 100,
|
||||||
|
'active_read': {'enabled': False},
|
||||||
|
'names': [_name('sin', 'Pressure', 'm1')],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
assert result == (expected, ['3'])
|
|
||||||
|
def test_build_tag_config_empty():
|
||||||
|
assert build_tag_config([]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_scouter_skips_tags_without_name():
|
||||||
|
"""A tag_id the tag database cannot resolve has no name and stays out of model_tags"""
|
||||||
|
config = {
|
||||||
|
'workflow_type': 'scouter',
|
||||||
|
'schedule_name': 'sin',
|
||||||
|
'model_id': 'm1',
|
||||||
|
'model': {'name': 'model m1'},
|
||||||
|
'read_tags': [{'tag_name': 'a'}, {'tag_id': 404}],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert list(scouter(config)['model_tags']) == ['a']
|
||||||
|
|
||||||
|
|
||||||
|
def test_referenced_tag_ids():
|
||||||
|
"""Ids in int and text form, scouter pipelines only, empty or null tag_id ignored"""
|
||||||
|
pipelines = [
|
||||||
|
_pipeline('sin', 'm1', [{'tag_id': 7}, {'tag_id': '12'}, {'tag_id': ''}, {'tag_id': None}]),
|
||||||
|
_pipeline('cos', 'm2', [{'tag_id': 'abc'}, _legacy_tag('ns=2;s=A', 'a')]),
|
||||||
|
_pipeline('pred', 'm3', [{'tag_id': 99}], workflow_type='predictions_batch'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sorted by text (ints first), so the query is the same on every run
|
||||||
|
assert referenced_tag_ids(pipelines) == [12, 7, '12', '7', 'abc']
|
||||||
|
|
||||||
|
|
||||||
|
def test_referenced_tag_ids_without_tag_ids():
|
||||||
|
pipeline = _pipeline('sin', 'm1', [_legacy_tag('ns=2;s=A', 'a')])
|
||||||
|
no_read_tags = _pipeline('cos', 'm2', [])
|
||||||
|
del no_read_tags['read_tags']
|
||||||
|
|
||||||
|
assert referenced_tag_ids([pipeline, no_read_tags]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_by_tag_ids():
|
||||||
|
"""A tag_id $match goes in front, on a new aggregation list"""
|
||||||
|
query = {'collection': 'connector_tags', 'aggregation': [{'$project': {'_id': 0}}]}
|
||||||
|
|
||||||
|
result = filter_by_tag_ids(query, [7, '7'])
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
'collection': 'connector_tags',
|
||||||
|
'aggregation': [{'$match': {'tag_id': {'$in': [7, '7']}}}, {'$project': {'_id': 0}}],
|
||||||
|
}
|
||||||
|
assert result['aggregation'] is not query['aggregation']
|
||||||
|
assert query['aggregation'] == [{'$project': {'_id': 0}}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_skips_incomplete_documents():
|
||||||
|
"""A malformed tag database document is left out instead of failing the run"""
|
||||||
|
tags = [
|
||||||
|
{'tag_id': 7, 'connector_id': 3, 'address': 'ns=3;i=1', 'tag_name': 'a'},
|
||||||
|
{'connector_id': 3, 'address': 'ns=3;i=2', 'tag_name': 'no id'},
|
||||||
|
{'tag_id': 9, 'connector_id': 3, 'address': '', 'tag_name': 'no address'},
|
||||||
|
]
|
||||||
|
connector_list = [
|
||||||
|
{'connector_id': 3, 'name': 'plant', 'url': 'opc.tcp://p:4840', 'server_uri': 'urn:p'},
|
||||||
|
{'connector_id': 4, 'name': 'no url', 'server_uri': 'urn:x'},
|
||||||
|
{'name': 'no id', 'url': 'opc.tcp://x:4840', 'server_uri': 'urn:x'},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert list(index_connector_tags(tags)) == ['7']
|
||||||
|
assert list(index_connectors(connector_list)) == ['3']
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_empty_tag_id_keeps_legacy_fields():
|
||||||
|
"""tag_id '' is no tag database reference: the current fields are used (P4)"""
|
||||||
|
entries, issues = _gather([_pipeline('sin', 'm1', [_legacy_tag('ns=2;s=A', 'a', tag_id='')])])
|
||||||
|
|
||||||
|
assert [(e['server']['server_id'], e['address']) for e in entries] == [('1', 'ns=2;s=A')]
|
||||||
|
assert issues == {'tags': set(), 'connectors': set(), 'servers': set(), 'invalid': set()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_invalid_read_tags():
|
||||||
|
"""Unusable read tags are reported and skipped; the others still make the slot"""
|
||||||
|
pipelines = [
|
||||||
|
_pipeline(
|
||||||
|
'sin',
|
||||||
|
'm1',
|
||||||
|
[
|
||||||
|
_legacy_tag('ns=2;s=A', 'a'),
|
||||||
|
{'server_id': '1', 'tag_address': 'ns=2;s=B', 'tag_name': 'b'},
|
||||||
|
_legacy_tag('ns=2;s=C', 'c', frequency='fast'),
|
||||||
|
{'server_id': '1', 'tag_address': 'ns=2;s=D', 'frequency': 1000},
|
||||||
|
{'tag_address': 'ns=2;s=E', 'tag_name': 'e', 'frequency': 1000},
|
||||||
|
{'tag_id': 7},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
entries, issues = _gather(pipelines)
|
||||||
|
|
||||||
|
assert [e['address'] for e in entries] == ['ns=2;s=A']
|
||||||
|
assert issues['invalid'] == {
|
||||||
|
'sin/7',
|
||||||
|
'sin/ns=2;s=B',
|
||||||
|
'sin/ns=2;s=C',
|
||||||
|
'sin/ns=2;s=D',
|
||||||
|
'sin/ns=2;s=E',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_frequency_type_does_not_depend_on_order():
|
||||||
|
"""15000 and '15000' give the same int frequency in any pipeline order (O6)"""
|
||||||
|
as_int = _pipeline('sin', 'm1', [_legacy_tag('ns=2;s=A', 'a', frequency=15000)])
|
||||||
|
as_text = _pipeline('cos', 'm2', [_legacy_tag('ns=2;s=A', 'a', frequency='15000')])
|
||||||
|
|
||||||
|
first, _ = _gather([as_int, as_text])
|
||||||
|
second, _ = _gather([as_text, as_int])
|
||||||
|
|
||||||
|
assert first[0]['frequency'] == second[0]['frequency'] == 15000
|
||||||
|
assert isinstance(first[0]['frequency'], int)
|
||||||
|
assert isinstance(second[0]['frequency'], int)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_read_tags_server_key_never_collides():
|
||||||
|
"""A server literally named 'x:3' does not share its key with server 3"""
|
||||||
|
servers = {
|
||||||
|
'1': {**opc_servers['1'], 'server_name': 'x:3'},
|
||||||
|
'2': {**opc_servers['2'], 'server_name': 'x'},
|
||||||
|
'3': {'id': '3', 'server_name': 'x', 'url': 'opc.tcp://three:4840', 'uri': 'urn:3'},
|
||||||
|
}
|
||||||
|
pipelines = [
|
||||||
|
_pipeline(
|
||||||
|
'sin',
|
||||||
|
'm1',
|
||||||
|
[
|
||||||
|
_legacy_tag('ns=2;s=A', 'a', server_id='1'),
|
||||||
|
_legacy_tag('ns=2;s=B', 'b', server_id='2'),
|
||||||
|
_legacy_tag('ns=2;s=C', 'c', server_id='3'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
entries, _issues = _gather(pipelines, servers=servers)
|
||||||
|
slot = build_tag_config(entries)
|
||||||
|
|
||||||
|
keys = [e['server']['key'] for e in entries]
|
||||||
|
assert len(set(keys)) == 3
|
||||||
|
assert {server['server_id'] for server in slot.values()} == {'1', '2', '3'}
|
||||||
|
assert all(len(server['tags']) == 1 for server in slot.values())
|
||||||
|
|
||||||
|
|
||||||
def test_base_scouter():
|
def test_base_scouter():
|
||||||
|
|||||||
@@ -237,6 +237,65 @@ def test_compute_query_time_filter_model_ignored_by_only_one_group_not_excluded(
|
|||||||
assert 'model_id' not in result
|
assert 'model_id' not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_query_time_filter_model_ids_union_when_all_groups_restrict(
|
||||||
|
load_notification_package,
|
||||||
|
):
|
||||||
|
# SIENTIAPDE-2149: every active group sets a model_ids allow-list -> the
|
||||||
|
# union applies at query time, mirroring how `levels` already behaves.
|
||||||
|
sending_configs = [
|
||||||
|
{'group_name': 'group_a', 'model_ids': ['model_a']},
|
||||||
|
{'group_name': 'group_b', 'model_ids': ['model_b']},
|
||||||
|
]
|
||||||
|
default_filter = {}
|
||||||
|
|
||||||
|
result = load_notification_package._compute_query_time_filter(sending_configs, default_filter)
|
||||||
|
|
||||||
|
assert result == {'model_id': {'$in': ['model_a', 'model_b']}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_query_time_filter_model_ids_no_restriction_when_any_group_missing(
|
||||||
|
load_notification_package,
|
||||||
|
):
|
||||||
|
# One active group has no model_ids restriction -> it wants every model,
|
||||||
|
# so the query can't restrict to an allow-list at all.
|
||||||
|
sending_configs = [
|
||||||
|
{'group_name': 'group_a', 'model_ids': ['model_a']},
|
||||||
|
{'group_name': 'group_b'},
|
||||||
|
]
|
||||||
|
default_filter = {}
|
||||||
|
|
||||||
|
result = load_notification_package._compute_query_time_filter(sending_configs, default_filter)
|
||||||
|
|
||||||
|
assert 'model_id' not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_query_time_filter_model_ids_union_and_ignore_models_id_intersection_together(
|
||||||
|
load_notification_package,
|
||||||
|
):
|
||||||
|
# SIENTIAPDE-2149: the allow-list union ($in) and the ignore-list
|
||||||
|
# intersection ($nin) are independent axes and can both narrow the same
|
||||||
|
# model_id field at once.
|
||||||
|
sending_configs = [
|
||||||
|
{
|
||||||
|
'group_name': 'group_a',
|
||||||
|
'model_ids': ['model_a', 'model_b'],
|
||||||
|
'ignore_models_id': ['model_a'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'group_name': 'group_b',
|
||||||
|
'model_ids': ['model_b', 'model_c'],
|
||||||
|
'ignore_models_id': ['model_a'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
default_filter = {}
|
||||||
|
|
||||||
|
result = load_notification_package._compute_query_time_filter(sending_configs, default_filter)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
'model_id': {'$in': ['model_a', 'model_b', 'model_c'], '$nin': ['model_a']},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_compute_query_time_filter_union_levels_and_intersection_models(
|
def test_compute_query_time_filter_union_levels_and_intersection_models(
|
||||||
load_notification_package,
|
load_notification_package,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
from unittest.mock import ANY, AsyncMock, call, patch
|
from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
|
||||||
|
|
||||||
from pytest import fixture, mark
|
from pytest import fixture, mark
|
||||||
|
|
||||||
from orchestrator.activities.activities import Activities
|
from orchestrator.activities.activities import Activities
|
||||||
from orchestrator.workflows.orchestrator import Orchestrator
|
from orchestrator.workflows.orchestrator import (
|
||||||
|
DEFAULT_CONNECTOR_TAGS_QUERY,
|
||||||
|
DEFAULT_CONNECTORS_QUERY,
|
||||||
|
TAG_DATABASE_PATCH,
|
||||||
|
Orchestrator,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@fixture
|
@fixture
|
||||||
@@ -22,8 +27,10 @@ metadata = {
|
|||||||
|
|
||||||
|
|
||||||
@mark.asyncio
|
@mark.asyncio
|
||||||
|
@patch('orchestrator.workflows.orchestrator.referenced_tag_ids', return_value=[7, '7'])
|
||||||
@patch('orchestrator.workflows.orchestrator.workflow', new_callable=AsyncMock)
|
@patch('orchestrator.workflows.orchestrator.workflow', new_callable=AsyncMock)
|
||||||
async def test_run(workflow_mock, orchestrator):
|
async def test_run(workflow_mock, referenced_tag_ids_mock, orchestrator):
|
||||||
|
workflow_mock.patched = MagicMock(return_value=True)
|
||||||
input_data = {
|
input_data = {
|
||||||
'pipelines_query': 'SELECT * FROM bucket',
|
'pipelines_query': 'SELECT * FROM bucket',
|
||||||
'opc_servers_query': 'SELECT * FROM servers',
|
'opc_servers_query': 'SELECT * FROM servers',
|
||||||
@@ -109,11 +116,54 @@ async def test_run(workflow_mock, orchestrator):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
workflow_mock.patched.assert_called_once_with(TAG_DATABASE_PATCH)
|
||||||
|
referenced_tag_ids_mock.assert_called_once_with(
|
||||||
|
workflow_mock.start_local_activity_method.return_value
|
||||||
|
)
|
||||||
|
|
||||||
|
workflow_mock.start_local_activity_method.assert_has_calls(
|
||||||
|
[
|
||||||
|
call(
|
||||||
|
Activities.aggregate_documents_in_mongodb,
|
||||||
|
{**metadata, 'query': DEFAULT_CONNECTORS_QUERY},
|
||||||
|
retry_policy=ANY,
|
||||||
|
start_to_close_timeout=ANY,
|
||||||
|
),
|
||||||
|
call(
|
||||||
|
Activities.aggregate_documents_in_mongodb,
|
||||||
|
{
|
||||||
|
**metadata,
|
||||||
|
'query': {
|
||||||
|
**DEFAULT_CONNECTOR_TAGS_QUERY,
|
||||||
|
'aggregation': [
|
||||||
|
{'$match': {'tag_id': {'$in': [7, '7']}}},
|
||||||
|
*DEFAULT_CONNECTOR_TAGS_QUERY['aggregation'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
retry_policy=ANY,
|
||||||
|
start_to_close_timeout=ANY,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# The activity appends a stage to the aggregation it gets: never the module default
|
||||||
|
tag_database_calls = _tag_database_calls(workflow_mock)
|
||||||
|
assert len(tag_database_calls) == 2
|
||||||
|
for c in tag_database_calls:
|
||||||
|
aggregation = c.args[1]['query']['aggregation']
|
||||||
|
assert aggregation is not DEFAULT_CONNECTORS_QUERY['aggregation']
|
||||||
|
assert aggregation is not DEFAULT_CONNECTOR_TAGS_QUERY['aggregation']
|
||||||
|
|
||||||
workflow_mock.start_local_activity_method.assert_has_calls(
|
workflow_mock.start_local_activity_method.assert_has_calls(
|
||||||
[
|
[
|
||||||
call(
|
call(
|
||||||
Activities.process_schedules,
|
Activities.process_schedules,
|
||||||
{**metadata, 'pipelines': workflow_mock.start_local_activity_method.return_value},
|
{
|
||||||
|
**metadata,
|
||||||
|
'pipelines': workflow_mock.start_local_activity_method.return_value,
|
||||||
|
'connector_tags': workflow_mock.start_local_activity_method.return_value,
|
||||||
|
},
|
||||||
retry_policy=ANY,
|
retry_policy=ANY,
|
||||||
start_to_close_timeout=ANY,
|
start_to_close_timeout=ANY,
|
||||||
)
|
)
|
||||||
@@ -127,6 +177,8 @@ async def test_run(workflow_mock, orchestrator):
|
|||||||
{
|
{
|
||||||
**metadata,
|
**metadata,
|
||||||
'opc_servers': workflow_mock.start_local_activity_method.return_value,
|
'opc_servers': workflow_mock.start_local_activity_method.return_value,
|
||||||
|
'connectors': workflow_mock.start_local_activity_method.return_value,
|
||||||
|
'connector_tags': workflow_mock.start_local_activity_method.return_value,
|
||||||
'active_ingestors': workflow_mock.start_local_activity_method.return_value,
|
'active_ingestors': workflow_mock.start_local_activity_method.return_value,
|
||||||
'pipelines': workflow_mock.start_local_activity_method.return_value,
|
'pipelines': workflow_mock.start_local_activity_method.return_value,
|
||||||
},
|
},
|
||||||
@@ -346,3 +398,96 @@ async def test_run(workflow_mock, orchestrator):
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_database_calls(workflow_mock):
|
||||||
|
return [
|
||||||
|
c
|
||||||
|
for c in workflow_mock.start_local_activity_method.call_args_list
|
||||||
|
if c.args[0] is Activities.aggregate_documents_in_mongodb
|
||||||
|
and isinstance(c.args[1]['query'], dict)
|
||||||
|
and c.args[1]['query'].get('collection') in ('connectors', 'connector_tags')
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _process_slots_input(workflow_mock):
|
||||||
|
return next(
|
||||||
|
c.args[1]
|
||||||
|
for c in workflow_mock.start_local_activity_method.call_args_list
|
||||||
|
if c.args[0] is Activities.process_slots
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
@patch('orchestrator.workflows.orchestrator.referenced_tag_ids', return_value=[7])
|
||||||
|
@patch('orchestrator.workflows.orchestrator.workflow', new_callable=AsyncMock)
|
||||||
|
async def test_run_with_tag_database_queries_from_input(workflow_mock, _ids, orchestrator):
|
||||||
|
"""The input overrides the default tag database aggregations (SIENTIAPDE-1974, D9)"""
|
||||||
|
workflow_mock.patched = MagicMock(return_value=True)
|
||||||
|
connectors_query = {'collection': 'connectors', 'aggregation': [{'$match': {'type': 'opc_ua'}}]}
|
||||||
|
connector_tags_query = {
|
||||||
|
'collection': 'connector_tags',
|
||||||
|
'aggregation': [{'$match': {'category': 'Read'}}],
|
||||||
|
}
|
||||||
|
input_data = {
|
||||||
|
'pipelines_query': 'SELECT * FROM bucket',
|
||||||
|
'opc_servers_query': 'SELECT * FROM servers',
|
||||||
|
'connectors_query': connectors_query,
|
||||||
|
'connector_tags_query': connector_tags_query,
|
||||||
|
'schedule_name': 'test-schedule-name',
|
||||||
|
}
|
||||||
|
|
||||||
|
await orchestrator.run(input_data)
|
||||||
|
|
||||||
|
queries = [c.args[1]['query'] for c in _tag_database_calls(workflow_mock)]
|
||||||
|
assert queries == [
|
||||||
|
connectors_query,
|
||||||
|
{
|
||||||
|
'collection': 'connector_tags',
|
||||||
|
'aggregation': [
|
||||||
|
{'$match': {'tag_id': {'$in': [7]}}},
|
||||||
|
{'$match': {'category': 'Read'}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
@patch('orchestrator.workflows.orchestrator.referenced_tag_ids', return_value=[])
|
||||||
|
@patch('orchestrator.workflows.orchestrator.workflow', new_callable=AsyncMock)
|
||||||
|
async def test_run_without_tag_ids_skips_tag_database(workflow_mock, _ids, orchestrator):
|
||||||
|
"""No read tag with tag_id: the tag database is not queried at all (P4)"""
|
||||||
|
workflow_mock.patched = MagicMock(return_value=True)
|
||||||
|
input_data = {
|
||||||
|
'pipelines_query': 'SELECT * FROM bucket',
|
||||||
|
'opc_servers_query': 'SELECT * FROM servers',
|
||||||
|
'schedule_name': 'test-schedule-name',
|
||||||
|
}
|
||||||
|
|
||||||
|
await orchestrator.run(input_data)
|
||||||
|
|
||||||
|
workflow_mock.patched.assert_called_once_with(TAG_DATABASE_PATCH)
|
||||||
|
assert _tag_database_calls(workflow_mock) == []
|
||||||
|
slots_input = _process_slots_input(workflow_mock)
|
||||||
|
assert slots_input['connectors'] == []
|
||||||
|
assert slots_input['connector_tags'] == []
|
||||||
|
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
@patch('orchestrator.workflows.orchestrator.referenced_tag_ids', return_value=[7])
|
||||||
|
@patch('orchestrator.workflows.orchestrator.workflow', new_callable=AsyncMock)
|
||||||
|
async def test_run_replay_before_tag_database_patch(workflow_mock, _ids, orchestrator):
|
||||||
|
"""A run started before the patch replays without the tag database loads"""
|
||||||
|
workflow_mock.patched = MagicMock(return_value=False)
|
||||||
|
input_data = {
|
||||||
|
'pipelines_query': 'SELECT * FROM bucket',
|
||||||
|
'opc_servers_query': 'SELECT * FROM servers',
|
||||||
|
'schedule_name': 'test-schedule-name',
|
||||||
|
}
|
||||||
|
|
||||||
|
await orchestrator.run(input_data)
|
||||||
|
|
||||||
|
assert _tag_database_calls(workflow_mock) == []
|
||||||
|
slots_input = _process_slots_input(workflow_mock)
|
||||||
|
assert slots_input['connectors'] == []
|
||||||
|
assert slots_input['connector_tags'] == []
|
||||||
|
|||||||
Reference in New Issue
Block a user