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.
- scietex_log_aggregator_service-0.1.0/LICENSE +21 -0
- scietex_log_aggregator_service-0.1.0/PKG-INFO +136 -0
- scietex_log_aggregator_service-0.1.0/README.md +112 -0
- scietex_log_aggregator_service-0.1.0/pyproject.toml +44 -0
- scietex_log_aggregator_service-0.1.0/setup.cfg +4 -0
- scietex_log_aggregator_service-0.1.0/src/scietex/log_aggregator_service/__init__.py +21 -0
- scietex_log_aggregator_service-0.1.0/src/scietex/log_aggregator_service/aggregator_worker.py +227 -0
- scietex_log_aggregator_service-0.1.0/src/scietex/log_aggregator_service/config.py +108 -0
- scietex_log_aggregator_service-0.1.0/src/scietex/log_aggregator_service/py.typed +0 -0
- scietex_log_aggregator_service-0.1.0/src/scietex/log_aggregator_service/run_worker.py +74 -0
- scietex_log_aggregator_service-0.1.0/src/scietex/log_aggregator_service/version.py +3 -0
- scietex_log_aggregator_service-0.1.0/src/scietex.log_aggregator_service.egg-info/PKG-INFO +136 -0
- scietex_log_aggregator_service-0.1.0/src/scietex.log_aggregator_service.egg-info/SOURCES.txt +18 -0
- scietex_log_aggregator_service-0.1.0/src/scietex.log_aggregator_service.egg-info/dependency_links.txt +1 -0
- scietex_log_aggregator_service-0.1.0/src/scietex.log_aggregator_service.egg-info/entry_points.txt +2 -0
- scietex_log_aggregator_service-0.1.0/src/scietex.log_aggregator_service.egg-info/requires.txt +12 -0
- scietex_log_aggregator_service-0.1.0/src/scietex.log_aggregator_service.egg-info/top_level.txt +1 -0
- scietex_log_aggregator_service-0.1.0/tests/test_aggregator_worker.py +154 -0
- scietex_log_aggregator_service-0.1.0/tests/test_config.py +84 -0
- 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,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
|
|
File without changes
|
|
@@ -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,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 @@
|
|
|
1
|
+
|
scietex_log_aggregator_service-0.1.0/src/scietex.log_aggregator_service.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
scietex
|
|
@@ -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__
|