scietex.log-aggregator-service 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. scietex_log_aggregator_service-0.1.0/LICENSE +21 -0
  2. scietex_log_aggregator_service-0.1.0/PKG-INFO +136 -0
  3. scietex_log_aggregator_service-0.1.0/README.md +112 -0
  4. scietex_log_aggregator_service-0.1.0/pyproject.toml +44 -0
  5. scietex_log_aggregator_service-0.1.0/setup.cfg +4 -0
  6. scietex_log_aggregator_service-0.1.0/src/scietex/log_aggregator_service/__init__.py +21 -0
  7. scietex_log_aggregator_service-0.1.0/src/scietex/log_aggregator_service/aggregator_worker.py +227 -0
  8. scietex_log_aggregator_service-0.1.0/src/scietex/log_aggregator_service/config.py +108 -0
  9. scietex_log_aggregator_service-0.1.0/src/scietex/log_aggregator_service/py.typed +0 -0
  10. scietex_log_aggregator_service-0.1.0/src/scietex/log_aggregator_service/run_worker.py +74 -0
  11. scietex_log_aggregator_service-0.1.0/src/scietex/log_aggregator_service/version.py +3 -0
  12. scietex_log_aggregator_service-0.1.0/src/scietex.log_aggregator_service.egg-info/PKG-INFO +136 -0
  13. scietex_log_aggregator_service-0.1.0/src/scietex.log_aggregator_service.egg-info/SOURCES.txt +18 -0
  14. scietex_log_aggregator_service-0.1.0/src/scietex.log_aggregator_service.egg-info/dependency_links.txt +1 -0
  15. scietex_log_aggregator_service-0.1.0/src/scietex.log_aggregator_service.egg-info/entry_points.txt +2 -0
  16. scietex_log_aggregator_service-0.1.0/src/scietex.log_aggregator_service.egg-info/requires.txt +12 -0
  17. scietex_log_aggregator_service-0.1.0/src/scietex.log_aggregator_service.egg-info/top_level.txt +1 -0
  18. scietex_log_aggregator_service-0.1.0/tests/test_aggregator_worker.py +154 -0
  19. scietex_log_aggregator_service-0.1.0/tests/test_config.py +84 -0
  20. scietex_log_aggregator_service-0.1.0/tests/test_run_worker.py +70 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Anton Bondarenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: scietex.log_aggregator_service
3
+ Version: 0.1.0
4
+ Summary: Scietex microservice daemon that aggregates per-worker log streams
5
+ Author-email: Anton Bondarenko <bond.anton@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Bug Tracker, https://github.com/bond-anton/scietex.log_aggregator_service/issues
8
+ Project-URL: Homepage, https://github.com/bond-anton/scietex.log_aggregator_service
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: scietex.service[valkey]~=5.2.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: tox>=4.45.0; extra == "dev"
17
+ Provides-Extra: lint
18
+ Requires-Dist: ruff; extra == "lint"
19
+ Requires-Dist: ty; extra == "lint"
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest; extra == "test"
22
+ Requires-Dist: pytest-asyncio; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # scietex.log_aggregator_service
26
+
27
+ **scietex.log_aggregator_service** is a `scietex.service` worker that merges the
28
+ per-worker log streams of the configured services into one shared stream. Each
29
+ `scietex.service` worker writes its own stream
30
+ (`scietex:{service}:{instance_id}:log`); this service tails all of them and
31
+ copies every entry into a single target stream (`scietex:log` by default),
32
+ stamping each entry with a `source` field (`{service}:{instance_id}`) so
33
+ consumers can tell which worker emitted it.
34
+
35
+ The aggregator is the **sole writer** of the target stream, so it owns both the
36
+ `MAXLEN ~` trim and the whole-key `EXPIRE`. The API backend reads that stream
37
+ for the system log viewer and no longer trims it.
38
+
39
+ **Python ≥ 3.10** · **License: MIT**
40
+
41
+ ## How it works
42
+
43
+ ```
44
+ per-worker log streams shared stream viewer
45
+ scietex:{svc}:{inst}:log ─┐
46
+ scietex:{svc2}:{inst}:log ─┤ XREAD (multi-key) ┌────────────────┐ GET /logs/ ─► LogViewer
47
+ scietex:{svcN}:{inst}:log ─┘ ───────────────► │ scietex:log │ GET /logs/stream (SSE) (Source col)
48
+ │ XADD MAXLEN ~ │
49
+ ▲ each writer owns its stream │ EXPIRE ttl │
50
+ │ (worker heartbeat refreshes its TTL) └────────────────┘
51
+ ▲ single owner
52
+ ┌───────────────┴────────────────┐
53
+ │ LogAggregatorService worker │
54
+ │ (reads scietex:{agg}:config) │
55
+ └─────────────────────────────────┘
56
+ ```
57
+
58
+ - A **single supervisor loop** (not one task per stream) tails every source with
59
+ a multi-key `XREAD`, so the merge is natural and there is no task fan-out.
60
+ - A periodic `SCAN` (`scietex:{service}:*:log`) picks up workers that appear or
61
+ disappear; the source set is rebuilt in place.
62
+ - The target stream's TTL is refreshed on a timer (Valkey `EXPIRE` sets a TTL on
63
+ the key; `XADD` does not refresh it).
64
+ - The aggregator's own service is excluded from the source set, so its logs do
65
+ not loop back into the stream it writes.
66
+
67
+ ## Installation
68
+
69
+ ```bash
70
+ pip install scietex.log_aggregator_service
71
+ ```
72
+
73
+ This pulls in `scietex.service[valkey]` (the worker framework and its Valkey
74
+ transport).
75
+
76
+ ## Quick start
77
+
78
+ ### 1. Write a configuration file
79
+
80
+ The service reads `log_aggregator.yml` from a `log_aggregator/` subdirectory of
81
+ its config directory. Create it by hand, or let the service generate defaults on
82
+ first run:
83
+
84
+ ```yaml
85
+ source_services:
86
+ - ModbusService
87
+ exclude_services:
88
+ - LogAggregatorService
89
+ target_stream: scietex:log
90
+ max_len: 100000
91
+ ttl_seconds: 604800
92
+ batch_size: 200
93
+ block_ms: 1000
94
+ scan_interval: 15.0
95
+ ```
96
+
97
+ ### 2. Run the service
98
+
99
+ ```bash
100
+ start-log-aggregator --conf-dir /etc/scietex
101
+ ```
102
+
103
+ The service resolves its config directory automatically when `--conf-dir` is
104
+ omitted. It runs in the foreground until it receives `SIGINT` or `SIGTERM`.
105
+
106
+ ### 3. Register it with the backend
107
+
108
+ The backend is the config authority: register the service so it can deliver the
109
+ `log_aggregator` section (source services, target stream, `max_len`, `ttl`):
110
+
111
+ ```bash
112
+ curl -X POST http://localhost:8000/api/v1/services/discovered/LogAggregatorService/register
113
+ ```
114
+
115
+ ## Configuration at a glance
116
+
117
+ | Setting | Default | Meaning |
118
+ | --- | --- | --- |
119
+ | `source_services` | `[]` | Service names whose per-worker log streams are aggregated |
120
+ | `exclude_services` | `[]` | Names removed from the source set (self-exclusion) |
121
+ | `target_stream` | `scietex:log` | Shared stream the aggregator writes |
122
+ | `max_len` | `100000` | `MAXLEN ~` applied on each write (`0` disables) |
123
+ | `ttl_seconds` | `604800` | Whole-key TTL refreshed on a timer (`0` disables) |
124
+ | `batch_size` | `200` | Entries per `XREAD` |
125
+ | `block_ms` | `1000` | `XREAD` block timeout |
126
+ | `scan_interval` | `15.0` | Seconds between source-set SCANs |
127
+
128
+ ## Development
129
+
130
+ Dependencies are managed with **uv**; checks and tests run through **tox**:
131
+
132
+ ```bash
133
+ uv sync --all-extras
134
+ uv run tox # format, lint, type, py314
135
+ uv run tox -e py314 # tests only
136
+ ```
@@ -0,0 +1,112 @@
1
+ # scietex.log_aggregator_service
2
+
3
+ **scietex.log_aggregator_service** is a `scietex.service` worker that merges the
4
+ per-worker log streams of the configured services into one shared stream. Each
5
+ `scietex.service` worker writes its own stream
6
+ (`scietex:{service}:{instance_id}:log`); this service tails all of them and
7
+ copies every entry into a single target stream (`scietex:log` by default),
8
+ stamping each entry with a `source` field (`{service}:{instance_id}`) so
9
+ consumers can tell which worker emitted it.
10
+
11
+ The aggregator is the **sole writer** of the target stream, so it owns both the
12
+ `MAXLEN ~` trim and the whole-key `EXPIRE`. The API backend reads that stream
13
+ for the system log viewer and no longer trims it.
14
+
15
+ **Python ≥ 3.10** · **License: MIT**
16
+
17
+ ## How it works
18
+
19
+ ```
20
+ per-worker log streams shared stream viewer
21
+ scietex:{svc}:{inst}:log ─┐
22
+ scietex:{svc2}:{inst}:log ─┤ XREAD (multi-key) ┌────────────────┐ GET /logs/ ─► LogViewer
23
+ scietex:{svcN}:{inst}:log ─┘ ───────────────► │ scietex:log │ GET /logs/stream (SSE) (Source col)
24
+ │ XADD MAXLEN ~ │
25
+ ▲ each writer owns its stream │ EXPIRE ttl │
26
+ │ (worker heartbeat refreshes its TTL) └────────────────┘
27
+ ▲ single owner
28
+ ┌───────────────┴────────────────┐
29
+ │ LogAggregatorService worker │
30
+ │ (reads scietex:{agg}:config) │
31
+ └─────────────────────────────────┘
32
+ ```
33
+
34
+ - A **single supervisor loop** (not one task per stream) tails every source with
35
+ a multi-key `XREAD`, so the merge is natural and there is no task fan-out.
36
+ - A periodic `SCAN` (`scietex:{service}:*:log`) picks up workers that appear or
37
+ disappear; the source set is rebuilt in place.
38
+ - The target stream's TTL is refreshed on a timer (Valkey `EXPIRE` sets a TTL on
39
+ the key; `XADD` does not refresh it).
40
+ - The aggregator's own service is excluded from the source set, so its logs do
41
+ not loop back into the stream it writes.
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install scietex.log_aggregator_service
47
+ ```
48
+
49
+ This pulls in `scietex.service[valkey]` (the worker framework and its Valkey
50
+ transport).
51
+
52
+ ## Quick start
53
+
54
+ ### 1. Write a configuration file
55
+
56
+ The service reads `log_aggregator.yml` from a `log_aggregator/` subdirectory of
57
+ its config directory. Create it by hand, or let the service generate defaults on
58
+ first run:
59
+
60
+ ```yaml
61
+ source_services:
62
+ - ModbusService
63
+ exclude_services:
64
+ - LogAggregatorService
65
+ target_stream: scietex:log
66
+ max_len: 100000
67
+ ttl_seconds: 604800
68
+ batch_size: 200
69
+ block_ms: 1000
70
+ scan_interval: 15.0
71
+ ```
72
+
73
+ ### 2. Run the service
74
+
75
+ ```bash
76
+ start-log-aggregator --conf-dir /etc/scietex
77
+ ```
78
+
79
+ The service resolves its config directory automatically when `--conf-dir` is
80
+ omitted. It runs in the foreground until it receives `SIGINT` or `SIGTERM`.
81
+
82
+ ### 3. Register it with the backend
83
+
84
+ The backend is the config authority: register the service so it can deliver the
85
+ `log_aggregator` section (source services, target stream, `max_len`, `ttl`):
86
+
87
+ ```bash
88
+ curl -X POST http://localhost:8000/api/v1/services/discovered/LogAggregatorService/register
89
+ ```
90
+
91
+ ## Configuration at a glance
92
+
93
+ | Setting | Default | Meaning |
94
+ | --- | --- | --- |
95
+ | `source_services` | `[]` | Service names whose per-worker log streams are aggregated |
96
+ | `exclude_services` | `[]` | Names removed from the source set (self-exclusion) |
97
+ | `target_stream` | `scietex:log` | Shared stream the aggregator writes |
98
+ | `max_len` | `100000` | `MAXLEN ~` applied on each write (`0` disables) |
99
+ | `ttl_seconds` | `604800` | Whole-key TTL refreshed on a timer (`0` disables) |
100
+ | `batch_size` | `200` | Entries per `XREAD` |
101
+ | `block_ms` | `1000` | `XREAD` block timeout |
102
+ | `scan_interval` | `15.0` | Seconds between source-set SCANs |
103
+
104
+ ## Development
105
+
106
+ Dependencies are managed with **uv**; checks and tests run through **tox**:
107
+
108
+ ```bash
109
+ uv sync --all-extras
110
+ uv run tox # format, lint, type, py314
111
+ uv run tox -e py314 # tests only
112
+ ```
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "scietex.log_aggregator_service"
3
+ description = "Scietex microservice daemon that aggregates per-worker log streams"
4
+ readme = "README.md"
5
+ requires-python = ">=3.10"
6
+ license = "MIT"
7
+ license-files = ["LICEN[CS]E*"]
8
+ authors = [{ name = "Anton Bondarenko", email = "bond.anton@gmail.com" }]
9
+ classifiers = [
10
+ "Operating System :: OS Independent",
11
+ "Programming Language :: Python :: 3",
12
+ ]
13
+ dependencies = [
14
+ "scietex.service[valkey]~=5.2.0",
15
+ ]
16
+ dynamic = ["version"]
17
+
18
+ [project.urls]
19
+ "Bug Tracker" = "https://github.com/bond-anton/scietex.log_aggregator_service/issues"
20
+ "Homepage" = "https://github.com/bond-anton/scietex.log_aggregator_service"
21
+
22
+ [project.scripts]
23
+ start-log-aggregator = "scietex.log_aggregator_service.run_worker:main"
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["tox>=4.45.0"]
27
+ lint = ["ruff", "ty"]
28
+ test = ["pytest", "pytest-asyncio"]
29
+
30
+ [build-system]
31
+ requires = ["setuptools>=61.0"]
32
+ build-backend = "setuptools.build_meta"
33
+
34
+ [tool.pytest.ini_options]
35
+ pythonpath = ["src"]
36
+
37
+ [tool.setuptools.dynamic]
38
+ version = { attr = "scietex.log_aggregator_service.version.__version__" }
39
+
40
+ [tool.setuptools.package-data]
41
+ "scietex.log_aggregator_service" = ["py.typed"]
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,21 @@
1
+ """Log Aggregator Service."""
2
+
3
+ from .aggregator_worker import LogAggregatorWorker
4
+ from .config import (
5
+ AGGREGATOR_CONFIG_FILE,
6
+ AGGREGATOR_CONFIG_SUBDIR,
7
+ AGGREGATOR_SECTION,
8
+ LogAggregatorSettings,
9
+ read_aggregator_config,
10
+ )
11
+ from .version import __version__
12
+
13
+ __all__ = [
14
+ "__version__",
15
+ "LogAggregatorWorker",
16
+ "AGGREGATOR_CONFIG_FILE",
17
+ "AGGREGATOR_CONFIG_SUBDIR",
18
+ "AGGREGATOR_SECTION",
19
+ "LogAggregatorSettings",
20
+ "read_aggregator_config",
21
+ ]
@@ -0,0 +1,227 @@
1
+ """Log aggregator worker: a Valkey-backed service that merges worker log streams.
2
+
3
+ `LogAggregatorWorker` extends `ValkeyWorker` and runs a single supervisor loop
4
+ that tails every per-worker log stream of the configured source services and
5
+ copies each entry into one shared stream. It is the sole writer of that stream,
6
+ so it owns both the ``MAXLEN`` trim and the whole-key ``EXPIRE``.
7
+
8
+ The loop is a single task (not one task per stream): a multi-key ``XREAD``
9
+ merges all sources naturally, and a periodic ``SCAN`` picks up workers that
10
+ appear or disappear. Each copied entry is stamped with ``source`` =
11
+ ``{service}:{instance_id}``, derived from the stream key, so consumers can tell
12
+ which worker emitted it. The aggregator's own service is excluded from the
13
+ source set to prevent its logs from looping back into the stream it writes.
14
+ """
15
+
16
+ import asyncio
17
+ import contextlib
18
+ import time
19
+ from collections.abc import Mapping
20
+ from typing import Any, cast
21
+
22
+ from glide import GlideError, StreamAddOptions, StreamReadOptions, TrimByMaxLen
23
+ from scietex.service import ValkeyWorker, ValkeyWorkerConfig
24
+
25
+ from .config import AGGREGATOR_SECTION, LogAggregatorSettings, read_aggregator_config
26
+
27
+ #: Suffix of a per-worker log stream key: ``scietex:{service}:{instance_id}:log``.
28
+ _LOG_STREAM_SUFFIX: str = ":log"
29
+
30
+
31
+ class LogAggregatorWorker(ValkeyWorker):
32
+ """A `ValkeyWorker` that aggregates per-worker log streams into one stream.
33
+
34
+ Settings precedence: constructor default < ``log_aggregator.yml`` < framework
35
+ ``config.yml`` / remote ``log_aggregator`` section. The reader loop starts
36
+ after `super().initialize()` so the remote apply (which runs the
37
+ ``log_aggregator`` section hook) has already updated ``self._settings``.
38
+ """
39
+
40
+ def __init__(self, config: ValkeyWorkerConfig | None = None, *, client_factory=None, theme=None) -> None:
41
+ super().__init__(config, client_factory=client_factory, theme=theme)
42
+ self._settings: LogAggregatorSettings | None = None
43
+ self._reader_task: asyncio.Task | None = None
44
+ self.register_config_settings(AGGREGATOR_SECTION, LogAggregatorSettings, apply=self._apply_settings)
45
+
46
+ @property
47
+ def aggregator_settings(self) -> LogAggregatorSettings | None:
48
+ """The effective aggregator settings, or `None` before startup."""
49
+ return self._settings
50
+
51
+ async def initialize(self) -> bool:
52
+ """Load settings, initialize the framework, then start the reader loop.
53
+
54
+ The reader loop is started only after `super().initialize()` succeeds, so
55
+ the remote config apply has already run and ``self._settings`` reflects
56
+ the effective configuration.
57
+
58
+ Returns:
59
+ `True` if the framework initialized and the reader loop started;
60
+ `False` on any configuration or startup failure.
61
+ """
62
+ try:
63
+ self._settings = read_aggregator_config(self.conf_dir)
64
+ except RuntimeError as exc:
65
+ self.logger.error("Failed to load log aggregator configuration: %s", exc)
66
+ return False
67
+
68
+ if not await super().initialize():
69
+ return False
70
+
71
+ self._reader_task = asyncio.create_task(self._reader_loop(), name="LogAggregatorReader")
72
+ return True
73
+
74
+ async def cleanup(self) -> None:
75
+ """Cancel the reader loop, then tear down the framework resources."""
76
+ task, self._reader_task = self._reader_task, None
77
+ if task is not None:
78
+ task.cancel()
79
+ with contextlib.suppress(asyncio.CancelledError):
80
+ await task
81
+ await super().cleanup()
82
+
83
+ def _apply_settings(self, settings: LogAggregatorSettings) -> None:
84
+ """Store remote aggregator settings; the reader loop picks them up.
85
+
86
+ The loop detects the settings object identity change and forces a SCAN,
87
+ so a changed ``source_services`` set is reflected without restarting the
88
+ task. ``max_len`` and ``ttl_seconds`` apply on the next iteration.
89
+ """
90
+ self._settings = settings
91
+ self.logger.info(
92
+ "Log aggregator settings applied: %d source service(s), target=%s",
93
+ len(settings.source_services),
94
+ settings.target_stream,
95
+ )
96
+
97
+ async def _reader_loop(self) -> None:
98
+ """Tail every source stream and copy entries into the target stream.
99
+
100
+ A single loop owns all sources: ``last_ids`` maps each source stream key
101
+ to the last entry id read, and a multi-key ``XREAD`` merges them. The
102
+ source set is refreshed by SCAN on a timer (and immediately when the
103
+ settings object changes), so workers appearing or disappearing are
104
+ handled without per-stream tasks. Any iteration error is logged and the
105
+ loop continues; only cancellation ends it.
106
+ """
107
+ last_ids: dict[str, str] = {}
108
+ settings_seen: LogAggregatorSettings | None = None
109
+ last_scan = 0.0
110
+ last_expire = 0.0
111
+ while True:
112
+ try:
113
+ settings = self._settings
114
+ client = self.client
115
+ if settings is None or client is None:
116
+ await asyncio.sleep(1.0)
117
+ continue
118
+
119
+ now = time.monotonic()
120
+ if settings is not settings_seen or now - last_scan >= settings.scan_interval:
121
+ await self._refresh_streams(settings, last_ids)
122
+ last_scan = now
123
+ settings_seen = settings
124
+
125
+ if settings.ttl_seconds > 0 and now - last_expire >= max(1.0, settings.ttl_seconds / 3):
126
+ # EXPIRE is a no-op until the target stream exists (the first
127
+ # XADD creates it), so only advance the timer on success;
128
+ # otherwise the long interval would delay the TTL until the
129
+ # next window even though the stream now exists.
130
+ if await client.expire(settings.target_stream, settings.ttl_seconds):
131
+ last_expire = now
132
+
133
+ if not last_ids:
134
+ await asyncio.sleep(1.0)
135
+ continue
136
+
137
+ entries = await client.xread(
138
+ keys_and_ids=cast("Mapping[str | bytes, str | bytes]", last_ids),
139
+ options=StreamReadOptions(count=settings.batch_size, block_ms=settings.block_ms),
140
+ )
141
+ for key_bytes, group in (entries or {}).items():
142
+ key = key_bytes.decode() if isinstance(key_bytes, bytes) else str(key_bytes)
143
+ for entry_id, fields in group.items():
144
+ await self._copy(settings, key, fields)
145
+ last_ids[key] = entry_id.decode() if isinstance(entry_id, bytes) else str(entry_id)
146
+ except asyncio.CancelledError:
147
+ raise
148
+ except GlideError as exc:
149
+ # Mirror the framework transport: report to TransportHealth so
150
+ # the single reconnect owner recovers the shared client, then
151
+ # retry. GlideError covers ClosingError too, which the framework
152
+ # raises when its reconnect closes the client mid-read.
153
+ self.logger.debug("Log aggregation read failed: %s", exc)
154
+ self._health.report_failure(exc)
155
+ await self._health.recover()
156
+ await asyncio.sleep(1.0)
157
+ except Exception:
158
+ self.logger.exception("Log aggregation iteration failed; continuing")
159
+ await asyncio.sleep(1.0)
160
+
161
+ async def _refresh_streams(self, settings: LogAggregatorSettings, last_ids: dict[str, str]) -> None:
162
+ """Rebuild ``last_ids`` from a SCAN of the configured source services.
163
+
164
+ Each source service contributes keys matching ``scietex:{service}:*:log``.
165
+ Keys that vanished are dropped; new keys start from ``"0"`` so their
166
+ backlog is copied. The dict is mutated in place so the reader loop keeps
167
+ a single reference.
168
+ """
169
+ client = self.client
170
+ if client is None:
171
+ return
172
+ excluded = set(settings.exclude_services)
173
+ found: set[str] = set()
174
+ for service in settings.source_services:
175
+ if service in excluded:
176
+ continue
177
+ cursor: bytes = b"0"
178
+ while True:
179
+ result = await client.scan(cursor, match=f"scietex:{service}:*{_LOG_STREAM_SUFFIX}", count=100)
180
+ cursor = cast(bytes, result[0])
181
+ for name in cast("list[bytes]", result[1]):
182
+ found.add(name.decode() if isinstance(name, bytes) else str(name))
183
+ if cursor == b"0":
184
+ break
185
+ for key in list(last_ids):
186
+ if key not in found:
187
+ del last_ids[key]
188
+ for key in found:
189
+ last_ids.setdefault(key, "0")
190
+
191
+ async def _copy(self, settings: LogAggregatorSettings, source_key: str, fields: Any) -> None:
192
+ """Copy one source entry into the target stream, stamped with ``source``.
193
+
194
+ ``fields`` is the Glide ``list[tuple[bytes, bytes]]`` payload. The
195
+ ``source`` label is derived from the stream key
196
+ (``scietex:a:1:log`` -> ``a:1``) and overrides any existing field, so a
197
+ consumer always sees the emitting worker. ``MAXLEN ~`` is applied on the
198
+ write when ``max_len > 0``.
199
+ """
200
+ client = self.client
201
+ if client is None:
202
+ return
203
+ decoded: dict[str, str] = {}
204
+ for pair in fields:
205
+ name = pair[0].decode() if isinstance(pair[0], bytes) else str(pair[0])
206
+ value = pair[1].decode() if isinstance(pair[1], bytes) else str(pair[1])
207
+ decoded[name] = value
208
+ decoded["source"] = _source_label(source_key)
209
+ options = (
210
+ StreamAddOptions(trim=TrimByMaxLen(exact=False, threshold=settings.max_len, limit=None))
211
+ if settings.max_len > 0
212
+ else None
213
+ )
214
+ await client.xadd(settings.target_stream, list(decoded.items()), options=options)
215
+
216
+
217
+ def _source_label(stream_key: str) -> str:
218
+ """Derive ``{service}:{instance_id}`` from a per-worker log stream key.
219
+
220
+ ``scietex:{service}:{instance_id}:log`` -> ``{service}:{instance_id}``. A key
221
+ that does not match the expected shape is returned unchanged, so a
222
+ misconfigured source still produces a usable label.
223
+ """
224
+ parts = stream_key.split(":")
225
+ if len(parts) == 4 and parts[0] == "scietex" and parts[3] == "log":
226
+ return f"{parts[1]}:{parts[2]}"
227
+ return stream_key
@@ -0,0 +1,108 @@
1
+ """Configuration models and YAML loader for the log aggregator service.
2
+
3
+ `LogAggregatorSettings` is the service-owned bootstrap snapshot stored in
4
+ ``log_aggregator.yml`` under the config directory. It is kept deliberately thin:
5
+ it names the source services to aggregate, the target stream, and the retention
6
+ policy the aggregator enforces on that stream.
7
+
8
+ The aggregator is the sole writer of the target stream, so it owns both the
9
+ ``MAXLEN`` trim and the whole-key ``EXPIRE``. The API delivers these values as
10
+ the remote ``log_aggregator`` section; the local YAML is only the bootstrap
11
+ fallback used before the first remote apply.
12
+ """
13
+
14
+ from pathlib import Path
15
+
16
+ import msgspec
17
+
18
+ #: Remote-config section name the settings are registered under.
19
+ AGGREGATOR_SECTION: str = "log_aggregator"
20
+
21
+ #: Subdirectory under the shared config dir that namespaces this service's
22
+ #: files. The framework's ``config.yml`` snapshot and the service-owned
23
+ #: ``log_aggregator.yml`` both live here, so services sharing one config dir
24
+ #: (the framework resolves a single dir for all scietex services) cannot collide.
25
+ AGGREGATOR_CONFIG_SUBDIR: str = "log_aggregator"
26
+
27
+ #: Filename of the service-owned bootstrap snapshot in the config subdirectory.
28
+ AGGREGATOR_CONFIG_FILE: str = "log_aggregator.yml"
29
+
30
+
31
+ class LogAggregatorSettings(msgspec.Struct, frozen=True, forbid_unknown_fields=True):
32
+ """Top-level log aggregator settings.
33
+
34
+ ``source_services`` lists the service names whose per-worker log streams are
35
+ aggregated; ``exclude_services`` removes names from that set (the backend
36
+ sets it to the aggregator's own service name to prevent self-ingestion).
37
+ ``target_stream`` is the shared stream the aggregator writes; ``max_len`` and
38
+ ``ttl_seconds`` are the retention policy it enforces on it. A ``max_len`` or
39
+ ``ttl_seconds`` of ``0`` disables that half of the policy.
40
+ """
41
+
42
+ source_services: list[str] = msgspec.field(default_factory=list)
43
+ exclude_services: list[str] = msgspec.field(default_factory=list)
44
+ target_stream: str = "scietex:log"
45
+ max_len: int = 100_000
46
+ ttl_seconds: int = 604800
47
+ batch_size: int = 200
48
+ block_ms: int = 1000
49
+ scan_interval: float = 15.0
50
+
51
+
52
+ def read_aggregator_config(conf_dir: Path | None, *, create_default: bool = True) -> LogAggregatorSettings:
53
+ """Read aggregator settings from ``log_aggregator/log_aggregator.yml``.
54
+
55
+ The service's files are namespaced in a ``log_aggregator/`` subdirectory so
56
+ they do not collide with other services sharing the framework's single config
57
+ dir. Mirrors `read_modbus_config`: the file (and, when missing, its
58
+ directory) is only created when ``create_default=True`` (the bootstrap path).
59
+ A ``None`` or non-directory ``conf_dir``, a missing file/directory with
60
+ ``create_default=False``, or an unparseable file each raise `RuntimeError`.
61
+ An existing-but-invalid file is left untouched regardless of ``create_default``.
62
+
63
+ Args:
64
+ conf_dir: Path to the configuration directory.
65
+ create_default: Whether to create the directory and write a default
66
+ ``log_aggregator.yml`` when missing. Default ``True``.
67
+
68
+ Returns:
69
+ A `LogAggregatorSettings` loaded from ``log_aggregator.yml`` or defaults.
70
+
71
+ Raises:
72
+ RuntimeError: If ``conf_dir`` is ``None`` or not a directory, the file
73
+ is missing with ``create_default=False``, or the file cannot be parsed.
74
+ """
75
+ if not isinstance(conf_dir, Path):
76
+ raise RuntimeError("Configuration dir was not set!")
77
+ service_dir = conf_dir / AGGREGATOR_CONFIG_SUBDIR
78
+ if not service_dir.exists():
79
+ if create_default:
80
+ try:
81
+ service_dir.mkdir(parents=True, exist_ok=True)
82
+ except Exception as exc:
83
+ raise RuntimeError(f"Failed to create configuration directory {service_dir}!") from exc
84
+ else:
85
+ raise RuntimeError(
86
+ f"Configuration directory {service_dir} does not exist and create_default=False (no default generated)."
87
+ )
88
+ elif not service_dir.is_dir():
89
+ raise RuntimeError(f"Provided configuration directory path {service_dir} is not a directory!")
90
+ config_yml = service_dir.joinpath(AGGREGATOR_CONFIG_FILE)
91
+ if not config_yml.exists():
92
+ if create_default:
93
+ settings = LogAggregatorSettings()
94
+ with open(config_yml, "wb") as f:
95
+ f.write(msgspec.yaml.encode(settings))
96
+ return settings
97
+ raise RuntimeError(
98
+ f"Log aggregator configuration file {config_yml} does not exist and create_default=False "
99
+ "(pass create_default=True to generate defaults)."
100
+ )
101
+ try:
102
+ with open(config_yml, "rb") as f:
103
+ return msgspec.yaml.decode(f.read(), type=LogAggregatorSettings, strict=True)
104
+ except Exception as exc:
105
+ raise RuntimeError(
106
+ f"Failed to parse log aggregator configuration file {config_yml}. "
107
+ "Fix the file or remove it to regenerate defaults."
108
+ ) from exc
@@ -0,0 +1,74 @@
1
+ """Entry point to run the log aggregator worker as a foreground daemon."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import os
6
+
7
+ from scietex.service import ValkeyWorkerConfig
8
+
9
+ from .aggregator_worker import LogAggregatorWorker
10
+ from .config import AGGREGATOR_CONFIG_SUBDIR
11
+ from .version import __version__
12
+
13
+ #: Environment fallbacks for the CLI options, so a container can be configured
14
+ #: with ``-e`` without overriding its command. An explicit CLI flag still wins.
15
+ ENV_SERVICE_NAME: str = "SCIETEX_SERVICE_NAME"
16
+ ENV_LOGGING_LEVEL: str = "SCIETEX_LOGGING_LEVEL"
17
+
18
+ DEFAULT_SERVICE_NAME: str = "LogAggregatorService"
19
+ DEFAULT_LOGGING_LEVEL: str = "INFO"
20
+
21
+
22
+ def _build_parser() -> argparse.ArgumentParser:
23
+ """Build the CLI parser with environment-variable fallbacks.
24
+
25
+ ``--conf-dir`` defaults to ``None`` so the framework's ``prepare_conf_dir``
26
+ resolves it (it already honors ``SCIETEX_CONFIG_DIR``); the other options
27
+ fall back to their environment variables when no flag is given.
28
+ """
29
+ parser = argparse.ArgumentParser(description="Run the log aggregator worker.")
30
+ parser.add_argument("--conf-dir", default=None, help="Configuration directory (default: auto-resolved)")
31
+ parser.add_argument(
32
+ "--service-name",
33
+ default=os.environ.get(ENV_SERVICE_NAME, DEFAULT_SERVICE_NAME),
34
+ help=f"Service name (default: ${ENV_SERVICE_NAME} or {DEFAULT_SERVICE_NAME})",
35
+ )
36
+ parser.add_argument(
37
+ "--logging-level",
38
+ default=os.environ.get(ENV_LOGGING_LEVEL, DEFAULT_LOGGING_LEVEL),
39
+ help=f"Logging level (default: ${ENV_LOGGING_LEVEL} or {DEFAULT_LOGGING_LEVEL})",
40
+ )
41
+ return parser
42
+
43
+
44
+ def _build_config(args: argparse.Namespace) -> ValkeyWorkerConfig:
45
+ """Build the worker config from parsed CLI arguments."""
46
+ return ValkeyWorkerConfig(
47
+ service_name=args.service_name,
48
+ version=__version__,
49
+ conf_dir=args.conf_dir,
50
+ logging_level=args.logging_level,
51
+ remote_config_enabled=True,
52
+ valkey_config=None,
53
+ # Namespace the framework snapshot under the same subdir as
54
+ # log_aggregator.yml, so services sharing one config dir cannot clobber
55
+ # each other's config.
56
+ config_file=f"{AGGREGATOR_CONFIG_SUBDIR}/config.yml",
57
+ )
58
+
59
+
60
+ def main() -> None:
61
+ """Parse CLI arguments and run the aggregator worker until exit is requested."""
62
+ args = _build_parser().parse_args()
63
+ config = _build_config(args)
64
+
65
+ async def run() -> None:
66
+ worker = LogAggregatorWorker(config)
67
+ await worker.start()
68
+ await worker.events["exit"].wait()
69
+
70
+ asyncio.run(run())
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
@@ -0,0 +1,3 @@
1
+ """Version of the `scietex.log_aggregator_service` package"""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: scietex.log_aggregator_service
3
+ Version: 0.1.0
4
+ Summary: Scietex microservice daemon that aggregates per-worker log streams
5
+ Author-email: Anton Bondarenko <bond.anton@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Bug Tracker, https://github.com/bond-anton/scietex.log_aggregator_service/issues
8
+ Project-URL: Homepage, https://github.com/bond-anton/scietex.log_aggregator_service
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: scietex.service[valkey]~=5.2.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: tox>=4.45.0; extra == "dev"
17
+ Provides-Extra: lint
18
+ Requires-Dist: ruff; extra == "lint"
19
+ Requires-Dist: ty; extra == "lint"
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest; extra == "test"
22
+ Requires-Dist: pytest-asyncio; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # scietex.log_aggregator_service
26
+
27
+ **scietex.log_aggregator_service** is a `scietex.service` worker that merges the
28
+ per-worker log streams of the configured services into one shared stream. Each
29
+ `scietex.service` worker writes its own stream
30
+ (`scietex:{service}:{instance_id}:log`); this service tails all of them and
31
+ copies every entry into a single target stream (`scietex:log` by default),
32
+ stamping each entry with a `source` field (`{service}:{instance_id}`) so
33
+ consumers can tell which worker emitted it.
34
+
35
+ The aggregator is the **sole writer** of the target stream, so it owns both the
36
+ `MAXLEN ~` trim and the whole-key `EXPIRE`. The API backend reads that stream
37
+ for the system log viewer and no longer trims it.
38
+
39
+ **Python ≥ 3.10** · **License: MIT**
40
+
41
+ ## How it works
42
+
43
+ ```
44
+ per-worker log streams shared stream viewer
45
+ scietex:{svc}:{inst}:log ─┐
46
+ scietex:{svc2}:{inst}:log ─┤ XREAD (multi-key) ┌────────────────┐ GET /logs/ ─► LogViewer
47
+ scietex:{svcN}:{inst}:log ─┘ ───────────────► │ scietex:log │ GET /logs/stream (SSE) (Source col)
48
+ │ XADD MAXLEN ~ │
49
+ ▲ each writer owns its stream │ EXPIRE ttl │
50
+ │ (worker heartbeat refreshes its TTL) └────────────────┘
51
+ ▲ single owner
52
+ ┌───────────────┴────────────────┐
53
+ │ LogAggregatorService worker │
54
+ │ (reads scietex:{agg}:config) │
55
+ └─────────────────────────────────┘
56
+ ```
57
+
58
+ - A **single supervisor loop** (not one task per stream) tails every source with
59
+ a multi-key `XREAD`, so the merge is natural and there is no task fan-out.
60
+ - A periodic `SCAN` (`scietex:{service}:*:log`) picks up workers that appear or
61
+ disappear; the source set is rebuilt in place.
62
+ - The target stream's TTL is refreshed on a timer (Valkey `EXPIRE` sets a TTL on
63
+ the key; `XADD` does not refresh it).
64
+ - The aggregator's own service is excluded from the source set, so its logs do
65
+ not loop back into the stream it writes.
66
+
67
+ ## Installation
68
+
69
+ ```bash
70
+ pip install scietex.log_aggregator_service
71
+ ```
72
+
73
+ This pulls in `scietex.service[valkey]` (the worker framework and its Valkey
74
+ transport).
75
+
76
+ ## Quick start
77
+
78
+ ### 1. Write a configuration file
79
+
80
+ The service reads `log_aggregator.yml` from a `log_aggregator/` subdirectory of
81
+ its config directory. Create it by hand, or let the service generate defaults on
82
+ first run:
83
+
84
+ ```yaml
85
+ source_services:
86
+ - ModbusService
87
+ exclude_services:
88
+ - LogAggregatorService
89
+ target_stream: scietex:log
90
+ max_len: 100000
91
+ ttl_seconds: 604800
92
+ batch_size: 200
93
+ block_ms: 1000
94
+ scan_interval: 15.0
95
+ ```
96
+
97
+ ### 2. Run the service
98
+
99
+ ```bash
100
+ start-log-aggregator --conf-dir /etc/scietex
101
+ ```
102
+
103
+ The service resolves its config directory automatically when `--conf-dir` is
104
+ omitted. It runs in the foreground until it receives `SIGINT` or `SIGTERM`.
105
+
106
+ ### 3. Register it with the backend
107
+
108
+ The backend is the config authority: register the service so it can deliver the
109
+ `log_aggregator` section (source services, target stream, `max_len`, `ttl`):
110
+
111
+ ```bash
112
+ curl -X POST http://localhost:8000/api/v1/services/discovered/LogAggregatorService/register
113
+ ```
114
+
115
+ ## Configuration at a glance
116
+
117
+ | Setting | Default | Meaning |
118
+ | --- | --- | --- |
119
+ | `source_services` | `[]` | Service names whose per-worker log streams are aggregated |
120
+ | `exclude_services` | `[]` | Names removed from the source set (self-exclusion) |
121
+ | `target_stream` | `scietex:log` | Shared stream the aggregator writes |
122
+ | `max_len` | `100000` | `MAXLEN ~` applied on each write (`0` disables) |
123
+ | `ttl_seconds` | `604800` | Whole-key TTL refreshed on a timer (`0` disables) |
124
+ | `batch_size` | `200` | Entries per `XREAD` |
125
+ | `block_ms` | `1000` | `XREAD` block timeout |
126
+ | `scan_interval` | `15.0` | Seconds between source-set SCANs |
127
+
128
+ ## Development
129
+
130
+ Dependencies are managed with **uv**; checks and tests run through **tox**:
131
+
132
+ ```bash
133
+ uv sync --all-extras
134
+ uv run tox # format, lint, type, py314
135
+ uv run tox -e py314 # tests only
136
+ ```
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/scietex.log_aggregator_service.egg-info/PKG-INFO
5
+ src/scietex.log_aggregator_service.egg-info/SOURCES.txt
6
+ src/scietex.log_aggregator_service.egg-info/dependency_links.txt
7
+ src/scietex.log_aggregator_service.egg-info/entry_points.txt
8
+ src/scietex.log_aggregator_service.egg-info/requires.txt
9
+ src/scietex.log_aggregator_service.egg-info/top_level.txt
10
+ src/scietex/log_aggregator_service/__init__.py
11
+ src/scietex/log_aggregator_service/aggregator_worker.py
12
+ src/scietex/log_aggregator_service/config.py
13
+ src/scietex/log_aggregator_service/py.typed
14
+ src/scietex/log_aggregator_service/run_worker.py
15
+ src/scietex/log_aggregator_service/version.py
16
+ tests/test_aggregator_worker.py
17
+ tests/test_config.py
18
+ tests/test_run_worker.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ start-log-aggregator = scietex.log_aggregator_service.run_worker:main
@@ -0,0 +1,12 @@
1
+ scietex.service[valkey]~=5.2.0
2
+
3
+ [dev]
4
+ tox>=4.45.0
5
+
6
+ [lint]
7
+ ruff
8
+ ty
9
+
10
+ [test]
11
+ pytest
12
+ pytest-asyncio
@@ -0,0 +1,154 @@
1
+ """Tests for the LogAggregatorWorker reader loop, copy, and config apply hook."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import logging
6
+ from unittest.mock import AsyncMock
7
+
8
+ import pytest
9
+ from scietex.service import ValkeyWorker, ValkeyWorkerConfig
10
+
11
+ from scietex.log_aggregator_service.aggregator_worker import LogAggregatorWorker, _source_label
12
+ from scietex.log_aggregator_service.config import LogAggregatorSettings
13
+
14
+
15
+ def _make_worker(tmp_path) -> LogAggregatorWorker:
16
+ """Build a worker with a real conf_dir and no remote-config handlers."""
17
+ return LogAggregatorWorker(
18
+ ValkeyWorkerConfig(
19
+ service_name="test",
20
+ conf_dir=str(tmp_path),
21
+ remote_config_enabled=False,
22
+ )
23
+ )
24
+
25
+
26
+ def test_source_label_derives_service_and_instance() -> None:
27
+ """A per-worker stream key maps to ``{service}:{instance_id}``."""
28
+ assert _source_label("scietex:modbus:abc123:log") == "modbus:abc123"
29
+
30
+
31
+ def test_source_label_passes_through_unexpected_keys() -> None:
32
+ """A key that does not match the expected shape is returned unchanged."""
33
+ assert _source_label("scietex:log") == "scietex:log"
34
+ assert _source_label("other:modbus:abc:log") == "other:modbus:abc:log"
35
+
36
+
37
+ @pytest.mark.asyncio
38
+ async def test_cleanup_safe_when_never_started(tmp_path) -> None:
39
+ """cleanup() is a no-op when the reader loop was never started."""
40
+ worker = _make_worker(tmp_path)
41
+
42
+ await worker.cleanup()
43
+
44
+ assert worker.aggregator_settings is None
45
+
46
+
47
+ def test_apply_hook_stores_settings(tmp_path, caplog) -> None:
48
+ """The apply hook stores the settings and logs the source count."""
49
+ worker = _make_worker(tmp_path)
50
+ settings = LogAggregatorSettings(source_services=["ModbusService"])
51
+
52
+ with caplog.at_level(logging.INFO):
53
+ worker._apply_settings(settings)
54
+
55
+ assert worker.aggregator_settings is settings
56
+ assert any("settings applied" in record.message for record in caplog.records)
57
+
58
+
59
+ @pytest.mark.asyncio
60
+ async def test_copy_stamps_source_and_applies_maxlen(tmp_path) -> None:
61
+ """_copy decodes fields, stamps source, and passes MAXLEN on the write."""
62
+ worker = _make_worker(tmp_path)
63
+ client = AsyncMock()
64
+ worker._client = client
65
+ settings = LogAggregatorSettings(max_len=500)
66
+
67
+ await worker._copy(
68
+ settings,
69
+ "scietex:modbus:abc123:log",
70
+ [(b"level", b"INF"), (b"message", b"boot"), (b"name", b"modbus")],
71
+ )
72
+
73
+ client.xadd.assert_awaited_once()
74
+ args, kwargs = client.xadd.await_args
75
+ assert args[0] == "scietex:log"
76
+ fields = dict(args[1])
77
+ assert fields["source"] == "modbus:abc123"
78
+ assert fields["message"] == "boot"
79
+ trim = kwargs["options"].trim
80
+ assert trim.threshold == 500
81
+
82
+
83
+ @pytest.mark.asyncio
84
+ async def test_copy_omits_trim_when_maxlen_disabled(tmp_path) -> None:
85
+ """A max_len of 0 leaves the stream unbounded (no trim option)."""
86
+ worker = _make_worker(tmp_path)
87
+ client = AsyncMock()
88
+ worker._client = client
89
+ settings = LogAggregatorSettings(max_len=0)
90
+
91
+ await worker._copy(settings, "scietex:modbus:abc123:log", [(b"message", b"boot")])
92
+
93
+ _, kwargs = client.xadd.await_args
94
+ assert kwargs["options"] is None
95
+
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_refresh_streams_prunes_vanished_and_excludes_self(tmp_path) -> None:
99
+ """SCAN results replace the source set; excluded services are skipped."""
100
+ worker = _make_worker(tmp_path)
101
+ client = AsyncMock()
102
+ worker._client = client
103
+ client.scan.return_value = (b"0", [b"scietex:modbus:abc123:log"])
104
+ settings = LogAggregatorSettings(
105
+ source_services=["ModbusService", "LogAggregatorService"],
106
+ exclude_services=["LogAggregatorService"],
107
+ )
108
+ last_ids = {"scietex:modbus:gone:log": "5-0"}
109
+
110
+ await worker._refresh_streams(settings, last_ids)
111
+
112
+ assert last_ids == {"scietex:modbus:abc123:log": "0"}
113
+ # Only the non-excluded service is scanned.
114
+ assert client.scan.await_count == 1
115
+ assert client.scan.await_args.kwargs["match"] == "scietex:ModbusService:*:log"
116
+
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_reader_loop_retries_expire_until_stream_exists(tmp_path) -> None:
120
+ """EXPIRE is retried each iteration until it succeeds (stream created)."""
121
+ worker = _make_worker(tmp_path)
122
+ client = AsyncMock()
123
+ worker._client = client
124
+ worker._settings = LogAggregatorSettings(source_services=["ModbusService"], ttl_seconds=3, scan_interval=0.0)
125
+ client.scan.return_value = (b"0", [b"scietex:ModbusService:abc:log"])
126
+ client.xread.return_value = None
127
+ # First EXPIRE misses (stream absent), second succeeds.
128
+ client.expire.side_effect = [False, True, True, True]
129
+
130
+ task = asyncio.create_task(worker._reader_loop())
131
+ await asyncio.sleep(0.2)
132
+ task.cancel()
133
+ with contextlib.suppress(asyncio.CancelledError):
134
+ await task
135
+
136
+ assert client.expire.await_count >= 2
137
+
138
+
139
+ @pytest.mark.asyncio
140
+ async def test_initialize_fails_on_bad_config(tmp_path, monkeypatch) -> None:
141
+ """A config load failure returns False without starting the reader loop."""
142
+
143
+ async def fake_initialize(self) -> bool:
144
+ return True
145
+
146
+ monkeypatch.setattr(ValkeyWorker, "initialize", fake_initialize)
147
+ worker = _make_worker(tmp_path)
148
+ monkeypatch.setattr(
149
+ "scietex.log_aggregator_service.aggregator_worker.read_aggregator_config",
150
+ lambda *a, **k: (_ for _ in ()).throw(RuntimeError("bad config")),
151
+ )
152
+
153
+ assert await worker.initialize() is False
154
+ assert worker._reader_task is None
@@ -0,0 +1,84 @@
1
+ """Tests for the log aggregator configuration models and YAML loader."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from scietex.log_aggregator_service.config import (
8
+ AGGREGATOR_CONFIG_FILE,
9
+ AGGREGATOR_CONFIG_SUBDIR,
10
+ LogAggregatorSettings,
11
+ read_aggregator_config,
12
+ )
13
+
14
+
15
+ def _config_path(conf_dir: Path) -> Path:
16
+ """The namespaced path the loader reads and writes."""
17
+ return conf_dir / AGGREGATOR_CONFIG_SUBDIR / AGGREGATOR_CONFIG_FILE
18
+
19
+
20
+ def test_defaults_are_sane() -> None:
21
+ """The default settings aggregate nothing and target the shared stream."""
22
+ settings = LogAggregatorSettings()
23
+
24
+ assert settings.source_services == []
25
+ assert settings.exclude_services == []
26
+ assert settings.target_stream == "scietex:log"
27
+ assert settings.max_len == 100_000
28
+ assert settings.ttl_seconds == 604800
29
+
30
+
31
+ def test_read_aggregator_config_writes_defaults_when_missing(tmp_path: Path) -> None:
32
+ """A missing file is created under the subdir with default settings."""
33
+ settings = read_aggregator_config(tmp_path)
34
+
35
+ assert settings == LogAggregatorSettings()
36
+ assert _config_path(tmp_path).is_file()
37
+
38
+
39
+ def test_read_aggregator_config_malformed_raises_and_leaves_file(tmp_path: Path) -> None:
40
+ """An unparseable file raises RuntimeError and is left untouched."""
41
+ path = _config_path(tmp_path)
42
+ path.parent.mkdir(parents=True)
43
+ path.write_text("unterminated: [flow\n")
44
+
45
+ with pytest.raises(RuntimeError):
46
+ read_aggregator_config(tmp_path)
47
+
48
+ assert path.read_text() == "unterminated: [flow\n"
49
+
50
+
51
+ def test_read_aggregator_config_none_dir_raises() -> None:
52
+ """A None conf_dir is rejected with RuntimeError."""
53
+ with pytest.raises(RuntimeError):
54
+ read_aggregator_config(None)
55
+
56
+
57
+ def test_read_aggregator_config_missing_without_create_raises(tmp_path: Path) -> None:
58
+ """A missing file with create_default=False raises and writes nothing."""
59
+ with pytest.raises(RuntimeError):
60
+ read_aggregator_config(tmp_path, create_default=False)
61
+
62
+ assert not _config_path(tmp_path).exists()
63
+
64
+
65
+ def test_read_aggregator_config_roundtrips_values(tmp_path: Path) -> None:
66
+ """Explicit values survive a write/read round-trip."""
67
+ path = _config_path(tmp_path)
68
+ path.parent.mkdir(parents=True)
69
+ path.write_text(
70
+ "source_services:\n"
71
+ " - ModbusService\n"
72
+ "exclude_services:\n"
73
+ " - LogAggregatorService\n"
74
+ "target_stream: scietex:log\n"
75
+ "max_len: 500\n"
76
+ "ttl_seconds: 60\n"
77
+ )
78
+
79
+ settings = read_aggregator_config(tmp_path)
80
+
81
+ assert settings.source_services == ["ModbusService"]
82
+ assert settings.exclude_services == ["LogAggregatorService"]
83
+ assert settings.max_len == 500
84
+ assert settings.ttl_seconds == 60
@@ -0,0 +1,70 @@
1
+ """Tests for the entry point's CLI/env-var configuration."""
2
+
3
+ from scietex.log_aggregator_service.config import AGGREGATOR_CONFIG_SUBDIR
4
+ from scietex.log_aggregator_service.run_worker import (
5
+ DEFAULT_LOGGING_LEVEL,
6
+ DEFAULT_SERVICE_NAME,
7
+ ENV_LOGGING_LEVEL,
8
+ ENV_SERVICE_NAME,
9
+ _build_config,
10
+ _build_parser,
11
+ )
12
+ from scietex.log_aggregator_service.version import __version__
13
+
14
+
15
+ def test_defaults_when_no_env_or_flags(monkeypatch) -> None:
16
+ """Without env vars or flags, the built-in defaults apply."""
17
+ monkeypatch.delenv(ENV_SERVICE_NAME, raising=False)
18
+ monkeypatch.delenv(ENV_LOGGING_LEVEL, raising=False)
19
+
20
+ args = _build_parser().parse_args([])
21
+
22
+ assert args.service_name == DEFAULT_SERVICE_NAME
23
+ assert args.logging_level == DEFAULT_LOGGING_LEVEL
24
+ assert args.conf_dir is None
25
+
26
+
27
+ def test_env_vars_supply_defaults(monkeypatch) -> None:
28
+ """Environment variables override the built-in defaults."""
29
+ monkeypatch.setenv(ENV_SERVICE_NAME, "FromEnv")
30
+ monkeypatch.setenv(ENV_LOGGING_LEVEL, "DEBUG")
31
+
32
+ args = _build_parser().parse_args([])
33
+
34
+ assert args.service_name == "FromEnv"
35
+ assert args.logging_level == "DEBUG"
36
+
37
+
38
+ def test_cli_flags_win_over_env(monkeypatch) -> None:
39
+ """An explicit CLI flag takes precedence over the environment."""
40
+ monkeypatch.setenv(ENV_SERVICE_NAME, "FromEnv")
41
+ monkeypatch.setenv(ENV_LOGGING_LEVEL, "DEBUG")
42
+
43
+ args = _build_parser().parse_args(["--service-name", "FromFlag", "--logging-level", "WARNING"])
44
+
45
+ assert args.service_name == "FromFlag"
46
+ assert args.logging_level == "WARNING"
47
+
48
+
49
+ def test_build_config_namespaces_framework_snapshot(monkeypatch) -> None:
50
+ """The framework snapshot is namespaced under the aggregator subdir."""
51
+ monkeypatch.delenv(ENV_SERVICE_NAME, raising=False)
52
+ monkeypatch.delenv(ENV_LOGGING_LEVEL, raising=False)
53
+ args = _build_parser().parse_args([])
54
+
55
+ config = _build_config(args)
56
+
57
+ assert config.config_file == f"{AGGREGATOR_CONFIG_SUBDIR}/config.yml"
58
+ assert config.remote_config_enabled is True
59
+ assert config.valkey_config is None
60
+
61
+
62
+ def test_build_config_reports_package_version(monkeypatch) -> None:
63
+ """The worker reports its own package version, not the framework default."""
64
+ monkeypatch.delenv(ENV_SERVICE_NAME, raising=False)
65
+ monkeypatch.delenv(ENV_LOGGING_LEVEL, raising=False)
66
+ args = _build_parser().parse_args([])
67
+
68
+ config = _build_config(args)
69
+
70
+ assert config.version == __version__