Compare commits
12 Commits
fix/SIENTI
...
fix/SIENTI
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed666c260d | ||
|
|
14b854148a | ||
|
|
1711f3b0d1 | ||
| ff7b441140 | |||
|
|
d12c89e1c5 | ||
|
|
b933783e59 | ||
| d8ece5ea3c | |||
| 30fe5ff69c | |||
|
|
0c63b68cdd | ||
| bfbc71bbfb | |||
| cae514ebae | |||
|
|
3c69b35b3f |
29
.github/workflows/deploy.yml
vendored
Normal file
29
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Deploy DataOps Worker
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [main, 'release/**', 'feature/**']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true
|
||||
uses: Aignosi/github_workflow_templates/.github/workflows/reusable-deploy-dataops.yml@main
|
||||
with:
|
||||
branch_name: ${{ github.event.pull_request.head.ref }}
|
||||
project_name: 'orchestrator'
|
||||
chart_name: 'orchestrator-worker'
|
||||
env_list_path: '[".env"]'
|
||||
update_version_in: '["pyproject"]'
|
||||
create_tarball: ${{ github.event_name == 'pull_request' }}
|
||||
mirror: true
|
||||
bump_chart: true
|
||||
deploy: true
|
||||
secrets: inherit
|
||||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@@ -1,16 +0,0 @@
|
||||
name: Create Release on Merge to Main
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.event.pull_request.merged == true
|
||||
uses: Aignosi/github_workflow_templates/.github/workflows/dataops-module-release.yml@main
|
||||
permissions: write-all
|
||||
with:
|
||||
project_name: 'orchestrator'
|
||||
secrets: inherit
|
||||
16
.github/workflows/require-chart-ref-label.yml
vendored
Normal file
16
.github/workflows/require-chart-ref-label.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: Require chart-ref label
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
branches:
|
||||
- main
|
||||
- 'release/**'
|
||||
- 'feature/**'
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
require-chart-ref-label:
|
||||
uses: Aignosi/github_workflow_templates/.github/workflows/require-chart-ref-label.yml@main
|
||||
@@ -29,7 +29,6 @@ MONGO_COLLECTIONS = (
|
||||
)
|
||||
|
||||
DATETIME_FORMAT_MS_WITH_TZ = '%Y-%m-%d %H:%M:%S.%f%z'
|
||||
DATETIME_FORMAT_WITH_TZ = '%Y-%m-%d %H:%M:%S%z'
|
||||
|
||||
_TIMESTAMP_MARKER_PATTERN = re.compile(r'^@now(?:([+-])(\d+)([smhd]))?$')
|
||||
_TIMESTAMP_UNIT_TO_KWARG = {'s': 'seconds', 'm': 'minutes', 'h': 'hours', 'd': 'days'}
|
||||
@@ -222,17 +221,19 @@ def seed_last_timestamp(redis_client: Redis, mail_type: str, value: str) -> None
|
||||
Set notification_last_timestamp for a mail type, JSON-encoded.
|
||||
|
||||
Values must be JSON-encoded so ``redis_repository.get`` (which calls
|
||||
``json.loads`` on the raw payload) can deserialize them. The value
|
||||
must follow the exact format ``sientia_do.notifications.models.Notification``
|
||||
writes into ``notification_queue.timestamp``: ``DATETIME_FORMAT_WITH_TZ``
|
||||
(e.g. ``"2026-05-22 16:47:02+0000"``) — no microseconds and no colon in
|
||||
the timezone offset.
|
||||
``json.loads`` on the raw payload) can deserialize them. The value must
|
||||
follow the exact format ``put_last_data_timestamp``
|
||||
(``orchestrator/activities/slot_manager.py``) writes into Redis today:
|
||||
``DATETIME_FORMAT_MS_WITH_TZ`` (e.g. ``"2026-05-22 16:47:02.000000+0000"``)
|
||||
— microseconds included, no colon in the timezone offset. ``load_latest_data``
|
||||
parses this same format back into a ``datetime`` for the ``$gt`` comparison,
|
||||
so the two sides must agree exactly.
|
||||
|
||||
Args:
|
||||
redis_client: Redis client connected to the test instance.
|
||||
mail_type: Mail type identifier (e.g. ``"Alerts"``, ``"Reports"``).
|
||||
value: Timestamp string in ``DATETIME_FORMAT_WITH_TZ``
|
||||
(e.g. ``"2024-06-01 10:30:00+0000"``).
|
||||
value: Timestamp string in ``DATETIME_FORMAT_MS_WITH_TZ``
|
||||
(e.g. ``"2024-06-01 10:30:00.000000+0000"``).
|
||||
"""
|
||||
redis_client.set(f'notification_last_timestamp:{mail_type}', json.dumps(value))
|
||||
|
||||
@@ -295,34 +296,35 @@ def default_notification(
|
||||
*,
|
||||
notification_id: str,
|
||||
level: str = 'ERROR',
|
||||
timestamp: str | None = None,
|
||||
timestamp: datetime | None = None,
|
||||
model_name: str = 'model-a',
|
||||
model_id: str = '1',
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a minimal notification_queue document mirroring production layout.
|
||||
|
||||
The ``timestamp`` field is stored as a string in ``DATETIME_FORMAT_WITH_TZ``
|
||||
because that is exactly what ``sientia_do.notifications.models.Notification``
|
||||
writes into ``notification_queue`` in production (``now().strftime(
|
||||
DATETIME_FORMAT_WITH_TZ)``). Tests intentionally use this same format so we
|
||||
The ``timestamp`` field is stored as a native, timezone-aware ``datetime``
|
||||
(BSON Date) because that is exactly what
|
||||
``sientia_do.notifications.models.Notification`` writes into
|
||||
``notification_queue`` in production since SIENTIAPDE-2007
|
||||
(``self.timestamp = now()`` — a real ``datetime``, no more string
|
||||
formatting). Tests intentionally use this same representation so we
|
||||
surface, rather than hide, real production behavior in downstream
|
||||
activities.
|
||||
activities (e.g. ``put_last_data_timestamp``'s ``DataFrame(...)['timestamp']
|
||||
.max().strftime(...)``, which requires a real ``datetime``, not a string).
|
||||
|
||||
Args:
|
||||
notification_id: Unique identifier for the notification.
|
||||
level: Notification level (e.g. ``"ERROR"``, ``"WARNING"``).
|
||||
timestamp: Optional production-format timestamp string. ``None`` falls
|
||||
back to a fixed sample value.
|
||||
timestamp: Optional timezone-aware datetime. ``None`` falls back to a
|
||||
fixed sample value.
|
||||
model_name: Model name attached to the notification.
|
||||
model_id: Model id attached to the notification.
|
||||
|
||||
Return:
|
||||
dict[str, Any]: A notification document ready for insertion.
|
||||
"""
|
||||
ts = timestamp if timestamp is not None else datetime(
|
||||
2024, 6, 1, 12, 0, 0, tzinfo=UTC
|
||||
).strftime(DATETIME_FORMAT_WITH_TZ)
|
||||
ts = timestamp if timestamp is not None else datetime(2024, 6, 1, 12, 0, 0, tzinfo=UTC)
|
||||
return {
|
||||
'notification_id': notification_id,
|
||||
'level': level,
|
||||
@@ -343,7 +345,7 @@ def default_receiver_group(
|
||||
members: list[str] | None = None,
|
||||
levels: list[str] | None = None,
|
||||
contents: list[str] | None = None,
|
||||
ignore_models: list[str] | None = None,
|
||||
ignore_models_id: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Minimal active receiver_groups document."""
|
||||
return {
|
||||
@@ -352,5 +354,5 @@ def default_receiver_group(
|
||||
'members': members or ['admin@example.com'],
|
||||
'levels': levels or ['ERROR', 'WARNING', 'INFO'],
|
||||
'contents': contents or ['core_alerts', 'persistent_alerts', 'reports'],
|
||||
'ignore_models': ignore_models or [],
|
||||
'ignore_models_id': ignore_models_id or [],
|
||||
}
|
||||
|
||||
@@ -71,7 +71,12 @@ Alert past `notification_ttl` with cache cleared → new email sent.
|
||||
|
||||
### A.2.1 Group filtering
|
||||
|
||||
Receiver group `levels` / `ignore_models` honored.
|
||||
Receiver group `levels` / `ignore_models_id` honored. The query-time half
|
||||
(union of `levels`, intersection of `ignore_models_id` across active groups)
|
||||
is covered at the subworkflow level — see `LoadNotificationPackage` below.
|
||||
The per-group half (each group only receiving its own precise slice) is not
|
||||
yet implemented in production code (SIENTIAPDE-2012) and so is not yet
|
||||
exercised here.
|
||||
|
||||
### A.3.1 Empty queue
|
||||
|
||||
@@ -103,6 +108,7 @@ No notifications → no SMTP, no Postgres row.
|
||||
|
||||
- No prior Redis timestamp → all matching notifications returned, max timestamp stored.
|
||||
- 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.
|
||||
- Empty Mongo → no Redis timestamp write.
|
||||
|
||||
### ProcessNotifications — `e2e/test_subworkflow_process_notifications.py`
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""E2E tests for the Reports main workflow."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage, Message
|
||||
|
||||
import pytest
|
||||
@@ -79,13 +80,13 @@ async def test_scenario_r_1_1_happy_path(
|
||||
notification_id='r-warn',
|
||||
level='WARNING',
|
||||
model_name='m2',
|
||||
timestamp='2024-06-01 12:01:00+0000',
|
||||
timestamp=datetime(2024, 6, 1, 12, 1, 0, tzinfo=UTC),
|
||||
),
|
||||
default_notification(
|
||||
notification_id='r-info',
|
||||
level='INFO',
|
||||
model_name='m3',
|
||||
timestamp='2024-06-01 12:02:00+0000',
|
||||
timestamp=datetime(2024, 6, 1, 12, 2, 0, tzinfo=UTC),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""E2E tests for LoadNotificationPackage subworkflow."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from redis import Redis
|
||||
from temporalio.testing import WorkflowEnvironment
|
||||
@@ -42,10 +44,12 @@ async def test_load_package_without_prior_timestamp(
|
||||
"""
|
||||
No Redis timestamp → returns notifications and stores max timestamp.
|
||||
|
||||
Notifications are seeded with the exact production format produced by
|
||||
``sientia_do.notifications.models.Notification`` (string in
|
||||
``DATETIME_FORMAT_WITH_TZ``, e.g. ``"2024-06-01 11:00:00+0000"``). The
|
||||
cached "last timestamp" must mirror that representation.
|
||||
Notifications are seeded with the exact production representation written
|
||||
by ``sientia_do.notifications.models.Notification`` since SIENTIAPDE-2007:
|
||||
a native ``datetime`` (BSON Date), not a formatted string. The cached
|
||||
"last timestamp" written back to Redis by ``put_last_data_timestamp`` is a
|
||||
string formatted per ``DATETIME_FORMAT_MS_WITH_TZ`` (microseconds
|
||||
included) — that is the format this test asserts against.
|
||||
"""
|
||||
seed_receiver_groups(mongo_uri, E2E_DATABASE, [default_receiver_group()])
|
||||
seed_notifications(
|
||||
@@ -54,11 +58,11 @@ async def test_load_package_without_prior_timestamp(
|
||||
[
|
||||
default_notification(
|
||||
notification_id='n1',
|
||||
timestamp='2024-06-01 10:00:00+0000',
|
||||
timestamp=datetime(2024, 6, 1, 10, 0, 0, tzinfo=UTC),
|
||||
),
|
||||
default_notification(
|
||||
notification_id='n2',
|
||||
timestamp='2024-06-01 11:00:00+0000',
|
||||
timestamp=datetime(2024, 6, 1, 11, 0, 0, tzinfo=UTC),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -75,7 +79,7 @@ async def test_load_package_without_prior_timestamp(
|
||||
|
||||
assert len(result['notification_package']) == 2
|
||||
stored = redis_client.get('notification_last_timestamp:Alerts') or ''
|
||||
assert stored.strip('"') == '2024-06-01 11:00:00+0000'
|
||||
assert stored.strip('"') == '2024-06-01 11:00:00.000000+0000'
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@@ -90,27 +94,27 @@ async def test_load_package_with_prior_timestamp(
|
||||
"""
|
||||
Existing timestamp → only newer notifications returned.
|
||||
|
||||
Both the seeded "last timestamp" (Redis) and the notification timestamps
|
||||
(Mongo) follow the production format used by
|
||||
``sientia_do.notifications.models.Notification`` (string in
|
||||
``DATETIME_FORMAT_WITH_TZ``). ``load_latest_data`` will translate the
|
||||
Redis value into a Python ``datetime`` and apply ``{$gt: <Date>}``
|
||||
against the Mongo string timestamps; this exercise surfaces the real
|
||||
BSON comparison semantics rather than a synthetic ideal.
|
||||
The seeded "last timestamp" (Redis) follows the format
|
||||
``put_last_data_timestamp`` actually writes (``DATETIME_FORMAT_MS_WITH_TZ``,
|
||||
microseconds included); the notification timestamps (Mongo) are native
|
||||
``datetime`` values, matching current production behavior since
|
||||
SIENTIAPDE-2007. ``load_latest_data`` parses the Redis string back into a
|
||||
``datetime`` and applies ``{$gt: <Date>}`` against the Mongo BSON dates —
|
||||
this exercises the real comparison semantics rather than a synthetic ideal.
|
||||
"""
|
||||
seed_receiver_groups(mongo_uri, E2E_DATABASE, [default_receiver_group()])
|
||||
seed_last_timestamp(redis_client, 'Alerts', '2024-06-01 10:30:00+0000')
|
||||
seed_last_timestamp(redis_client, 'Alerts', '2024-06-01 10:30:00.000000+0000')
|
||||
seed_notifications(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
[
|
||||
default_notification(
|
||||
notification_id='old',
|
||||
timestamp='2024-06-01 10:00:00+0000',
|
||||
timestamp=datetime(2024, 6, 1, 10, 0, 0, tzinfo=UTC),
|
||||
),
|
||||
default_notification(
|
||||
notification_id='new',
|
||||
timestamp='2024-06-01 11:00:00+0000',
|
||||
timestamp=datetime(2024, 6, 1, 11, 0, 0, tzinfo=UTC),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -129,6 +133,78 @@ async def test_load_package_with_prior_timestamp(
|
||||
assert ids == {'new'}
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_package_query_time_filter_union_and_intersection(
|
||||
temporal_env: WorkflowEnvironment,
|
||||
orchestrator_worker,
|
||||
stub_workers,
|
||||
mongo_uri: str,
|
||||
redis_client: Redis,
|
||||
):
|
||||
"""
|
||||
SIENTIAPDE-2011: query-time filter unions active groups' levels and
|
||||
intersects their ignore_models_id, instead of fetching everything.
|
||||
|
||||
Two active receiver groups narrow the fetch differently:
|
||||
- group_a: levels=['ERROR'], ignore_models_id=['model-x']
|
||||
- group_b: levels=['WARNING'], ignore_models_id=['model-x', 'model-y']
|
||||
|
||||
The Mongo query must return exactly what the UNION of levels allows
|
||||
(ERROR or WARNING, never INFO) and must exclude only models in the
|
||||
INTERSECTION of every active group's ignore list (model-x, ignored by
|
||||
both). A model ignored by only one group (model-y) must still be
|
||||
fetched, since the other group still wants it — per-group correctness
|
||||
for that case is SIENTIAPDE-2012's job, not this query-time filter's.
|
||||
"""
|
||||
seed_receiver_groups(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
[
|
||||
default_receiver_group(
|
||||
group_name='group_a', levels=['ERROR'], ignore_models_id=['model-x']
|
||||
),
|
||||
default_receiver_group(
|
||||
group_name='group_b',
|
||||
levels=['WARNING'],
|
||||
ignore_models_id=['model-x', 'model-y'],
|
||||
),
|
||||
],
|
||||
)
|
||||
seed_notifications(
|
||||
mongo_uri,
|
||||
E2E_DATABASE,
|
||||
[
|
||||
default_notification(
|
||||
notification_id='error-included', level='ERROR', model_id='model-a'
|
||||
),
|
||||
default_notification(
|
||||
notification_id='warning-included', level='WARNING', model_id='model-a'
|
||||
),
|
||||
default_notification(notification_id='info-excluded', level='INFO', model_id='model-a'),
|
||||
default_notification(
|
||||
notification_id='modelx-excluded', level='ERROR', model_id='model-x'
|
||||
),
|
||||
default_notification(
|
||||
notification_id='modely-included', level='WARNING', model_id='model-y'
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
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-filter'),
|
||||
)
|
||||
|
||||
ids = {n['notification_id'] for n in result['notification_package']}
|
||||
assert ids == {'error-included', 'warning-included', 'modely-included'}
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_package_empty_mongo_no_redis_write(
|
||||
|
||||
@@ -453,7 +453,7 @@ class MongoDB(SientiaMonitoring):
|
||||
- metadata (dict[str, Any]): Workflow execution metadata
|
||||
- collection_name (str): Name of the MongoDB collection
|
||||
- last_data_timestamp (str | None): Last processed timestamp
|
||||
(ISO-format string) for filtering
|
||||
(formatted per ``DATETIME_FORMAT_MS_WITH_TZ``) for filtering
|
||||
- base_data_filter (dict[str, Any]): Base query filter conditions
|
||||
|
||||
Returns:
|
||||
@@ -475,12 +475,15 @@ class MongoDB(SientiaMonitoring):
|
||||
else:
|
||||
# ``notification_queue.timestamp`` is stored as a native BSON
|
||||
# ``Date`` (``Notification.timestamp`` is a ``datetime``).
|
||||
# ``last_data_timestamp`` arrives here as an ISO-format string
|
||||
# (round-tripped through Redis), so it must be parsed back to
|
||||
# ``datetime`` for the ``$gt`` comparison to be type-correct.
|
||||
# ``last_data_timestamp`` arrives here as a string formatted per
|
||||
# ``DATETIME_FORMAT_MS_WITH_TZ`` (round-tripped through Redis),
|
||||
# so it must be parsed back to ``datetime`` for the ``$gt``
|
||||
# comparison to be type-correct.
|
||||
data_filter = {
|
||||
**base_data_filter,
|
||||
'timestamp': {'$gt': datetime.fromisoformat(last_data_timestamp)},
|
||||
'timestamp': {
|
||||
'$gt': datetime.strptime(last_data_timestamp, DATETIME_FORMAT_MS_WITH_TZ)
|
||||
},
|
||||
}
|
||||
|
||||
self.debug(f'Data filter: {data_filter}', metadata=metadata)
|
||||
|
||||
@@ -7,7 +7,7 @@ with workflow.unsafe.imports_passed_through():
|
||||
from logging import Logger
|
||||
from typing import Any
|
||||
|
||||
from pandas import DataFrame
|
||||
from pandas import DataFrame, to_datetime
|
||||
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
|
||||
from sientia_do.notifications.models import NotificationLevel
|
||||
from sientia_do.observability.sientia_monitoring import MetricsController, SientiaMonitoring
|
||||
@@ -331,17 +331,33 @@ class SlotManager(SientiaMonitoring):
|
||||
self.warning('No data to insert', metadata=metadata)
|
||||
return None
|
||||
|
||||
last_data_timestamp = data['timestamp'].max()
|
||||
# ``timestamp`` started out as a native ``datetime``/BSON Date in Mongo,
|
||||
# but by the time it gets here it has crossed a Temporal activity
|
||||
# boundary (this activity's ``input_data`` is whatever
|
||||
# ``load_latest_data`` returned): Temporal's default data converter
|
||||
# serializes ``datetime`` values to plain ISO-8601 strings and does not
|
||||
# restore them on decode, so ``data['timestamp']`` is a ``str`` in a
|
||||
# real workflow run even though unit tests mocking the activity in
|
||||
# isolation never see that conversion. pymongo also returns naive
|
||||
# datetimes for BSON Date fields (no tzinfo), so the ISO string may
|
||||
# carry no UTC offset at all. ``utc=True`` normalizes every case
|
||||
# (native datetime/Timestamp, offset-aware or naive ISO string) into a
|
||||
# UTC-aware ``Timestamp`` — naive values are BSON Dates, always UTC.
|
||||
last_data_timestamp = to_datetime(data['timestamp'], utc=True).max()
|
||||
|
||||
self.debug(f'Last collected timestamp to insert: {last_data_timestamp}', metadata=metadata)
|
||||
|
||||
# ``timestamp`` is a native ``datetime``/``Timestamp`` (BSON Date from
|
||||
# Mongo), which the Redis repository's plain ``json.dumps`` cannot
|
||||
# serialize. Store it as an ISO-format string instead.
|
||||
last_data_timestamp = last_data_timestamp.isoformat()
|
||||
# The Redis repository's plain ``json.dumps`` cannot serialize a
|
||||
# ``Timestamp``/``datetime`` directly, so store it as a string
|
||||
# formatted per ``DATETIME_FORMAT_MS_WITH_TZ`` instead. Kept as a
|
||||
# separate variable (rather than reassigning ``last_data_timestamp``)
|
||||
# since it's a different type — mypy infers a local's type from its
|
||||
# first assignment and flags a same-name reassignment to an
|
||||
# incompatible type as an error.
|
||||
last_data_timestamp_str = last_data_timestamp.strftime(DATETIME_FORMAT_MS_WITH_TZ)
|
||||
|
||||
try:
|
||||
self.redis_repository.set(key, last_data_timestamp, ttl=60 * 60 * 5)
|
||||
self.redis_repository.set(key, last_data_timestamp_str, ttl=60 * 60 * 5)
|
||||
except Exception as e:
|
||||
self.send_notification(
|
||||
metadata=metadata,
|
||||
@@ -353,7 +369,7 @@ class SlotManager(SientiaMonitoring):
|
||||
)
|
||||
raise e
|
||||
|
||||
return last_data_timestamp
|
||||
return last_data_timestamp_str
|
||||
|
||||
@activity.defn(name='filter_notification_alerts')
|
||||
def filter_notification_alerts(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -68,18 +68,23 @@ class LoadNotificationPackage:
|
||||
# Load notification package from collection "notification_queue", using a
|
||||
# static filter
|
||||
|
||||
sending_configs = await sending_configs_handler
|
||||
|
||||
base_data_filter = self._compute_query_time_filter(
|
||||
sending_configs, input_data['base_data_filter']
|
||||
)
|
||||
|
||||
notification_package = await workflow.start_local_activity_method(
|
||||
Activities.load_latest_data,
|
||||
{
|
||||
**metadata,
|
||||
'collection_name': 'notification_queue',
|
||||
'last_data_timestamp': last_timestamp,
|
||||
'base_data_filter': input_data['base_data_filter'],
|
||||
'base_data_filter': base_data_filter,
|
||||
},
|
||||
start_to_close_timeout=timedelta(seconds=60),
|
||||
retry_policy=retry_policy,
|
||||
)
|
||||
sending_configs = await sending_configs_handler
|
||||
|
||||
if not sending_configs or not notification_package:
|
||||
return {
|
||||
@@ -106,3 +111,42 @@ class LoadNotificationPackage:
|
||||
'notification_package': notification_package,
|
||||
'sending_configs': sending_configs,
|
||||
}
|
||||
|
||||
def _compute_query_time_filter(
|
||||
self, sending_configs: list[dict[str, Any]], default_filter: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Compute the Mongo query-time filter from all active receiver groups.
|
||||
|
||||
- levels: union of every group's `levels`. If ANY active group has no
|
||||
restriction (missing/empty `levels`), that group wants every level, so
|
||||
the query can't restrict levels at all -> falls back to whatever level
|
||||
restriction `default_filter` itself carries (e.g. ERROR-only for Alerts).
|
||||
- ignore_models_id: intersection of every group's `ignore_models_id`. A model
|
||||
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
|
||||
set), which naturally zeroes out the intersection via set math.
|
||||
"""
|
||||
if not sending_configs:
|
||||
return default_filter
|
||||
|
||||
sending_configs_levels = [group.get('levels') for group in sending_configs]
|
||||
|
||||
if all(sending_configs_levels):
|
||||
levels_union: set[str] = set()
|
||||
for levels in sending_configs_levels:
|
||||
if levels:
|
||||
levels_union.update(levels)
|
||||
query_filter = {**default_filter, 'level': {'$in': sorted(levels_union)}}
|
||||
else:
|
||||
query_filter = dict(default_filter)
|
||||
|
||||
sending_configs_ignore_models_id = [
|
||||
set(group.get('ignore_models_id', [])) for group in sending_configs
|
||||
]
|
||||
models_intersection = set.intersection(*sending_configs_ignore_models_id)
|
||||
|
||||
if models_intersection:
|
||||
query_filter['model_id'] = {'$nin': sorted(models_intersection)}
|
||||
|
||||
return query_filter
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "orchestrator"
|
||||
version = "0.0.0"
|
||||
version = "0.6.3"
|
||||
description = "Sientia DataOps Orchestrator - ML Model Orchestration System"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -504,7 +504,7 @@ def test_load_latest_data_not_none_last_data_timestamp(mongo_db):
|
||||
{
|
||||
'metadata': {'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule'},
|
||||
'collection_name': 'test_collection',
|
||||
'last_data_timestamp': '2023-01-01T12:00:00+00:00',
|
||||
'last_data_timestamp': '2023-01-01 12:00:00.000000+0000',
|
||||
'base_data_filter': {'level': 'ERROR'},
|
||||
}
|
||||
)
|
||||
@@ -561,7 +561,7 @@ def test_load_latest_data_run_boundary_no_skip_or_duplicate(mongo_db):
|
||||
{
|
||||
'metadata': {'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule'},
|
||||
'collection_name': 'test_collection',
|
||||
'last_data_timestamp': boundary_timestamp.isoformat(),
|
||||
'last_data_timestamp': boundary_timestamp.strftime(DATETIME_FORMAT_MS_WITH_TZ),
|
||||
'base_data_filter': {'level': 'ERROR'},
|
||||
}
|
||||
)
|
||||
@@ -578,7 +578,7 @@ def test_load_latest_data_error(mongo_db):
|
||||
{
|
||||
'metadata': {'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule'},
|
||||
'collection_name': 'test_collection',
|
||||
'last_data_timestamp': '2023-01-01T12:00:00+00:00',
|
||||
'last_data_timestamp': '2023-01-01 12:00:00.000000+0000',
|
||||
'base_data_filter': {'level': 'ERROR'},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -297,10 +297,10 @@ def test_put_last_data_timestamp_not_empty_dataframe(slot_manager):
|
||||
|
||||
result = slot_manager.put_last_data_timestamp(test_data)
|
||||
|
||||
assert result == '2023-01-01T12:00:01+00:00'
|
||||
assert result == '2023-01-01 12:00:01.000000+0000'
|
||||
|
||||
slot_manager.redis_repository.set.assert_called_once_with(
|
||||
'notification_last_timestamp:test_mail_type', '2023-01-01T12:00:01+00:00', ttl=18000
|
||||
'notification_last_timestamp:test_mail_type', '2023-01-01 12:00:01.000000+0000', ttl=18000
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -32,16 +32,19 @@ async def test_run(workflow_mock, load_notification_package):
|
||||
'base_data_filter': {'level': 'ERROR'},
|
||||
}
|
||||
|
||||
# Await order is now: last_timestamp -> sending_configs -> notification_package
|
||||
# (fixed ordering bug: sending_configs must resolve before load_latest_data is
|
||||
# called, so its levels/ignore_models_id can feed the query-time filter).
|
||||
workflow_mock.start_local_activity_method.side_effect = [
|
||||
'2023-01-01 12:00:00',
|
||||
[
|
||||
{
|
||||
'id': '1',
|
||||
'id_r': '1',
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
'id_r': '1',
|
||||
'id': '1',
|
||||
}
|
||||
],
|
||||
]
|
||||
@@ -158,7 +161,9 @@ async def test_run_has_data(workflow_mock, load_notification_package):
|
||||
'mail_type': 'test_mail_type',
|
||||
}
|
||||
|
||||
workflow_mock.start_local_activity_method.side_effect = ['2023-01-01 12:00:00', ['data'], []]
|
||||
# sending_configs (awaited 2nd) is empty here on purpose, to exercise the
|
||||
# early-return branch below regardless of notification_package having data.
|
||||
workflow_mock.start_local_activity_method.side_effect = ['2023-01-01 12:00:00', [], ['data']]
|
||||
|
||||
output = await load_notification_package.run(input_data)
|
||||
|
||||
@@ -169,3 +174,62 @@ async def test_run_has_data(workflow_mock, load_notification_package):
|
||||
}
|
||||
|
||||
workflow_mock.start_activity_method.assert_not_called()
|
||||
|
||||
|
||||
def test_compute_query_time_filter_empty_active_groups(load_notification_package):
|
||||
default_filter = {'level': 'ERROR'}
|
||||
|
||||
result = load_notification_package._compute_query_time_filter([], default_filter)
|
||||
|
||||
assert result == default_filter
|
||||
|
||||
|
||||
def test_compute_query_time_filter_mixed_groups_no_levels_restriction(load_notification_package):
|
||||
# One active group restricts levels/ignore_models_id, the other has neither set
|
||||
# (the common case today) -> that group wants everything, so no levels
|
||||
# restriction can be applied at query time; falls back to default_filter.
|
||||
sending_configs = [
|
||||
{'group_name': 'group_a', 'levels': ['ERROR'], 'ignore_models_id': ['model_a']},
|
||||
{'group_name': 'group_b'},
|
||||
]
|
||||
default_filter = {'level': 'ERROR'}
|
||||
|
||||
result = load_notification_package._compute_query_time_filter(sending_configs, default_filter)
|
||||
|
||||
assert result == {'level': 'ERROR'}
|
||||
|
||||
|
||||
def test_compute_query_time_filter_model_ignored_by_only_one_group_not_excluded(
|
||||
load_notification_package,
|
||||
):
|
||||
# model_a is ignored by group_a but group_b still wants it -> must stay in
|
||||
# the fetch (the per-group filter is what drops it for group_a specifically).
|
||||
sending_configs = [
|
||||
{'group_name': 'group_a', 'levels': ['ERROR'], 'ignore_models_id': ['model_a']},
|
||||
{'group_name': 'group_b', 'levels': ['ERROR'], 'ignore_models_id': []},
|
||||
]
|
||||
default_filter = {}
|
||||
|
||||
result = load_notification_package._compute_query_time_filter(sending_configs, default_filter)
|
||||
|
||||
assert result == {'level': {'$in': ['ERROR']}}
|
||||
assert 'model_id' not in result
|
||||
|
||||
|
||||
def test_compute_query_time_filter_union_levels_and_intersection_models(
|
||||
load_notification_package,
|
||||
):
|
||||
# Every active group restricts levels -> union of levels applies.
|
||||
# model_a is ignored by every active group -> safe to exclude at query time.
|
||||
sending_configs = [
|
||||
{'group_name': 'group_a', 'levels': ['ERROR'], 'ignore_models_id': ['model_a', 'model_b']},
|
||||
{'group_name': 'group_b', 'levels': ['WARNING', 'ERROR'], 'ignore_models_id': ['model_a']},
|
||||
]
|
||||
default_filter = {}
|
||||
|
||||
result = load_notification_package._compute_query_time_filter(sending_configs, default_filter)
|
||||
|
||||
assert result == {
|
||||
'level': {'$in': ['ERROR', 'WARNING']},
|
||||
'model_id': {'$nin': ['model_a']},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user