Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab9cd43282
|
||
|
|
8874ef9c43
|
||
|
|
06d233054b
|
@@ -1,76 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, dev]
|
|
||||||
tags: ["v*"]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.13"
|
|
||||||
- name: Install uv
|
|
||||||
run: pip install uv --no-cache-dir
|
|
||||||
- name: Install dependencies
|
|
||||||
run: uv sync --extra dev
|
|
||||||
- name: Ruff check
|
|
||||||
run: uv run ruff check src/ tests/
|
|
||||||
- name: Ruff format
|
|
||||||
run: uv run ruff format --check src/ tests/
|
|
||||||
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.13"
|
|
||||||
- name: Install uv
|
|
||||||
run: pip install uv --no-cache-dir
|
|
||||||
- name: Install dependencies
|
|
||||||
run: uv sync --extra dev
|
|
||||||
- name: Run tests
|
|
||||||
run: uv run pytest tests/ -v
|
|
||||||
|
|
||||||
release:
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
needs: [lint, test]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.13"
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
run: pip install uv --no-cache-dir
|
|
||||||
|
|
||||||
- name: Build wheel
|
|
||||||
run: uv build
|
|
||||||
|
|
||||||
- name: Install system deps
|
|
||||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq fakeroot dpkg-dev
|
|
||||||
|
|
||||||
- name: Build .deb package
|
|
||||||
run: bash scripts/build-deb.sh "${GITHUB_REF_NAME#v}"
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
name: "daena ${{ github.ref_name }}"
|
|
||||||
body: |
|
|
||||||
## daena ${{ github.ref_name }}
|
|
||||||
|
|
||||||
See `CHANGELOG.md` for details.
|
|
||||||
files: |
|
|
||||||
dist/*.whl
|
|
||||||
dist/*.deb
|
|
||||||
fail_on_unmatched_files: true
|
|
||||||
@@ -28,6 +28,8 @@ data/
|
|||||||
logs/
|
logs/
|
||||||
*.pid
|
*.pid
|
||||||
*.sock
|
*.sock
|
||||||
|
/daena.yaml
|
||||||
|
/deploy/graylog/
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ COPY proto/ /app/proto/
|
|||||||
ENV PATH="/app/.venv/bin:$PATH" \
|
ENV PATH="/app/.venv/bin:$PATH" \
|
||||||
DAENA_CONFIG_PATH=/etc/daena/daena.yaml
|
DAENA_CONFIG_PATH=/etc/daena/daena.yaml
|
||||||
|
|
||||||
EXPOSE 8642
|
EXPOSE 8642 8643
|
||||||
|
|
||||||
USER daena
|
USER daena
|
||||||
ENTRYPOINT ["daena-serve"]
|
ENTRYPOINT ["daena-serve"]
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ Daena is a modular, extensible Linux service that:
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Sources → [Processors] → Pipeline → Persistence (LMDB) → Sink Dispatcher → Remote
|
Sources → [Processors] → LMDB (one delivery per sink) → dispatchers
|
||||||
↻ retry queue
|
↻ per-sink retry, no head-of-line sleep
|
||||||
```
|
```
|
||||||
|
|
||||||
Key design decisions:
|
Key design decisions:
|
||||||
@@ -67,8 +67,9 @@ asyncio.run(main())
|
|||||||
# Run tests
|
# Run tests
|
||||||
uv run pytest tests/ -v
|
uv run pytest tests/ -v
|
||||||
|
|
||||||
# Start gRPC server
|
# Start gRPC server. Startup prints the pipeline. --verbose logs each record.
|
||||||
uv run daena-serve
|
uv run daena-serve
|
||||||
|
uv run daena-serve --verbose
|
||||||
|
|
||||||
# Use the CLI (point at a running daena gRPC API)
|
# Use the CLI (point at a running daena gRPC API)
|
||||||
uv run daena status
|
uv run daena status
|
||||||
@@ -160,14 +161,15 @@ The gRPC API runs on `127.0.0.1:8642` with the following RPCs:
|
|||||||
| RPC | Description |
|
| RPC | Description |
|
||||||
|-----|-------------|
|
|-----|-------------|
|
||||||
| `GetStatus` | Service status and version |
|
| `GetStatus` | Service status and version |
|
||||||
| `GetHealth` | Health check (backend, plugins, runtime) |
|
| `GetHealth` | Health, including oldest inflight age (`ok`, `degraded`, `down`) |
|
||||||
| `GetConfig` | Loaded configuration as JSON |
|
| `GetConfig` | Loaded configuration as JSON |
|
||||||
| `ListPlugins` | Registered sources, processors, and sinks |
|
| `ListPlugins` | Registered sources, processors, and sinks |
|
||||||
| `GetQueueState` | Queue state counts (pending, delivered, failed, dead) |
|
| `GetQueueState` | Delivery counts, including a per-sink breakdown |
|
||||||
| `ListPendingRecords` | Pending records with pagination |
|
| `ListPendingRecords` | Pending records with pagination |
|
||||||
| `ListDeadRecords` | Dead-letter records with pagination |
|
| `ListDeadRecords` | Dead-letter records with pagination |
|
||||||
| `ListSinks` | Configured sinks |
|
| `ListSinks` | Configured sinks |
|
||||||
| `ReloadConfig` | Reload service configuration at runtime |
|
| `ReloadConfig` | Re-read the config file and restart the pipeline |
|
||||||
|
| `ReplayDead` | Requeue dead-letter deliveries |
|
||||||
|
|
||||||
### Reflect the gRPC service
|
### Reflect the gRPC service
|
||||||
|
|
||||||
@@ -182,9 +184,12 @@ grpcurl -plaintext 127.0.0.1:8642 describe daena.v1.Daena
|
|||||||
Daena provides a CLI that connects to the gRPC server:
|
Daena provides a CLI that connects to the gRPC server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
daena status # Show service status
|
daena --target 127.0.0.1:8642 status
|
||||||
daena health # Check health
|
daena health # ok / degraded / down
|
||||||
daena queue # Inspect queue state
|
daena queue # Inspect queue state, per sink
|
||||||
|
daena records pending
|
||||||
|
daena records dead
|
||||||
|
daena replay # Requeue dead-letter deliveries
|
||||||
daena sinks # List configured sinks
|
daena sinks # List configured sinks
|
||||||
daena plugins # List registered plugins
|
daena plugins # List registered plugins
|
||||||
daena config # Show loaded configuration
|
daena config # Show loaded configuration
|
||||||
@@ -250,6 +255,21 @@ my_source = "my_sources:MySource"
|
|||||||
|
|
||||||
Same pattern applies for `Processor` and `Sink` base classes.
|
Same pattern applies for `Processor` and `Sink` base classes.
|
||||||
|
|
||||||
|
Built-in plugins:
|
||||||
|
|
||||||
|
| Kind | Name | Role |
|
||||||
|
|------|------|------|
|
||||||
|
| source | `dummy` | Interval test events (`count` stops after N) |
|
||||||
|
| source | `journald` | `journalctl --follow` with an optional cursor file |
|
||||||
|
| processor | `passthrough` | No-op |
|
||||||
|
| processor | `enricher` | Hostname and static fields |
|
||||||
|
| processor | `filter` | Drop or keep by field equality |
|
||||||
|
| sink | `dummy` | Accept locally |
|
||||||
|
| sink | `http` | JSON POST |
|
||||||
|
| sink | `loki` | Loki push API |
|
||||||
|
|
||||||
|
`SIGHUP` re-reads the config file (systemd `ExecReload`). `SIGTERM` drains and closes LMDB. Prometheus text is served at `http://127.0.0.1:8643/metrics` when metrics are enabled. Each sink has its own delivery state, and a failed attempt is scheduled with `next_retry_at` instead of blocking the dispatcher.
|
||||||
|
|
||||||
## Branch workflow
|
## Branch workflow
|
||||||
|
|
||||||
- `main` — stable integration branch
|
- `main` — stable integration branch
|
||||||
|
|||||||
@@ -8,18 +8,59 @@ pid_file: /run/daena/daena.pid
|
|||||||
logging:
|
logging:
|
||||||
level: info
|
level: info
|
||||||
format: json
|
format: json
|
||||||
|
verbose: false
|
||||||
|
|
||||||
api:
|
api:
|
||||||
grpc:
|
grpc:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: 8642
|
port: 8642
|
||||||
max_workers: 10
|
max_workers: 10
|
||||||
|
metrics:
|
||||||
|
enabled: true
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8643
|
||||||
|
|
||||||
pipeline:
|
pipeline:
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
flush_interval: 5.0
|
flush_interval: 5.0
|
||||||
max_queue_size: 10000
|
max_queue_size: 10000
|
||||||
|
|
||||||
|
retention:
|
||||||
|
delivered_ttl_seconds: 86400
|
||||||
|
dead_ttl_seconds: 604800
|
||||||
|
interval_seconds: 60
|
||||||
|
|
||||||
|
health:
|
||||||
|
max_pending_age_seconds: 300
|
||||||
|
|
||||||
|
# journald source, enricher, filter, and loki sink are registered plugins.
|
||||||
|
# Example:
|
||||||
|
# sources:
|
||||||
|
# - name: system
|
||||||
|
# type: journald
|
||||||
|
# options:
|
||||||
|
# units: ["ssh.service"]
|
||||||
|
# cursor_path: /var/lib/daena/journal.cursor
|
||||||
|
# processors:
|
||||||
|
# - name: host
|
||||||
|
# type: enricher
|
||||||
|
# options:
|
||||||
|
# hostname: true
|
||||||
|
# static:
|
||||||
|
# env: prod
|
||||||
|
# - name: drop_debug
|
||||||
|
# type: filter
|
||||||
|
# options:
|
||||||
|
# drop:
|
||||||
|
# body.priority: "7"
|
||||||
|
# sinks:
|
||||||
|
# - name: loki
|
||||||
|
# type: loki
|
||||||
|
# options:
|
||||||
|
# url: http://127.0.0.1:3100/loki/api/v1/push
|
||||||
|
# labels:
|
||||||
|
# job: daena
|
||||||
|
|
||||||
persistence:
|
persistence:
|
||||||
backend: lmdb
|
backend: lmdb
|
||||||
lmdb:
|
lmdb:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ service Daena {
|
|||||||
rpc ListDeadRecords(Pagination) returns (RecordList);
|
rpc ListDeadRecords(Pagination) returns (RecordList);
|
||||||
rpc ListSinks(Empty) returns (SinkList);
|
rpc ListSinks(Empty) returns (SinkList);
|
||||||
rpc ReloadConfig(Empty) returns (ReloadResponse);
|
rpc ReloadConfig(Empty) returns (ReloadResponse);
|
||||||
|
rpc ReplayDead(ReplayRequest) returns (ReplayResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
message Empty {}
|
message Empty {}
|
||||||
@@ -27,6 +28,9 @@ message HealthResponse {
|
|||||||
bool running = 2;
|
bool running = 2;
|
||||||
bool backend = 3;
|
bool backend = 3;
|
||||||
bool plugins_loaded = 4;
|
bool plugins_loaded = 4;
|
||||||
|
string status = 5;
|
||||||
|
int64 oldest_pending_age_seconds = 6;
|
||||||
|
string detail = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ConfigResponse {
|
message ConfigResponse {
|
||||||
@@ -48,12 +52,23 @@ message PluginListResponse {
|
|||||||
PluginCategory sinks = 3;
|
PluginCategory sinks = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message SinkQueueState {
|
||||||
|
string sink = 1;
|
||||||
|
int64 pending = 2;
|
||||||
|
int64 delivering = 3;
|
||||||
|
int64 delivered = 4;
|
||||||
|
int64 failed = 5;
|
||||||
|
int64 dead = 6;
|
||||||
|
}
|
||||||
|
|
||||||
message QueueStateResponse {
|
message QueueStateResponse {
|
||||||
int64 pending = 1;
|
int64 pending = 1;
|
||||||
int64 delivered = 2;
|
int64 delivered = 2;
|
||||||
int64 failed = 3;
|
int64 failed = 3;
|
||||||
int64 dead = 4;
|
int64 dead = 4;
|
||||||
int64 total = 5;
|
int64 total = 5;
|
||||||
|
repeated SinkQueueState sinks = 6;
|
||||||
|
int64 delivering = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
message Pagination {
|
message Pagination {
|
||||||
@@ -61,6 +76,15 @@ message Pagination {
|
|||||||
int32 offset = 2;
|
int32 offset = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message DeliveryData {
|
||||||
|
string sink = 1;
|
||||||
|
string state = 2;
|
||||||
|
int32 retry_count = 3;
|
||||||
|
string next_retry_at = 4;
|
||||||
|
string last_error = 5;
|
||||||
|
string updated_at = 6;
|
||||||
|
}
|
||||||
|
|
||||||
message RecordData {
|
message RecordData {
|
||||||
string record_id = 1;
|
string record_id = 1;
|
||||||
string source = 2;
|
string source = 2;
|
||||||
@@ -74,6 +98,7 @@ message RecordData {
|
|||||||
string next_retry_at = 10;
|
string next_retry_at = 10;
|
||||||
string last_error = 11;
|
string last_error = 11;
|
||||||
string destination = 12;
|
string destination = 12;
|
||||||
|
repeated DeliveryData deliveries = 13;
|
||||||
}
|
}
|
||||||
|
|
||||||
message RecordList {
|
message RecordList {
|
||||||
@@ -94,3 +119,13 @@ message ReloadResponse {
|
|||||||
bool success = 1;
|
bool success = 1;
|
||||||
string message = 2;
|
string message = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ReplayRequest {
|
||||||
|
string record_id = 1;
|
||||||
|
string sink = 2;
|
||||||
|
int32 limit = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReplayResponse {
|
||||||
|
int32 replayed = 1;
|
||||||
|
}
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "daena"
|
name = "daena"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "Collect, buffer, and forward logs and events from Linux to central observability backends"
|
description = "Collect, buffer, and forward logs and events from Linux to central observability backends"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
@@ -50,13 +50,17 @@ daena-serve = "daena.app:serve"
|
|||||||
|
|
||||||
[project.entry-points."daena.sources"]
|
[project.entry-points."daena.sources"]
|
||||||
dummy = "daena.pipeline.sources.dummy:DummySource"
|
dummy = "daena.pipeline.sources.dummy:DummySource"
|
||||||
|
journald = "daena.pipeline.sources.journald:JournaldSource"
|
||||||
|
|
||||||
[project.entry-points."daena.sinks"]
|
[project.entry-points."daena.sinks"]
|
||||||
dummy = "daena.pipeline.sinks.dummy:DummySink"
|
dummy = "daena.pipeline.sinks.dummy:DummySink"
|
||||||
http = "daena.pipeline.sinks.http:HTTPSink"
|
http = "daena.pipeline.sinks.http:HTTPSink"
|
||||||
|
loki = "daena.pipeline.sinks.loki:LokiSink"
|
||||||
|
|
||||||
[project.entry-points."daena.processors"]
|
[project.entry-points."daena.processors"]
|
||||||
passthrough = "daena.pipeline.processors.passthrough:PassthroughProcessor"
|
passthrough = "daena.pipeline.processors.passthrough:PassthroughProcessor"
|
||||||
|
enricher = "daena.pipeline.processors.enricher:EnricherProcessor"
|
||||||
|
filter = "daena.pipeline.processors.filter:FilterProcessor"
|
||||||
|
|
||||||
[tool.pyproject-fmt]
|
[tool.pyproject-fmt]
|
||||||
max_blank_lines = 1
|
max_blank_lines = 1
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.1.0}"
|
VERSION="${1:-0.2.0}"
|
||||||
PACKAGE="daena_${VERSION}_all"
|
PACKAGE="daena_${VERSION}_all"
|
||||||
BUILDROOT="/tmp/build/${PACKAGE}"
|
BUILDROOT="/tmp/build/${PACKAGE}"
|
||||||
DISTDIR="dist"
|
DISTDIR="dist"
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.1.0"
|
__version__ = "0.2.0"
|
||||||
|
|||||||
+111
-49
@@ -1,33 +1,47 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import grpc
|
import grpc
|
||||||
import grpc.aio
|
import grpc.aio
|
||||||
|
|
||||||
|
from daena._version import __version__
|
||||||
from daena.api.pb2 import daena_pb2 as pb2
|
from daena.api.pb2 import daena_pb2 as pb2
|
||||||
from daena.api.pb2 import daena_pb2_grpc as pb2_grpc
|
from daena.api.pb2 import daena_pb2_grpc as pb2_grpc
|
||||||
from daena.core.runtime import DaenaRuntime
|
from daena.core.runtime import DaenaRuntime
|
||||||
from daena.logutil import get_logger
|
from daena.logutil import get_logger
|
||||||
from daena.persistence.models import RecordState
|
from daena.persistence.models import RecordState, StoredRecord
|
||||||
|
|
||||||
log = get_logger("daena.grpc")
|
log = get_logger("daena.grpc")
|
||||||
|
|
||||||
|
|
||||||
def _record_to_proto(record) -> pb2.RecordData:
|
def _record_to_proto(record: StoredRecord) -> pb2.RecordData:
|
||||||
|
deliveries = [
|
||||||
|
pb2.DeliveryData(
|
||||||
|
sink=name,
|
||||||
|
state=item.state.value,
|
||||||
|
retry_count=item.retry_count,
|
||||||
|
next_retry_at=item.next_retry_at or "",
|
||||||
|
last_error=item.last_error or "",
|
||||||
|
updated_at=item.updated_at or "",
|
||||||
|
)
|
||||||
|
for name, item in sorted(record.deliveries.items())
|
||||||
|
]
|
||||||
return pb2.RecordData(
|
return pb2.RecordData(
|
||||||
record_id=record.record_id,
|
record_id=record.record_id,
|
||||||
source=record.source,
|
source=record.source,
|
||||||
record_type=record.record_type,
|
record_type=record.record_type,
|
||||||
body=json.dumps(record.body),
|
body=json.dumps(record.body),
|
||||||
metadata=json.dumps(record.metadata),
|
metadata=json.dumps(record.metadata),
|
||||||
state=record.state.value if hasattr(record.state, "value") else record.state,
|
state=record.state.value,
|
||||||
created_at=record.created_at or "",
|
created_at=record.created_at or "",
|
||||||
updated_at=record.updated_at or "",
|
updated_at=record.updated_at or "",
|
||||||
retry_count=record.retry_count,
|
retry_count=record.retry_count,
|
||||||
next_retry_at=record.next_retry_at or "",
|
next_retry_at=record.next_retry_at or "",
|
||||||
last_error=record.last_error or "",
|
last_error=record.last_error or "",
|
||||||
destination=record.destination or "",
|
destination=record.destination or "",
|
||||||
|
deliveries=deliveries,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -39,16 +53,20 @@ class DaenaServicer(pb2_grpc.DaenaServicer):
|
|||||||
async def GetStatus(self, request, context):
|
async def GetStatus(self, request, context):
|
||||||
return pb2.StatusResponse(
|
return pb2.StatusResponse(
|
||||||
service="daena",
|
service="daena",
|
||||||
version="0.1.0",
|
version=__version__,
|
||||||
running=self._runtime._running,
|
running=self._runtime.running,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def GetHealth(self, request, context):
|
async def GetHealth(self, request, context):
|
||||||
|
report = await asyncio.to_thread(self._runtime.health)
|
||||||
return pb2.HealthResponse(
|
return pb2.HealthResponse(
|
||||||
healthy=self._runtime._running,
|
healthy=report.healthy,
|
||||||
running=self._runtime._running,
|
running=report.running,
|
||||||
backend=self._runtime.backend is not None,
|
backend=report.backend,
|
||||||
plugins_loaded=bool(self._runtime.registry.list_sources()),
|
plugins_loaded=report.plugins_loaded,
|
||||||
|
status=report.status,
|
||||||
|
oldest_pending_age_seconds=report.oldest_pending_age_seconds,
|
||||||
|
detail=report.detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def GetConfig(self, request, context):
|
async def GetConfig(self, request, context):
|
||||||
@@ -56,12 +74,21 @@ class DaenaServicer(pb2_grpc.DaenaServicer):
|
|||||||
raw = {
|
raw = {
|
||||||
"data_dir": cfg.data_dir,
|
"data_dir": cfg.data_dir,
|
||||||
"log_dir": cfg.log_dir,
|
"log_dir": cfg.log_dir,
|
||||||
"api": {"grpc": {"host": cfg.api.grpc.host, "port": cfg.api.grpc.port}},
|
"api": {
|
||||||
|
"grpc": {
|
||||||
|
"host": cfg.api.grpc.host,
|
||||||
|
"port": cfg.api.grpc.port,
|
||||||
|
"max_workers": cfg.api.grpc.max_workers,
|
||||||
|
},
|
||||||
|
"metrics": cfg.api.metrics.model_dump(),
|
||||||
|
},
|
||||||
"pipeline": cfg.pipeline.model_dump(),
|
"pipeline": cfg.pipeline.model_dump(),
|
||||||
"persistence": cfg.persistence.model_dump(),
|
"persistence": cfg.persistence.model_dump(),
|
||||||
"sources": [s.model_dump() for s in cfg.sources],
|
"retention": cfg.retention.model_dump(),
|
||||||
"processors": [p.model_dump() for p in cfg.processors],
|
"health": cfg.health.model_dump(),
|
||||||
"sinks": [s.model_dump() for s in cfg.sinks],
|
"sources": [item.model_dump() for item in cfg.sources],
|
||||||
|
"processors": [item.model_dump() for item in cfg.processors],
|
||||||
|
"sinks": [item.model_dump() for item in cfg.sinks],
|
||||||
}
|
}
|
||||||
return pb2.ConfigResponse(config_json=json.dumps(raw))
|
return pb2.ConfigResponse(config_json=json.dumps(raw))
|
||||||
|
|
||||||
@@ -69,11 +96,12 @@ class DaenaServicer(pb2_grpc.DaenaServicer):
|
|||||||
registry = self._runtime.registry
|
registry = self._runtime.registry
|
||||||
all_plugins = registry.all_plugins()
|
all_plugins = registry.all_plugins()
|
||||||
|
|
||||||
def _cat(key: str):
|
def _cat(key: str) -> pb2.PluginCategory:
|
||||||
items = all_plugins.get(key, {})
|
items = all_plugins.get(key, {})
|
||||||
return pb2.PluginCategory(
|
infos = [
|
||||||
items=[pb2.PluginInfo(name=n, class_name=cls.__name__) for n, cls in items.items()]
|
pb2.PluginInfo(name=name, class_name=cls.__name__) for name, cls in items.items()
|
||||||
)
|
]
|
||||||
|
return pb2.PluginCategory(items=infos)
|
||||||
|
|
||||||
return pb2.PluginListResponse(
|
return pb2.PluginListResponse(
|
||||||
sources=_cat("sources"),
|
sources=_cat("sources"),
|
||||||
@@ -85,69 +113,103 @@ class DaenaServicer(pb2_grpc.DaenaServicer):
|
|||||||
backend = self._runtime.backend
|
backend = self._runtime.backend
|
||||||
if backend is None:
|
if backend is None:
|
||||||
return pb2.QueueStateResponse()
|
return pb2.QueueStateResponse()
|
||||||
|
pending, delivering, delivered, failed, dead, total, sinks = await asyncio.to_thread(
|
||||||
|
self._queue_counts
|
||||||
|
)
|
||||||
return pb2.QueueStateResponse(
|
return pb2.QueueStateResponse(
|
||||||
pending=backend.count(RecordState.pending),
|
pending=pending,
|
||||||
delivered=backend.count(RecordState.delivered),
|
delivering=delivering,
|
||||||
failed=backend.count(RecordState.failed),
|
delivered=delivered,
|
||||||
dead=backend.count(RecordState.dead),
|
failed=failed,
|
||||||
total=backend.count(),
|
dead=dead,
|
||||||
|
total=total,
|
||||||
|
sinks=[
|
||||||
|
pb2.SinkQueueState(
|
||||||
|
sink=name,
|
||||||
|
pending=counts.get("pending", 0),
|
||||||
|
delivering=counts.get("delivering", 0),
|
||||||
|
delivered=counts.get("delivered", 0),
|
||||||
|
failed=counts.get("failed", 0),
|
||||||
|
dead=counts.get("dead", 0),
|
||||||
|
)
|
||||||
|
for name, counts in sorted(sinks.items())
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _queue_counts(
|
||||||
|
self,
|
||||||
|
) -> tuple[int, int, int, int, int, int, dict[str, dict[str, int]]]:
|
||||||
|
backend = self._runtime.backend
|
||||||
|
if backend is None:
|
||||||
|
return (0, 0, 0, 0, 0, 0, {})
|
||||||
|
return (
|
||||||
|
backend.count(RecordState.pending),
|
||||||
|
backend.count(RecordState.delivering),
|
||||||
|
backend.count(RecordState.delivered),
|
||||||
|
backend.count(RecordState.failed),
|
||||||
|
backend.count(RecordState.dead),
|
||||||
|
backend.count(),
|
||||||
|
backend.sink_counts(),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def ListPendingRecords(self, request, context):
|
async def ListPendingRecords(self, request, context):
|
||||||
backend = self._runtime.backend
|
return await self._list(RecordState.pending, request.limit or 100, request.offset or 0)
|
||||||
if backend is None:
|
|
||||||
return pb2.RecordList(records=[], count=0)
|
|
||||||
records = backend.list_by_state(
|
|
||||||
RecordState.pending,
|
|
||||||
limit=request.limit or 100,
|
|
||||||
offset=request.offset or 0,
|
|
||||||
)
|
|
||||||
return pb2.RecordList(
|
|
||||||
records=[_record_to_proto(r) for r in records],
|
|
||||||
count=len(records),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def ListDeadRecords(self, request, context):
|
async def ListDeadRecords(self, request, context):
|
||||||
|
return await self._list(RecordState.dead, request.limit or 100, request.offset or 0)
|
||||||
|
|
||||||
|
async def _list(self, state: RecordState, limit: int, offset: int) -> pb2.RecordList:
|
||||||
backend = self._runtime.backend
|
backend = self._runtime.backend
|
||||||
if backend is None:
|
if backend is None:
|
||||||
return pb2.RecordList(records=[], count=0)
|
return pb2.RecordList(records=[], count=0)
|
||||||
records = backend.list_by_state(
|
records = await asyncio.to_thread(backend.list_by_state, state, limit, offset)
|
||||||
RecordState.dead,
|
payload = [_record_to_proto(record) for record in records]
|
||||||
limit=request.limit or 100,
|
return pb2.RecordList(records=payload, count=len(payload))
|
||||||
offset=request.offset or 0,
|
|
||||||
)
|
|
||||||
return pb2.RecordList(
|
|
||||||
records=[_record_to_proto(r) for r in records],
|
|
||||||
count=len(records),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def ListSinks(self, request, context):
|
async def ListSinks(self, request, context):
|
||||||
pipeline = self._runtime.pipeline
|
pipeline = self._runtime.pipeline
|
||||||
if pipeline is None:
|
if pipeline is None:
|
||||||
return pb2.SinkList(sinks=[])
|
return pb2.SinkList(sinks=[])
|
||||||
return pb2.SinkList(
|
types = pipeline.sink_types()
|
||||||
sinks=[pb2.SinkInfo(name=s.name, type=type(s).__name__) for s in pipeline.sinks]
|
infos = [
|
||||||
)
|
pb2.SinkInfo(name=sink.name, type=types.get(sink.name, "")) for sink in pipeline.sinks
|
||||||
|
]
|
||||||
|
return pb2.SinkList(sinks=infos)
|
||||||
|
|
||||||
async def ReloadConfig(self, request, context):
|
async def ReloadConfig(self, request, context):
|
||||||
try:
|
try:
|
||||||
await self._runtime.reload()
|
await self._runtime.reload()
|
||||||
return pb2.ReloadResponse(success=True, message="Configuration reloaded")
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return pb2.ReloadResponse(success=False, message=str(exc))
|
return pb2.ReloadResponse(success=False, message=str(exc))
|
||||||
|
return pb2.ReloadResponse(success=True, message="Configuration reloaded")
|
||||||
|
|
||||||
|
async def ReplayDead(self, request, context):
|
||||||
|
replayed = await self._runtime.replay_dead(
|
||||||
|
record_id=request.record_id or None,
|
||||||
|
sink=request.sink or None,
|
||||||
|
limit=request.limit or 100,
|
||||||
|
)
|
||||||
|
return pb2.ReplayResponse(replayed=replayed)
|
||||||
|
|
||||||
|
|
||||||
class DaenaGRPCServer:
|
class DaenaGRPCServer:
|
||||||
"""Manages the gRPC server lifecycle."""
|
"""Manages the gRPC server lifecycle."""
|
||||||
|
|
||||||
def __init__(self, runtime: DaenaRuntime, host: str = "127.0.0.1", port: int = 8642) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
runtime: DaenaRuntime,
|
||||||
|
host: str = "127.0.0.1",
|
||||||
|
port: int = 8642,
|
||||||
|
maximum_concurrent_rpcs: int | None = None,
|
||||||
|
) -> None:
|
||||||
self._runtime = runtime
|
self._runtime = runtime
|
||||||
self._host = host
|
self._host = host
|
||||||
self._port = port
|
self._port = port
|
||||||
|
self._maximum_concurrent_rpcs = maximum_concurrent_rpcs
|
||||||
self._server: grpc.aio.Server | None = None
|
self._server: grpc.aio.Server | None = None
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
self._server = grpc.aio.server()
|
self._server = grpc.aio.server(maximum_concurrent_rpcs=self._maximum_concurrent_rpcs)
|
||||||
servicer = DaenaServicer(self._runtime)
|
servicer = DaenaServicer(self._runtime)
|
||||||
pb2_grpc.add_DaenaServicer_to_server(servicer, self._server)
|
pb2_grpc.add_DaenaServicer_to_server(servicer, self._server)
|
||||||
address = f"{self._host}:{self._port}"
|
address = f"{self._host}:{self._port}"
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MetricsSnapshot:
|
||||||
|
running: bool
|
||||||
|
stored_records: int
|
||||||
|
oldest_inflight_seconds: int
|
||||||
|
deliveries: dict[tuple[str, str], int]
|
||||||
|
sink_results: dict[str, dict[str, int]]
|
||||||
|
|
||||||
|
|
||||||
|
def render_prometheus(snapshot: MetricsSnapshot) -> str:
|
||||||
|
lines = [
|
||||||
|
"# HELP daena_up 1 when the runtime is running",
|
||||||
|
"# TYPE daena_up gauge",
|
||||||
|
f"daena_up {1 if snapshot.running else 0}",
|
||||||
|
"# HELP daena_records Stored records",
|
||||||
|
"# TYPE daena_records gauge",
|
||||||
|
f"daena_records {snapshot.stored_records}",
|
||||||
|
"# HELP daena_oldest_inflight_seconds Age of the oldest undelivered record",
|
||||||
|
"# TYPE daena_oldest_inflight_seconds gauge",
|
||||||
|
f"daena_oldest_inflight_seconds {snapshot.oldest_inflight_seconds}",
|
||||||
|
"# HELP daena_delivery_records Deliveries by sink and state",
|
||||||
|
"# TYPE daena_delivery_records gauge",
|
||||||
|
]
|
||||||
|
for (sink, state), value in sorted(snapshot.deliveries.items()):
|
||||||
|
lines.append(
|
||||||
|
f'daena_delivery_records{{sink="{_escape(sink)}",state="{_escape(state)}"}} {value}'
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"# HELP daena_sink_deliveries_total Delivery attempts since process start",
|
||||||
|
"# TYPE daena_sink_deliveries_total counter",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for sink, results in sorted(snapshot.sink_results.items()):
|
||||||
|
for result, value in sorted(results.items()):
|
||||||
|
lines.append(
|
||||||
|
"daena_sink_deliveries_total"
|
||||||
|
f'{{sink="{_escape(sink)}",result="{_escape(result)}"}} {value}'
|
||||||
|
)
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _escape(value: str) -> str:
|
||||||
|
return value.replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"')
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsServer:
|
||||||
|
"""Tiny HTTP server for Prometheus scrapes and a JSON health body."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
snapshot: Callable[[], MetricsSnapshot],
|
||||||
|
health_body: Callable[[], dict[str, object]],
|
||||||
|
) -> None:
|
||||||
|
self._host = host
|
||||||
|
self._port = port
|
||||||
|
self._snapshot = snapshot
|
||||||
|
self._health_body = health_body
|
||||||
|
self._server: asyncio.AbstractServer | None = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._server = await asyncio.start_server(self._handle, self._host, self._port)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._server is None:
|
||||||
|
return
|
||||||
|
self._server.close()
|
||||||
|
await self._server.wait_closed()
|
||||||
|
self._server = None
|
||||||
|
|
||||||
|
async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||||
|
try:
|
||||||
|
request = await reader.readuntil(b"\r\n\r\n")
|
||||||
|
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError):
|
||||||
|
writer.close()
|
||||||
|
return
|
||||||
|
line = request.split(b"\r\n", 1)[0].decode(errors="replace")
|
||||||
|
parts = line.split(" ")
|
||||||
|
path = parts[1] if len(parts) >= 2 else "/"
|
||||||
|
if path == "/metrics":
|
||||||
|
body = render_prometheus(await asyncio.to_thread(self._snapshot)).encode()
|
||||||
|
content_type = "text/plain; version=0.0.4"
|
||||||
|
status = "200 OK"
|
||||||
|
elif path == "/health":
|
||||||
|
body = json.dumps(await asyncio.to_thread(self._health_body)).encode()
|
||||||
|
content_type = "application/json"
|
||||||
|
status = "200 OK"
|
||||||
|
else:
|
||||||
|
body = b"not found"
|
||||||
|
content_type = "text/plain"
|
||||||
|
status = "404 Not Found"
|
||||||
|
writer.write(
|
||||||
|
(
|
||||||
|
f"HTTP/1.1 {status}\r\n"
|
||||||
|
f"Content-Type: {content_type}\r\n"
|
||||||
|
f"Content-Length: {len(body)}\r\n"
|
||||||
|
"Connection: close\r\n\r\n"
|
||||||
|
).encode()
|
||||||
|
+ body
|
||||||
|
)
|
||||||
|
await writer.drain()
|
||||||
|
writer.close()
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
# ruff: noqa: I001, E501, UP009
|
# ruff: noqa: I001, E501, UP009
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||||
# NO CHECKED-IN PROTOBUF GENCODE
|
# NO CHECKED-IN PROTOBUF GENCODE
|
||||||
# source: daena/v1/daena.proto
|
# source: daena/v1/daena.proto
|
||||||
# Protobuf Python Version: 7.35.0
|
# Protobuf Python Version: 7.35.1
|
||||||
"""Generated protocol buffer code."""
|
"""Generated protocol buffer code."""
|
||||||
|
|
||||||
from google.protobuf import descriptor as _descriptor
|
from google.protobuf import descriptor as _descriptor
|
||||||
@@ -13,7 +13,7 @@ from google.protobuf import symbol_database as _symbol_database
|
|||||||
from google.protobuf.internal import builder as _builder
|
from google.protobuf.internal import builder as _builder
|
||||||
|
|
||||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||||
_runtime_version.Domain.PUBLIC, 7, 35, 0, "", "daena/v1/daena.proto"
|
_runtime_version.Domain.PUBLIC, 7, 35, 1, "", "daena/v1/daena.proto"
|
||||||
)
|
)
|
||||||
# @@protoc_insertion_point(imports)
|
# @@protoc_insertion_point(imports)
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ _sym_db = _symbol_database.Default()
|
|||||||
|
|
||||||
|
|
||||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
|
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
|
||||||
b'\n\x14\x64\x61\x65na/v1/daena.proto\x12\x08\x64\x61\x65na.v1"\x07\n\x05\x45mpty"C\n\x0eStatusResponse\x12\x0f\n\x07service\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x0f\n\x07running\x18\x03 \x01(\x08"[\n\x0eHealthResponse\x12\x0f\n\x07healthy\x18\x01 \x01(\x08\x12\x0f\n\x07running\x18\x02 \x01(\x08\x12\x0f\n\x07\x62\x61\x63kend\x18\x03 \x01(\x08\x12\x16\n\x0eplugins_loaded\x18\x04 \x01(\x08"%\n\x0e\x43onfigResponse\x12\x13\n\x0b\x63onfig_json\x18\x01 \x01(\t".\n\nPluginInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\nclass_name\x18\x02 \x01(\t"5\n\x0ePluginCategory\x12#\n\x05items\x18\x01 \x03(\x0b\x32\x14.daena.v1.PluginInfo"\x96\x01\n\x12PluginListResponse\x12)\n\x07sources\x18\x01 \x01(\x0b\x32\x18.daena.v1.PluginCategory\x12,\n\nprocessors\x18\x02 \x01(\x0b\x32\x18.daena.v1.PluginCategory\x12\'\n\x05sinks\x18\x03 \x01(\x0b\x32\x18.daena.v1.PluginCategory"e\n\x12QueueStateResponse\x12\x0f\n\x07pending\x18\x01 \x01(\x03\x12\x11\n\tdelivered\x18\x02 \x01(\x03\x12\x0e\n\x06\x66\x61iled\x18\x03 \x01(\x03\x12\x0c\n\x04\x64\x65\x61\x64\x18\x04 \x01(\x03\x12\r\n\x05total\x18\x05 \x01(\x03"+\n\nPagination\x12\r\n\x05limit\x18\x01 \x01(\x05\x12\x0e\n\x06offset\x18\x02 \x01(\x05"\xf0\x01\n\nRecordData\x12\x11\n\trecord_id\x18\x01 \x01(\t\x12\x0e\n\x06source\x18\x02 \x01(\t\x12\x13\n\x0brecord_type\x18\x03 \x01(\t\x12\x0c\n\x04\x62ody\x18\x04 \x01(\t\x12\x10\n\x08metadata\x18\x05 \x01(\t\x12\r\n\x05state\x18\x06 \x01(\t\x12\x12\n\ncreated_at\x18\x07 \x01(\t\x12\x12\n\nupdated_at\x18\x08 \x01(\t\x12\x13\n\x0bretry_count\x18\t \x01(\x05\x12\x15\n\rnext_retry_at\x18\n \x01(\t\x12\x12\n\nlast_error\x18\x0b \x01(\t\x12\x13\n\x0b\x64\x65stination\x18\x0c \x01(\t"B\n\nRecordList\x12%\n\x07records\x18\x01 \x03(\x0b\x32\x14.daena.v1.RecordData\x12\r\n\x05\x63ount\x18\x02 \x01(\x05"&\n\x08SinkInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t"-\n\x08SinkList\x12!\n\x05sinks\x18\x01 \x03(\x0b\x32\x12.daena.v1.SinkInfo"2\n\x0eReloadResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t2\x9b\x04\n\x05\x44\x61\x65na\x12\x36\n\tGetStatus\x12\x0f.daena.v1.Empty\x1a\x18.daena.v1.StatusResponse\x12\x36\n\tGetHealth\x12\x0f.daena.v1.Empty\x1a\x18.daena.v1.HealthResponse\x12\x36\n\tGetConfig\x12\x0f.daena.v1.Empty\x1a\x18.daena.v1.ConfigResponse\x12<\n\x0bListPlugins\x12\x0f.daena.v1.Empty\x1a\x1c.daena.v1.PluginListResponse\x12>\n\rGetQueueState\x12\x0f.daena.v1.Empty\x1a\x1c.daena.v1.QueueStateResponse\x12@\n\x12ListPendingRecords\x12\x14.daena.v1.Pagination\x1a\x14.daena.v1.RecordList\x12=\n\x0fListDeadRecords\x12\x14.daena.v1.Pagination\x1a\x14.daena.v1.RecordList\x12\x30\n\tListSinks\x12\x0f.daena.v1.Empty\x1a\x12.daena.v1.SinkList\x12\x39\n\x0cReloadConfig\x12\x0f.daena.v1.Empty\x1a\x18.daena.v1.ReloadResponseb\x06proto3'
|
b'\n\x14\x64\x61\x65na/v1/daena.proto\x12\x08\x64\x61\x65na.v1"\x07\n\x05\x45mpty"C\n\x0eStatusResponse\x12\x0f\n\x07service\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x0f\n\x07running\x18\x03 \x01(\x08"\x9f\x01\n\x0eHealthResponse\x12\x0f\n\x07healthy\x18\x01 \x01(\x08\x12\x0f\n\x07running\x18\x02 \x01(\x08\x12\x0f\n\x07\x62\x61\x63kend\x18\x03 \x01(\x08\x12\x16\n\x0eplugins_loaded\x18\x04 \x01(\x08\x12\x0e\n\x06status\x18\x05 \x01(\t\x12"\n\x1aoldest_pending_age_seconds\x18\x06 \x01(\x03\x12\x0e\n\x06\x64\x65tail\x18\x07 \x01(\t"%\n\x0e\x43onfigResponse\x12\x13\n\x0b\x63onfig_json\x18\x01 \x01(\t".\n\nPluginInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\nclass_name\x18\x02 \x01(\t"5\n\x0ePluginCategory\x12#\n\x05items\x18\x01 \x03(\x0b\x32\x14.daena.v1.PluginInfo"\x96\x01\n\x12PluginListResponse\x12)\n\x07sources\x18\x01 \x01(\x0b\x32\x18.daena.v1.PluginCategory\x12,\n\nprocessors\x18\x02 \x01(\x0b\x32\x18.daena.v1.PluginCategory\x12\'\n\x05sinks\x18\x03 \x01(\x0b\x32\x18.daena.v1.PluginCategory"t\n\x0eSinkQueueState\x12\x0c\n\x04sink\x18\x01 \x01(\t\x12\x0f\n\x07pending\x18\x02 \x01(\x03\x12\x12\n\ndelivering\x18\x03 \x01(\x03\x12\x11\n\tdelivered\x18\x04 \x01(\x03\x12\x0e\n\x06\x66\x61iled\x18\x05 \x01(\x03\x12\x0c\n\x04\x64\x65\x61\x64\x18\x06 \x01(\x03"\xa2\x01\n\x12QueueStateResponse\x12\x0f\n\x07pending\x18\x01 \x01(\x03\x12\x11\n\tdelivered\x18\x02 \x01(\x03\x12\x0e\n\x06\x66\x61iled\x18\x03 \x01(\x03\x12\x0c\n\x04\x64\x65\x61\x64\x18\x04 \x01(\x03\x12\r\n\x05total\x18\x05 \x01(\x03\x12\'\n\x05sinks\x18\x06 \x03(\x0b\x32\x18.daena.v1.SinkQueueState\x12\x12\n\ndelivering\x18\x07 \x01(\x03"+\n\nPagination\x12\r\n\x05limit\x18\x01 \x01(\x05\x12\x0e\n\x06offset\x18\x02 \x01(\x05"\x7f\n\x0c\x44\x65liveryData\x12\x0c\n\x04sink\x18\x01 \x01(\t\x12\r\n\x05state\x18\x02 \x01(\t\x12\x13\n\x0bretry_count\x18\x03 \x01(\x05\x12\x15\n\rnext_retry_at\x18\x04 \x01(\t\x12\x12\n\nlast_error\x18\x05 \x01(\t\x12\x12\n\nupdated_at\x18\x06 \x01(\t"\x9c\x02\n\nRecordData\x12\x11\n\trecord_id\x18\x01 \x01(\t\x12\x0e\n\x06source\x18\x02 \x01(\t\x12\x13\n\x0brecord_type\x18\x03 \x01(\t\x12\x0c\n\x04\x62ody\x18\x04 \x01(\t\x12\x10\n\x08metadata\x18\x05 \x01(\t\x12\r\n\x05state\x18\x06 \x01(\t\x12\x12\n\ncreated_at\x18\x07 \x01(\t\x12\x12\n\nupdated_at\x18\x08 \x01(\t\x12\x13\n\x0bretry_count\x18\t \x01(\x05\x12\x15\n\rnext_retry_at\x18\n \x01(\t\x12\x12\n\nlast_error\x18\x0b \x01(\t\x12\x13\n\x0b\x64\x65stination\x18\x0c \x01(\t\x12*\n\ndeliveries\x18\r \x03(\x0b\x32\x16.daena.v1.DeliveryData"B\n\nRecordList\x12%\n\x07records\x18\x01 \x03(\x0b\x32\x14.daena.v1.RecordData\x12\r\n\x05\x63ount\x18\x02 \x01(\x05"&\n\x08SinkInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t"-\n\x08SinkList\x12!\n\x05sinks\x18\x01 \x03(\x0b\x32\x12.daena.v1.SinkInfo"2\n\x0eReloadResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t"?\n\rReplayRequest\x12\x11\n\trecord_id\x18\x01 \x01(\t\x12\x0c\n\x04sink\x18\x02 \x01(\t\x12\r\n\x05limit\x18\x03 \x01(\x05""\n\x0eReplayResponse\x12\x10\n\x08replayed\x18\x01 \x01(\x05\x32\xdc\x04\n\x05\x44\x61\x65na\x12\x36\n\tGetStatus\x12\x0f.daena.v1.Empty\x1a\x18.daena.v1.StatusResponse\x12\x36\n\tGetHealth\x12\x0f.daena.v1.Empty\x1a\x18.daena.v1.HealthResponse\x12\x36\n\tGetConfig\x12\x0f.daena.v1.Empty\x1a\x18.daena.v1.ConfigResponse\x12<\n\x0bListPlugins\x12\x0f.daena.v1.Empty\x1a\x1c.daena.v1.PluginListResponse\x12>\n\rGetQueueState\x12\x0f.daena.v1.Empty\x1a\x1c.daena.v1.QueueStateResponse\x12@\n\x12ListPendingRecords\x12\x14.daena.v1.Pagination\x1a\x14.daena.v1.RecordList\x12=\n\x0fListDeadRecords\x12\x14.daena.v1.Pagination\x1a\x14.daena.v1.RecordList\x12\x30\n\tListSinks\x12\x0f.daena.v1.Empty\x1a\x12.daena.v1.SinkList\x12\x39\n\x0cReloadConfig\x12\x0f.daena.v1.Empty\x1a\x18.daena.v1.ReloadResponse\x12?\n\nReplayDead\x12\x17.daena.v1.ReplayRequest\x1a\x18.daena.v1.ReplayResponseb\x06proto3'
|
||||||
)
|
)
|
||||||
|
|
||||||
_globals = globals()
|
_globals = globals()
|
||||||
@@ -33,30 +33,38 @@ if not _descriptor._USE_C_DESCRIPTORS:
|
|||||||
_globals["_EMPTY"]._serialized_end = 41
|
_globals["_EMPTY"]._serialized_end = 41
|
||||||
_globals["_STATUSRESPONSE"]._serialized_start = 43
|
_globals["_STATUSRESPONSE"]._serialized_start = 43
|
||||||
_globals["_STATUSRESPONSE"]._serialized_end = 110
|
_globals["_STATUSRESPONSE"]._serialized_end = 110
|
||||||
_globals["_HEALTHRESPONSE"]._serialized_start = 112
|
_globals["_HEALTHRESPONSE"]._serialized_start = 113
|
||||||
_globals["_HEALTHRESPONSE"]._serialized_end = 203
|
_globals["_HEALTHRESPONSE"]._serialized_end = 272
|
||||||
_globals["_CONFIGRESPONSE"]._serialized_start = 205
|
_globals["_CONFIGRESPONSE"]._serialized_start = 274
|
||||||
_globals["_CONFIGRESPONSE"]._serialized_end = 242
|
_globals["_CONFIGRESPONSE"]._serialized_end = 311
|
||||||
_globals["_PLUGININFO"]._serialized_start = 244
|
_globals["_PLUGININFO"]._serialized_start = 313
|
||||||
_globals["_PLUGININFO"]._serialized_end = 290
|
_globals["_PLUGININFO"]._serialized_end = 359
|
||||||
_globals["_PLUGINCATEGORY"]._serialized_start = 292
|
_globals["_PLUGINCATEGORY"]._serialized_start = 361
|
||||||
_globals["_PLUGINCATEGORY"]._serialized_end = 345
|
_globals["_PLUGINCATEGORY"]._serialized_end = 414
|
||||||
_globals["_PLUGINLISTRESPONSE"]._serialized_start = 348
|
_globals["_PLUGINLISTRESPONSE"]._serialized_start = 417
|
||||||
_globals["_PLUGINLISTRESPONSE"]._serialized_end = 498
|
_globals["_PLUGINLISTRESPONSE"]._serialized_end = 567
|
||||||
_globals["_QUEUESTATERESPONSE"]._serialized_start = 500
|
_globals["_SINKQUEUESTATE"]._serialized_start = 569
|
||||||
_globals["_QUEUESTATERESPONSE"]._serialized_end = 601
|
_globals["_SINKQUEUESTATE"]._serialized_end = 685
|
||||||
_globals["_PAGINATION"]._serialized_start = 603
|
_globals["_QUEUESTATERESPONSE"]._serialized_start = 688
|
||||||
_globals["_PAGINATION"]._serialized_end = 646
|
_globals["_QUEUESTATERESPONSE"]._serialized_end = 850
|
||||||
_globals["_RECORDDATA"]._serialized_start = 649
|
_globals["_PAGINATION"]._serialized_start = 852
|
||||||
_globals["_RECORDDATA"]._serialized_end = 889
|
_globals["_PAGINATION"]._serialized_end = 895
|
||||||
_globals["_RECORDLIST"]._serialized_start = 891
|
_globals["_DELIVERYDATA"]._serialized_start = 897
|
||||||
_globals["_RECORDLIST"]._serialized_end = 957
|
_globals["_DELIVERYDATA"]._serialized_end = 1024
|
||||||
_globals["_SINKINFO"]._serialized_start = 959
|
_globals["_RECORDDATA"]._serialized_start = 1027
|
||||||
_globals["_SINKINFO"]._serialized_end = 997
|
_globals["_RECORDDATA"]._serialized_end = 1311
|
||||||
_globals["_SINKLIST"]._serialized_start = 999
|
_globals["_RECORDLIST"]._serialized_start = 1313
|
||||||
_globals["_SINKLIST"]._serialized_end = 1044
|
_globals["_RECORDLIST"]._serialized_end = 1379
|
||||||
_globals["_RELOADRESPONSE"]._serialized_start = 1046
|
_globals["_SINKINFO"]._serialized_start = 1381
|
||||||
_globals["_RELOADRESPONSE"]._serialized_end = 1096
|
_globals["_SINKINFO"]._serialized_end = 1419
|
||||||
_globals["_DAENA"]._serialized_start = 1099
|
_globals["_SINKLIST"]._serialized_start = 1421
|
||||||
_globals["_DAENA"]._serialized_end = 1638
|
_globals["_SINKLIST"]._serialized_end = 1466
|
||||||
|
_globals["_RELOADRESPONSE"]._serialized_start = 1468
|
||||||
|
_globals["_RELOADRESPONSE"]._serialized_end = 1518
|
||||||
|
_globals["_REPLAYREQUEST"]._serialized_start = 1520
|
||||||
|
_globals["_REPLAYREQUEST"]._serialized_end = 1583
|
||||||
|
_globals["_REPLAYRESPONSE"]._serialized_start = 1585
|
||||||
|
_globals["_REPLAYRESPONSE"]._serialized_end = 1619
|
||||||
|
_globals["_DAENA"]._serialized_start = 1622
|
||||||
|
_globals["_DAENA"]._serialized_end = 2226
|
||||||
# @@protoc_insertion_point(module_scope)
|
# @@protoc_insertion_point(module_scope)
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ class DaenaStub:
|
|||||||
response_deserializer=daena_dot_v1_dot_daena__pb2.ReloadResponse.FromString,
|
response_deserializer=daena_dot_v1_dot_daena__pb2.ReloadResponse.FromString,
|
||||||
_registered_method=True,
|
_registered_method=True,
|
||||||
)
|
)
|
||||||
|
self.ReplayDead = channel.unary_unary(
|
||||||
|
"/daena.v1.Daena/ReplayDead",
|
||||||
|
request_serializer=daena_dot_v1_dot_daena__pb2.ReplayRequest.SerializeToString,
|
||||||
|
response_deserializer=daena_dot_v1_dot_daena__pb2.ReplayResponse.FromString,
|
||||||
|
_registered_method=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DaenaServicer:
|
class DaenaServicer:
|
||||||
@@ -150,6 +156,12 @@ class DaenaServicer:
|
|||||||
context.set_details("Method not implemented!")
|
context.set_details("Method not implemented!")
|
||||||
raise NotImplementedError("Method not implemented!")
|
raise NotImplementedError("Method not implemented!")
|
||||||
|
|
||||||
|
def ReplayDead(self, request, context):
|
||||||
|
"""Missing associated documentation comment in .proto file."""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details("Method not implemented!")
|
||||||
|
raise NotImplementedError("Method not implemented!")
|
||||||
|
|
||||||
|
|
||||||
def add_DaenaServicer_to_server(servicer, server):
|
def add_DaenaServicer_to_server(servicer, server):
|
||||||
rpc_method_handlers = {
|
rpc_method_handlers = {
|
||||||
@@ -198,6 +210,11 @@ def add_DaenaServicer_to_server(servicer, server):
|
|||||||
request_deserializer=daena_dot_v1_dot_daena__pb2.Empty.FromString,
|
request_deserializer=daena_dot_v1_dot_daena__pb2.Empty.FromString,
|
||||||
response_serializer=daena_dot_v1_dot_daena__pb2.ReloadResponse.SerializeToString,
|
response_serializer=daena_dot_v1_dot_daena__pb2.ReloadResponse.SerializeToString,
|
||||||
),
|
),
|
||||||
|
"ReplayDead": grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.ReplayDead,
|
||||||
|
request_deserializer=daena_dot_v1_dot_daena__pb2.ReplayRequest.FromString,
|
||||||
|
response_serializer=daena_dot_v1_dot_daena__pb2.ReplayResponse.SerializeToString,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
generic_handler = grpc.method_handlers_generic_handler("daena.v1.Daena", rpc_method_handlers)
|
generic_handler = grpc.method_handlers_generic_handler("daena.v1.Daena", rpc_method_handlers)
|
||||||
server.add_generic_rpc_handlers((generic_handler,))
|
server.add_generic_rpc_handlers((generic_handler,))
|
||||||
@@ -477,3 +494,33 @@ class Daena:
|
|||||||
metadata,
|
metadata,
|
||||||
_registered_method=True,
|
_registered_method=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ReplayDead(
|
||||||
|
request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None,
|
||||||
|
):
|
||||||
|
return grpc.experimental.unary_unary(
|
||||||
|
request,
|
||||||
|
target,
|
||||||
|
"/daena.v1.Daena/ReplayDead",
|
||||||
|
daena_dot_v1_dot_daena__pb2.ReplayRequest.SerializeToString,
|
||||||
|
daena_dot_v1_dot_daena__pb2.ReplayResponse.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
|
_registered_method=True,
|
||||||
|
)
|
||||||
|
|||||||
+140
-30
@@ -1,60 +1,170 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from daena.api.grpc_server import DaenaGRPCServer
|
from daena.api.grpc_server import DaenaGRPCServer
|
||||||
from daena.config import load_config
|
from daena.api.metrics import MetricsServer, MetricsSnapshot
|
||||||
|
from daena.config import DaenaConfig, load_config
|
||||||
|
from daena.core.layout import render_layout
|
||||||
from daena.core.runtime import DaenaRuntime
|
from daena.core.runtime import DaenaRuntime
|
||||||
|
from daena.exceptions import ConfigurationError
|
||||||
from daena.logutil import get_logger, setup_logging
|
from daena.logutil import get_logger, setup_logging
|
||||||
|
|
||||||
log = get_logger("daena.app")
|
log = get_logger("daena.app")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_config_path() -> str | None:
|
def _resolve_config_path() -> str | None:
|
||||||
for candidate in (
|
env_path = os.environ.get("DAENA_CONFIG_PATH")
|
||||||
"/etc/daena/daena.yaml",
|
if env_path:
|
||||||
"./daena.yaml",
|
if not Path(env_path).exists():
|
||||||
"./config/daena.yaml",
|
raise ConfigurationError(f"DAENA_CONFIG_PATH not found: {env_path}")
|
||||||
):
|
return env_path
|
||||||
p = Path(candidate)
|
for candidate in ("/etc/daena/daena.yaml", "./daena.yaml", "./config/daena.yaml"):
|
||||||
if p.exists():
|
if Path(candidate).exists():
|
||||||
return str(p)
|
return candidate
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _metrics_snapshot(runtime: DaenaRuntime) -> MetricsSnapshot:
|
||||||
|
backend = runtime.backend
|
||||||
|
deliveries: dict[tuple[str, str], int] = {}
|
||||||
|
stored = 0
|
||||||
|
oldest = runtime.health().oldest_pending_age_seconds if backend is not None else 0
|
||||||
|
if backend is not None:
|
||||||
|
stored = backend.count()
|
||||||
|
for sink, counts in backend.sink_counts().items():
|
||||||
|
for state, value in counts.items():
|
||||||
|
deliveries[(sink, state)] = value
|
||||||
|
results = {
|
||||||
|
name: {"delivered": item.delivered, "failed": item.failed, "dead": item.dead}
|
||||||
|
for name, item in runtime.sink_stats().items()
|
||||||
|
}
|
||||||
|
return MetricsSnapshot(
|
||||||
|
running=runtime.health().running,
|
||||||
|
stored_records=stored,
|
||||||
|
oldest_inflight_seconds=oldest,
|
||||||
|
deliveries=deliveries,
|
||||||
|
sink_results=results,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _health_body(runtime: DaenaRuntime) -> dict[str, object]:
|
||||||
|
report = runtime.health()
|
||||||
|
return {
|
||||||
|
"healthy": report.healthy,
|
||||||
|
"status": report.status,
|
||||||
|
"running": report.running,
|
||||||
|
"backend": report.backend,
|
||||||
|
"oldest_pending_age_seconds": report.oldest_pending_age_seconds,
|
||||||
|
"detail": report.detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_until_stopped(runtime: DaenaRuntime, stop: asyncio.Event) -> None:
|
||||||
|
"""Start the API and block until `stop` is set, then shut down."""
|
||||||
|
config = runtime.config
|
||||||
|
server = DaenaGRPCServer(
|
||||||
|
runtime,
|
||||||
|
host=config.api.grpc.host,
|
||||||
|
port=config.api.grpc.port,
|
||||||
|
maximum_concurrent_rpcs=config.api.grpc.max_workers,
|
||||||
|
)
|
||||||
|
metrics: MetricsServer | None = None
|
||||||
|
await runtime.start()
|
||||||
|
await server.start()
|
||||||
|
if config.api.metrics.enabled:
|
||||||
|
metrics = MetricsServer(
|
||||||
|
config.api.metrics.host,
|
||||||
|
config.api.metrics.port,
|
||||||
|
lambda: _metrics_snapshot(runtime),
|
||||||
|
lambda: _health_body(runtime),
|
||||||
|
)
|
||||||
|
await metrics.start()
|
||||||
|
log.info(
|
||||||
|
"metrics_server_serving",
|
||||||
|
address=f"{config.api.metrics.host}:{config.api.metrics.port}",
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"grpc_server_serving",
|
||||||
|
address=f"{config.api.grpc.host}:{config.api.grpc.port}",
|
||||||
|
)
|
||||||
|
reload_tasks: set[asyncio.Task[None]] = set()
|
||||||
|
reload_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def _reload() -> None:
|
||||||
|
if stop.is_set():
|
||||||
|
return
|
||||||
|
async with reload_lock:
|
||||||
|
try:
|
||||||
|
await runtime.reload()
|
||||||
|
except Exception:
|
||||||
|
log.exception("sighup_reload_failed")
|
||||||
|
return
|
||||||
|
_print_layout(runtime.config, runtime.config_path)
|
||||||
|
|
||||||
|
def _on_hup() -> None:
|
||||||
|
task = asyncio.create_task(_reload())
|
||||||
|
reload_tasks.add(task)
|
||||||
|
task.add_done_callback(reload_tasks.discard)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.add_signal_handler(signal.SIGINT, stop.set)
|
||||||
|
loop.add_signal_handler(signal.SIGTERM, stop.set)
|
||||||
|
loop.add_signal_handler(signal.SIGHUP, _on_hup)
|
||||||
|
try:
|
||||||
|
await stop.wait()
|
||||||
|
finally:
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
||||||
|
loop.remove_signal_handler(sig)
|
||||||
|
for task in list(reload_tasks):
|
||||||
|
task.cancel()
|
||||||
|
if metrics is not None:
|
||||||
|
await metrics.stop()
|
||||||
|
await server.stop()
|
||||||
|
await runtime.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="daena-serve",
|
||||||
|
description="Run daena: collect, buffer, and forward logs.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v",
|
||||||
|
"--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="log each collected, dropped, and delivered record",
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_layout(config: DaenaConfig, config_path: str | Path | None) -> None:
|
||||||
|
path = os.fspath(config_path) if config_path is not None else None
|
||||||
|
print(render_layout(config, path), file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def serve() -> None:
|
def serve() -> None:
|
||||||
"""Entry point for `daena-serve` (systemd/gRPC mode)."""
|
"""Entry point for `daena-serve` (systemd/gRPC mode)."""
|
||||||
|
args = _parse_args()
|
||||||
config_path = _resolve_config_path()
|
config_path = _resolve_config_path()
|
||||||
config = load_config(config_path)
|
config = load_config(config_path)
|
||||||
|
if args.verbose:
|
||||||
|
config.logging.verbose = True
|
||||||
setup_logging(
|
setup_logging(
|
||||||
level=config.logging.level,
|
level=config.logging.level,
|
||||||
fmt=config.logging.format,
|
fmt=config.logging.format,
|
||||||
log_file=config.logging.file,
|
log_file=config.logging.file,
|
||||||
)
|
)
|
||||||
|
_print_layout(config, config_path)
|
||||||
runtime = DaenaRuntime(config)
|
runtime = DaenaRuntime(config, config_path=config_path, force_verbose=args.verbose)
|
||||||
|
|
||||||
server = DaenaGRPCServer(
|
|
||||||
runtime,
|
|
||||||
host=config.api.grpc.host,
|
|
||||||
port=config.api.grpc.port,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
await runtime.start()
|
await run_until_stopped(runtime, asyncio.Event())
|
||||||
await server.start()
|
|
||||||
log.info(
|
|
||||||
"grpc_server_serving",
|
|
||||||
address=f"{config.api.grpc.host}:{config.api.grpc.port}",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await server.serve_forever()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
await server.stop()
|
|
||||||
await runtime.stop()
|
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|||||||
+85
-12
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
import grpc
|
import grpc
|
||||||
import typer
|
import typer
|
||||||
@@ -18,11 +19,22 @@ app = typer.Typer(
|
|||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
DEFAULT_TARGET = "127.0.0.1:8642"
|
DEFAULT_TARGET = "127.0.0.1:8642"
|
||||||
|
_state: dict[str, str] = {"target": DEFAULT_TARGET}
|
||||||
|
|
||||||
|
|
||||||
|
@app.callback()
|
||||||
|
def main(
|
||||||
|
target: Annotated[
|
||||||
|
str,
|
||||||
|
typer.Option("--target", "-t", help="gRPC address of a running daena service"),
|
||||||
|
] = DEFAULT_TARGET,
|
||||||
|
) -> None:
|
||||||
|
"""Operational client for a running daena service."""
|
||||||
|
_state["target"] = target
|
||||||
|
|
||||||
|
|
||||||
def _stub(target: str | None = None) -> pb2_grpc.DaenaStub:
|
def _stub(target: str | None = None) -> pb2_grpc.DaenaStub:
|
||||||
tgt = target or DEFAULT_TARGET
|
channel = grpc.insecure_channel(target or _state["target"])
|
||||||
channel = grpc.insecure_channel(tgt)
|
|
||||||
return pb2_grpc.DaenaStub(channel)
|
return pb2_grpc.DaenaStub(channel)
|
||||||
|
|
||||||
|
|
||||||
@@ -53,10 +65,12 @@ def health() -> None:
|
|||||||
"""Check service health."""
|
"""Check service health."""
|
||||||
stub = _connect()
|
stub = _connect()
|
||||||
resp = stub.GetHealth(pb2.Empty(), timeout=5.0)
|
resp = stub.GetHealth(pb2.Empty(), timeout=5.0)
|
||||||
if resp.healthy:
|
label = "green" if resp.healthy else "red"
|
||||||
console.print("[green]Healthy[/green]")
|
console.print(f"[{label}]{resp.status}[/{label}]")
|
||||||
else:
|
console.print(f"Oldest inflight: {resp.oldest_pending_age_seconds}s")
|
||||||
console.print("[red]Unhealthy[/red]")
|
if resp.detail:
|
||||||
|
console.print(resp.detail)
|
||||||
|
if not resp.healthy:
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -66,9 +80,68 @@ def queue() -> None:
|
|||||||
stub = _connect()
|
stub = _connect()
|
||||||
resp = stub.GetQueueState(pb2.Empty(), timeout=5.0)
|
resp = stub.GetQueueState(pb2.Empty(), timeout=5.0)
|
||||||
table = Table("State", "Count")
|
table = Table("State", "Count")
|
||||||
for key in ("pending", "delivered", "failed", "dead", "total"):
|
for key in ("pending", "delivering", "delivered", "failed", "dead", "total"):
|
||||||
table.add_row(key, str(getattr(resp, key, 0)))
|
table.add_row(key, str(getattr(resp, key, 0)))
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
if resp.sinks:
|
||||||
|
sinks = Table("Sink", "Pending", "Delivering", "Delivered", "Failed", "Dead")
|
||||||
|
for item in resp.sinks:
|
||||||
|
sinks.add_row(
|
||||||
|
item.sink,
|
||||||
|
str(item.pending),
|
||||||
|
str(item.delivering),
|
||||||
|
str(item.delivered),
|
||||||
|
str(item.failed),
|
||||||
|
str(item.dead),
|
||||||
|
)
|
||||||
|
console.print(sinks)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def records(
|
||||||
|
kind: Annotated[str, typer.Argument(help="pending or dead")] = "pending",
|
||||||
|
limit: Annotated[int, typer.Option(help="Maximum records to show")] = 20,
|
||||||
|
offset: Annotated[int, typer.Option(help="Records to skip")] = 0,
|
||||||
|
) -> None:
|
||||||
|
"""List pending or dead-letter records."""
|
||||||
|
if kind not in ("pending", "dead"):
|
||||||
|
console.print("[red]kind must be pending or dead[/red]")
|
||||||
|
raise typer.Exit(2)
|
||||||
|
stub = _connect()
|
||||||
|
page = pb2.Pagination(limit=limit, offset=offset)
|
||||||
|
if kind == "pending":
|
||||||
|
resp = stub.ListPendingRecords(page, timeout=5.0)
|
||||||
|
else:
|
||||||
|
resp = stub.ListDeadRecords(page, timeout=5.0)
|
||||||
|
if not resp.records:
|
||||||
|
console.print(f"No {kind} records")
|
||||||
|
return
|
||||||
|
table = Table("ID", "Source", "State", "Deliveries", "Error")
|
||||||
|
for record in resp.records:
|
||||||
|
deliveries = ",".join(f"{item.sink}:{item.state}" for item in record.deliveries)
|
||||||
|
table.add_row(
|
||||||
|
record.record_id,
|
||||||
|
record.source,
|
||||||
|
record.state,
|
||||||
|
deliveries,
|
||||||
|
record.last_error,
|
||||||
|
)
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def replay(
|
||||||
|
record_id: Annotated[str, typer.Option(help="One record id; empty replays a batch")] = "",
|
||||||
|
sink: Annotated[str, typer.Option(help="Limit replay to one sink")] = "",
|
||||||
|
limit: Annotated[int, typer.Option(help="Maximum dead records to requeue")] = 100,
|
||||||
|
) -> None:
|
||||||
|
"""Requeue dead-letter deliveries as pending."""
|
||||||
|
stub = _connect()
|
||||||
|
resp = stub.ReplayDead(
|
||||||
|
pb2.ReplayRequest(record_id=record_id, sink=sink, limit=limit),
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
console.print(f"Replayed {resp.replayed} record(s)")
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -81,8 +154,8 @@ def sinks() -> None:
|
|||||||
console.print("No sinks configured")
|
console.print("No sinks configured")
|
||||||
return
|
return
|
||||||
table = Table("Name", "Type")
|
table = Table("Name", "Type")
|
||||||
for s in items:
|
for item in items:
|
||||||
table.add_row(s.name, s.type)
|
table.add_row(item.name, item.type)
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
@@ -91,14 +164,14 @@ def plugins() -> None:
|
|||||||
"""List loaded plugins."""
|
"""List loaded plugins."""
|
||||||
stub = _connect()
|
stub = _connect()
|
||||||
resp = stub.ListPlugins(pb2.Empty(), timeout=5.0)
|
resp = stub.ListPlugins(pb2.Empty(), timeout=5.0)
|
||||||
for label, cat in (
|
for label, category in (
|
||||||
("Sources", resp.sources),
|
("Sources", resp.sources),
|
||||||
("Processors", resp.processors),
|
("Processors", resp.processors),
|
||||||
("Sinks", resp.sinks),
|
("Sinks", resp.sinks),
|
||||||
):
|
):
|
||||||
if cat.items:
|
if category.items:
|
||||||
table = Table(label)
|
table = Table(label)
|
||||||
for item in cat.items:
|
for item in category.items:
|
||||||
table.add_row(f"{item.name} ({item.class_name})")
|
table.add_row(f"{item.name} ({item.class_name})")
|
||||||
console.print(table)
|
console.print(table)
|
||||||
console.print()
|
console.print()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class LoggingConfig(BaseSettings):
|
|||||||
level: str = "info"
|
level: str = "info"
|
||||||
format: str = "json"
|
format: str = "json"
|
||||||
file: str | None = None
|
file: str | None = None
|
||||||
|
verbose: bool = False
|
||||||
|
|
||||||
|
|
||||||
class GRPCConfig(BaseSettings):
|
class GRPCConfig(BaseSettings):
|
||||||
@@ -20,8 +21,25 @@ class GRPCConfig(BaseSettings):
|
|||||||
max_workers: int = 10
|
max_workers: int = 10
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsConfig(BaseSettings):
|
||||||
|
enabled: bool = True
|
||||||
|
host: str = "127.0.0.1"
|
||||||
|
port: int = 8643
|
||||||
|
|
||||||
|
|
||||||
class APIConfig(BaseSettings):
|
class APIConfig(BaseSettings):
|
||||||
grpc: GRPCConfig = GRPCConfig()
|
grpc: GRPCConfig = GRPCConfig()
|
||||||
|
metrics: MetricsConfig = MetricsConfig()
|
||||||
|
|
||||||
|
|
||||||
|
class RetentionConfig(BaseSettings):
|
||||||
|
delivered_ttl_seconds: int = 86_400
|
||||||
|
dead_ttl_seconds: int = 604_800
|
||||||
|
interval_seconds: float = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
class HealthConfig(BaseSettings):
|
||||||
|
max_pending_age_seconds: float = 300.0
|
||||||
|
|
||||||
|
|
||||||
class SinkRetryConfig(BaseSettings):
|
class SinkRetryConfig(BaseSettings):
|
||||||
@@ -82,6 +100,8 @@ class DaenaConfig(BaseSettings):
|
|||||||
api: APIConfig = APIConfig()
|
api: APIConfig = APIConfig()
|
||||||
pipeline: PipelineConfig = PipelineConfig()
|
pipeline: PipelineConfig = PipelineConfig()
|
||||||
persistence: PersistenceConfig = PersistenceConfig()
|
persistence: PersistenceConfig = PersistenceConfig()
|
||||||
|
retention: RetentionConfig = RetentionConfig()
|
||||||
|
health: HealthConfig = HealthConfig()
|
||||||
|
|
||||||
sources: list[SourceConfig] = Field(default_factory=list)
|
sources: list[SourceConfig] = Field(default_factory=list)
|
||||||
processors: list[ProcessorConfig] = Field(default_factory=list)
|
processors: list[ProcessorConfig] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from daena._version import __version__
|
||||||
|
from daena.config import DaenaConfig, SinkConfig, SourceConfig
|
||||||
|
|
||||||
|
|
||||||
|
def preview_message(body: dict[str, Any], limit: int = 120) -> str:
|
||||||
|
"""One line of record text for verbose logs."""
|
||||||
|
raw = body.get("message")
|
||||||
|
if not isinstance(raw, str) or not raw.strip():
|
||||||
|
raw = json.dumps(body, default=str, separators=(",", ":"))
|
||||||
|
text = " ".join(raw.split())
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return text[: limit - 1] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def render_layout(config: DaenaConfig, config_path: str | None = None) -> str:
|
||||||
|
"""One-line pipeline and the addresses the process listens on."""
|
||||||
|
stages = [
|
||||||
|
", ".join(_source_label(item) for item in config.sources),
|
||||||
|
", ".join(_node(item.name, item.type, item.enabled) for item in config.processors),
|
||||||
|
"lmdb",
|
||||||
|
", ".join(_sink_label(item) for item in config.sinks),
|
||||||
|
]
|
||||||
|
flow = " → ".join(stage for stage in stages if stage)
|
||||||
|
where = config_path or "defaults"
|
||||||
|
lines = [
|
||||||
|
f"daena {__version__} {where}",
|
||||||
|
flow,
|
||||||
|
config.persistence.lmdb.path,
|
||||||
|
_listen(config),
|
||||||
|
]
|
||||||
|
return "\n".join(line for line in lines if line)
|
||||||
|
|
||||||
|
|
||||||
|
def _listen(config: DaenaConfig) -> str:
|
||||||
|
grpc = f"{config.api.grpc.host}:{config.api.grpc.port}"
|
||||||
|
if not config.api.metrics.enabled:
|
||||||
|
return grpc
|
||||||
|
metrics = f"{config.api.metrics.host}:{config.api.metrics.port}"
|
||||||
|
return f"{grpc} {metrics}"
|
||||||
|
|
||||||
|
|
||||||
|
def _source_label(source: SourceConfig) -> str:
|
||||||
|
return _node(source.name, source.type, source.enabled, _source_hint(source))
|
||||||
|
|
||||||
|
|
||||||
|
def _sink_label(sink: SinkConfig) -> str:
|
||||||
|
return _node(sink.name, sink.type, sink.enabled, _sink_hint(sink))
|
||||||
|
|
||||||
|
|
||||||
|
def _node(name: str, type_name: str, enabled: bool, hint: str = "") -> str:
|
||||||
|
notes: list[str] = []
|
||||||
|
if name != type_name:
|
||||||
|
notes.append(type_name)
|
||||||
|
if enabled and hint:
|
||||||
|
notes.append(hint)
|
||||||
|
if not enabled:
|
||||||
|
notes.append("off")
|
||||||
|
if not notes:
|
||||||
|
return name
|
||||||
|
return f"{name} ({', '.join(notes)})"
|
||||||
|
|
||||||
|
|
||||||
|
def _source_hint(source: SourceConfig) -> str:
|
||||||
|
options = source.options
|
||||||
|
if source.type == "dummy" and "interval" in options:
|
||||||
|
return _seconds(options["interval"])
|
||||||
|
if source.type == "journald":
|
||||||
|
units = options.get("units") or []
|
||||||
|
if isinstance(units, list) and units:
|
||||||
|
return str(units[0]) if len(units) == 1 else f"{units[0]} +{len(units) - 1}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _sink_hint(sink: SinkConfig) -> str:
|
||||||
|
url = sink.options.get("url")
|
||||||
|
if sink.type in {"http", "loki"} and isinstance(url, str):
|
||||||
|
return _endpoint(url)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _seconds(value: Any) -> str:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
seconds = float(value)
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
if seconds.is_integer():
|
||||||
|
return f"{int(seconds)}s"
|
||||||
|
return f"{seconds}s"
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint(url: str) -> str:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if not parsed.hostname:
|
||||||
|
return url
|
||||||
|
if parsed.port:
|
||||||
|
return f"{parsed.hostname}:{parsed.port}"
|
||||||
|
return parsed.hostname
|
||||||
+296
-136
@@ -3,10 +3,14 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from daena.config import DaenaConfig
|
from daena.config import DaenaConfig, SinkRetryConfig
|
||||||
|
from daena.core.layout import preview_message
|
||||||
from daena.logutil import get_logger
|
from daena.logutil import get_logger
|
||||||
|
from daena.persistence.async_backend import AsyncBackend
|
||||||
from daena.persistence.backend import Backend
|
from daena.persistence.backend import Backend
|
||||||
from daena.persistence.models import StoredRecord
|
from daena.persistence.models import StoredRecord
|
||||||
from daena.pipeline.base import DeliveryResult, Processor, Sink, Source
|
from daena.pipeline.base import DeliveryResult, Processor, Sink, Source
|
||||||
@@ -16,42 +20,49 @@ from daena.plugins.registry import PluginRegistry
|
|||||||
log = get_logger("daena.pipeline")
|
log = get_logger("daena.pipeline")
|
||||||
|
|
||||||
|
|
||||||
class SourceRunner:
|
@dataclass
|
||||||
"""Wraps a Source and handles record ingestion into the pipeline."""
|
class SinkStats:
|
||||||
|
"""Cumulative delivery outcomes for one sink since process start."""
|
||||||
|
|
||||||
def __init__(
|
delivered: int = 0
|
||||||
self,
|
failed: int = 0
|
||||||
source: Source,
|
dead: int = 0
|
||||||
on_record: Callable[[Record], Any],
|
last_error: str = ""
|
||||||
) -> None:
|
|
||||||
self._source = source
|
|
||||||
self._on_record = on_record
|
@dataclass
|
||||||
|
class SourceRunner:
|
||||||
|
"""Binds a source to the pipeline ingest callback."""
|
||||||
|
|
||||||
|
source: Source
|
||||||
|
on_record: Callable[[Record], Any]
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
if hasattr(self._source, "set_callback"):
|
self.source.set_callback(self.on_record)
|
||||||
self._source.set_callback(self._on_record)
|
log.info("source_starting", source=self.source.name)
|
||||||
log.info("source_starting", source=self._source.name)
|
await self.source.start()
|
||||||
await self._source.start()
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
log.info("source_stopping", source=self._source.name)
|
log.info("source_stopping", source=self.source.name)
|
||||||
await self._source.stop()
|
await self.source.stop()
|
||||||
|
|
||||||
|
|
||||||
class SinkDispatcher:
|
class SinkDispatcher:
|
||||||
"""Manages delivery of records to a sink with retry logic."""
|
"""Owns one sink instance and forwards a single record to it."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, sink: Sink, plugin_type: str) -> None:
|
||||||
self,
|
|
||||||
sink: Sink,
|
|
||||||
backend: Backend,
|
|
||||||
config: DaenaConfig,
|
|
||||||
) -> None:
|
|
||||||
self._sink = sink
|
self._sink = sink
|
||||||
self._backend = backend
|
self.plugin_type = plugin_type
|
||||||
self._config = config
|
|
||||||
self._logger = log.bind(sink=sink.name)
|
self._logger = log.bind(sink=sink.name)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self._sink.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sink(self) -> Sink:
|
||||||
|
return self._sink
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
self._logger.info("sink_starting")
|
self._logger.info("sink_starting")
|
||||||
await self._sink.start()
|
await self._sink.start()
|
||||||
@@ -65,14 +76,11 @@ class SinkDispatcher:
|
|||||||
|
|
||||||
|
|
||||||
class Pipeline:
|
class Pipeline:
|
||||||
"""Orchestrates the full collection → persist → forward data flow.
|
"""Collection, durable buffer, and per-sink delivery.
|
||||||
|
|
||||||
Lifecycle:
|
Sources block when the number of inflight records reaches `max_queue_size`.
|
||||||
1. Sources emit Records
|
Each delivery attempt is a single try; retries wait on `next_retry_at`
|
||||||
2. Processors transform/filter Records
|
instead of sleeping inside the dispatcher.
|
||||||
3. Records are persisted to the backend
|
|
||||||
4. SinkDispatcher reads pending records and delivers them
|
|
||||||
5. Delivery results update record state (ack/nack/dead)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -83,77 +91,148 @@ class Pipeline:
|
|||||||
) -> None:
|
) -> None:
|
||||||
self._config = config
|
self._config = config
|
||||||
self._backend = backend
|
self._backend = backend
|
||||||
|
self._store = AsyncBackend(backend)
|
||||||
self._registry = registry
|
self._registry = registry
|
||||||
self._sources: list[Source] = []
|
self._sources: list[Source] = []
|
||||||
self._processors: list[Processor] = []
|
self._processors: list[Processor] = []
|
||||||
self._dispatchers: list[SinkDispatcher] = []
|
self._dispatchers: list[SinkDispatcher] = []
|
||||||
self._source_runners: list[SourceRunner] = []
|
self._source_runners: list[SourceRunner] = []
|
||||||
self._dispatch_task: asyncio.Task[None] | None = None
|
self._dispatch_task: asyncio.Task[None] | None = None
|
||||||
|
self._retention_task: asyncio.Task[None] | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._queue: asyncio.Queue[Record] = asyncio.Queue(maxsize=config.pipeline.max_queue_size)
|
self._inflight = 0
|
||||||
|
self._capacity = asyncio.Condition()
|
||||||
|
self._stats: dict[str, SinkStats] = {}
|
||||||
|
self._sink_names: list[str] = []
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
self._running = True
|
self._running = True
|
||||||
self._build_pipeline()
|
self._build_pipeline()
|
||||||
|
self._inflight = await self._store.count_inflight()
|
||||||
for runner in self._source_runners:
|
for runner in self._source_runners:
|
||||||
await runner.start()
|
await runner.start()
|
||||||
for dispatcher in self._dispatchers:
|
for dispatcher in self._dispatchers:
|
||||||
await dispatcher.start()
|
await dispatcher.start()
|
||||||
self._dispatch_task = asyncio.create_task(self._dispatch_loop())
|
self._dispatch_task = asyncio.create_task(self._dispatch_loop())
|
||||||
|
self._retention_task = asyncio.create_task(self._retention_loop())
|
||||||
log.info(
|
log.info(
|
||||||
"pipeline_started",
|
"pipeline_started",
|
||||||
sources=len(self._source_runners),
|
sources=len(self._source_runners),
|
||||||
sinks=len(self._dispatchers),
|
sinks=len(self._dispatchers),
|
||||||
processors=len(self._processors),
|
processors=len(self._processors),
|
||||||
|
inflight=self._inflight,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._dispatch_task:
|
async with self._capacity:
|
||||||
self._dispatch_task.cancel()
|
self._capacity.notify_all()
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
await self._cancel(self._dispatch_task)
|
||||||
await self._dispatch_task
|
await self._cancel(self._retention_task)
|
||||||
|
self._dispatch_task = None
|
||||||
|
self._retention_task = None
|
||||||
for dispatcher in self._dispatchers:
|
for dispatcher in self._dispatchers:
|
||||||
await dispatcher.stop()
|
await dispatcher.stop()
|
||||||
for runner in reversed(self._source_runners):
|
for runner in reversed(self._source_runners):
|
||||||
await runner.stop()
|
await runner.stop()
|
||||||
|
recovered = await self._store.recover_inflight()
|
||||||
|
if recovered:
|
||||||
|
log.info("delivering_requeued", count=recovered)
|
||||||
log.info("pipeline_stopped")
|
log.info("pipeline_stopped")
|
||||||
|
|
||||||
|
async def note_replayed(self, records: int) -> None:
|
||||||
|
"""Account for dead records that are inflight again."""
|
||||||
|
if records <= 0:
|
||||||
|
return
|
||||||
|
async with self._capacity:
|
||||||
|
self._inflight += records
|
||||||
|
|
||||||
|
def stats(self) -> dict[str, SinkStats]:
|
||||||
|
return {name: SinkStats(**vars(item)) for name, item in self._stats.items()}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sources(self) -> list[Source]:
|
||||||
|
return list(self._sources)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sinks(self) -> list[Sink]:
|
||||||
|
return [dispatcher.sink for dispatcher in self._dispatchers]
|
||||||
|
|
||||||
|
def sink_types(self) -> dict[str, str]:
|
||||||
|
return {dispatcher.name: dispatcher.plugin_type for dispatcher in self._dispatchers}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def queue_size(self) -> int:
|
||||||
|
return self._inflight
|
||||||
|
|
||||||
def _build_pipeline(self) -> None:
|
def _build_pipeline(self) -> None:
|
||||||
for sc in self._config.sources:
|
self._sources.clear()
|
||||||
cls = self._registry.get_source(sc.type)
|
self._processors.clear()
|
||||||
if not cls:
|
self._dispatchers.clear()
|
||||||
log.warning("source_type_not_found", type=sc.type)
|
self._source_runners.clear()
|
||||||
|
self._stats.clear()
|
||||||
|
self._sink_names = []
|
||||||
|
|
||||||
|
for source_cfg in self._config.sources:
|
||||||
|
if not source_cfg.enabled:
|
||||||
|
log.info("source_disabled", source=source_cfg.name)
|
||||||
continue
|
continue
|
||||||
source = cls(name=sc.name, config=sc.options)
|
cls = self._registry.get_source(source_cfg.type)
|
||||||
|
if cls is None:
|
||||||
|
log.warning("source_type_not_found", type=source_cfg.type, name=source_cfg.name)
|
||||||
|
continue
|
||||||
|
source = cls(name=source_cfg.name, config=source_cfg.options)
|
||||||
self._sources.append(source)
|
self._sources.append(source)
|
||||||
runner = SourceRunner(source, self._ingest)
|
self._source_runners.append(SourceRunner(source, self._ingest))
|
||||||
self._source_runners.append(runner)
|
|
||||||
|
|
||||||
for pc in self._config.processors:
|
for processor_cfg in self._config.processors:
|
||||||
cls = self._registry.get_processor(pc.type)
|
if not processor_cfg.enabled:
|
||||||
if not cls:
|
log.info("processor_disabled", processor=processor_cfg.name)
|
||||||
log.warning("processor_type_not_found", type=pc.type)
|
|
||||||
continue
|
continue
|
||||||
proc = cls(name=pc.name, config=pc.options)
|
cls = self._registry.get_processor(processor_cfg.type)
|
||||||
self._processors.append(proc)
|
if cls is None:
|
||||||
|
log.warning(
|
||||||
|
"processor_type_not_found",
|
||||||
|
type=processor_cfg.type,
|
||||||
|
name=processor_cfg.name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
self._processors.append(cls(name=processor_cfg.name, config=processor_cfg.options))
|
||||||
|
|
||||||
for sc in self._config.sinks:
|
for sink_cfg in self._config.sinks:
|
||||||
cls = self._registry.get_sink(sc.type)
|
if not sink_cfg.enabled:
|
||||||
if not cls:
|
log.info("sink_disabled", sink=sink_cfg.name)
|
||||||
log.warning("sink_type_not_found", type=sc.type)
|
|
||||||
continue
|
continue
|
||||||
sink = cls(name=sc.name, config=sc.options)
|
cls = self._registry.get_sink(sink_cfg.type)
|
||||||
self._dispatchers.append(SinkDispatcher(sink, self._backend, self._config))
|
if cls is None:
|
||||||
|
log.warning("sink_type_not_found", type=sink_cfg.type, name=sink_cfg.name)
|
||||||
|
continue
|
||||||
|
sink = cls(name=sink_cfg.name, config=sink_cfg.options)
|
||||||
|
self._dispatchers.append(SinkDispatcher(sink, sink_cfg.type))
|
||||||
|
self._stats[sink_cfg.name] = SinkStats()
|
||||||
|
self._sink_names.append(sink_cfg.name)
|
||||||
|
|
||||||
async def _ingest(self, record: Record) -> None:
|
async def _ingest(self, record: Record) -> None:
|
||||||
"""Called by sources. Runs processors and persists."""
|
for processor in self._processors:
|
||||||
for proc in self._processors:
|
result = await processor.process(record)
|
||||||
result = await proc.process(record)
|
|
||||||
if result is None:
|
if result is None:
|
||||||
|
log.debug("record_dropped", record_id=record.record_id)
|
||||||
|
self._flow("dropped", record, processor=processor.name)
|
||||||
return
|
return
|
||||||
record = result
|
record = result
|
||||||
|
|
||||||
|
if not self._sink_names:
|
||||||
|
log.warning("record_dropped_no_sinks", record_id=record.record_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
limit = self._config.pipeline.max_queue_size
|
||||||
|
async with self._capacity:
|
||||||
|
while self._running and limit > 0 and self._inflight >= limit:
|
||||||
|
await self._capacity.wait()
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
self._inflight += 1
|
||||||
|
|
||||||
stored = StoredRecord(
|
stored = StoredRecord(
|
||||||
record_id=record.record_id,
|
record_id=record.record_id,
|
||||||
source=record.source,
|
source=record.source,
|
||||||
@@ -162,104 +241,185 @@ class Pipeline:
|
|||||||
metadata=record.metadata,
|
metadata=record.metadata,
|
||||||
destination=record.destination,
|
destination=record.destination,
|
||||||
created_at=record.timestamp,
|
created_at=record.timestamp,
|
||||||
|
sinks=list(self._sink_names),
|
||||||
)
|
)
|
||||||
self._backend.put(stored)
|
try:
|
||||||
log.debug("record_persisted", record_id=record.record_id)
|
await self._store.put(stored)
|
||||||
|
except Exception:
|
||||||
|
async with self._capacity:
|
||||||
|
self._inflight = max(0, self._inflight - 1)
|
||||||
|
self._capacity.notify_all()
|
||||||
|
raise
|
||||||
|
log.debug("record_persisted", record_id=record.record_id, sinks=self._sink_names)
|
||||||
|
self._flow("collected", record)
|
||||||
|
|
||||||
async def _dispatch_loop(self) -> None:
|
async def _dispatch_loop(self) -> None:
|
||||||
"""Background loop: pull pending records from backend and deliver."""
|
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
await self._dispatch_batch()
|
did_work = await self._dispatch_batch()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("dispatch_loop_error")
|
log.exception("dispatch_loop_error")
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
continue
|
||||||
|
if not did_work and self._running:
|
||||||
|
await asyncio.sleep(await self._idle_delay())
|
||||||
|
|
||||||
async def _dispatch_batch(self) -> None:
|
async def _dispatch_batch(self) -> bool:
|
||||||
pending = self._backend.pending(limit=self._config.pipeline.batch_size)
|
if not self._dispatchers:
|
||||||
retry = self._backend.ready_for_retry(limit=self._config.pipeline.batch_size)
|
return False
|
||||||
records = pending + retry
|
|
||||||
|
|
||||||
if not records:
|
async def drain(dispatcher: SinkDispatcher) -> int:
|
||||||
await asyncio.sleep(self._config.pipeline.flush_interval)
|
claimed = await self._store.claim(
|
||||||
return
|
dispatcher.name,
|
||||||
|
limit=self._config.pipeline.batch_size,
|
||||||
for stored in records:
|
|
||||||
if not self._running:
|
|
||||||
break
|
|
||||||
record = Record(
|
|
||||||
source=stored.source,
|
|
||||||
record_type=stored.record_type,
|
|
||||||
body=stored.body,
|
|
||||||
metadata=stored.metadata,
|
|
||||||
destination=stored.destination,
|
|
||||||
record_id=stored.record_id,
|
|
||||||
timestamp=stored.created_at,
|
|
||||||
)
|
)
|
||||||
for dispatcher in self._dispatchers:
|
for stored in claimed:
|
||||||
await self._deliver_with_retry(dispatcher, record)
|
if not self._running:
|
||||||
|
break
|
||||||
|
await self._deliver_once(dispatcher, stored)
|
||||||
|
return len(claimed)
|
||||||
|
|
||||||
if len(records) < self._config.pipeline.batch_size:
|
counts = await asyncio.gather(*(drain(dispatcher) for dispatcher in self._dispatchers))
|
||||||
await asyncio.sleep(self._config.pipeline.flush_interval)
|
return any(count > 0 for count in counts)
|
||||||
|
|
||||||
async def _deliver_with_retry(self, dispatcher: SinkDispatcher, record: Record) -> None:
|
async def _deliver_once(self, dispatcher: SinkDispatcher, stored: StoredRecord) -> None:
|
||||||
retry_cfg = None
|
record = Record(
|
||||||
for sc in self._config.sinks:
|
source=stored.source,
|
||||||
if sc.name == dispatcher._sink.name:
|
record_type=stored.record_type,
|
||||||
retry_cfg = sc.retry
|
body=stored.body,
|
||||||
break
|
metadata=stored.metadata,
|
||||||
|
destination=stored.destination,
|
||||||
|
record_id=stored.record_id,
|
||||||
|
timestamp=stored.created_at,
|
||||||
|
)
|
||||||
|
delivery = stored.deliveries.get(dispatcher.name)
|
||||||
|
retry_count = delivery.retry_count if delivery else 0
|
||||||
|
try:
|
||||||
|
result = await dispatcher.deliver(record)
|
||||||
|
except Exception as exc:
|
||||||
|
result = DeliveryResult(success=False, record_id=record.record_id, error=str(exc))
|
||||||
|
|
||||||
max_attempts = retry_cfg.max_attempts if retry_cfg else 3
|
retry_cfg = self._retry_for(dispatcher.name)
|
||||||
min_delay = retry_cfg.min_delay if retry_cfg else 1.0
|
attempts_used = retry_count + 1
|
||||||
max_delay = retry_cfg.max_delay if retry_cfg else 120.0
|
if result.success:
|
||||||
|
await self._finish(record.record_id, dispatcher.name, "ack")
|
||||||
|
self._flow("delivered", record, sink=dispatcher.name)
|
||||||
|
return
|
||||||
|
error = result.error or "unknown"
|
||||||
|
if result.permanent or attempts_used >= retry_cfg.max_attempts:
|
||||||
|
await self._finish(record.record_id, dispatcher.name, "dead", error)
|
||||||
|
self._flow("dead", record, sink=dispatcher.name, error=error)
|
||||||
|
return
|
||||||
|
delay = min(
|
||||||
|
retry_cfg.min_delay * (retry_cfg.multiplier**retry_count),
|
||||||
|
retry_cfg.max_delay,
|
||||||
|
)
|
||||||
|
await self._finish(record.record_id, dispatcher.name, "nack", error, delay)
|
||||||
|
self._flow("retry", record, sink=dispatcher.name, error=error)
|
||||||
|
|
||||||
attempt = 0
|
async def _finish(
|
||||||
while attempt < max_attempts:
|
self,
|
||||||
attempt += 1
|
record_id: str,
|
||||||
try:
|
sink: str,
|
||||||
result = await dispatcher.deliver(record)
|
action: str,
|
||||||
except Exception as exc:
|
error: str | None = None,
|
||||||
result = DeliveryResult(
|
delay: float = 0,
|
||||||
success=False,
|
) -> None:
|
||||||
record_id=record.record_id,
|
stats = self._stats.setdefault(sink, SinkStats())
|
||||||
error=str(exc),
|
if action == "ack":
|
||||||
)
|
await self._store.ack(record_id, sink)
|
||||||
|
stats.delivered += 1
|
||||||
|
stats.last_error = ""
|
||||||
|
elif action == "nack":
|
||||||
|
await self._store.nack(record_id, error or "unknown", retry_delay=delay, sink=sink)
|
||||||
|
stats.failed += 1
|
||||||
|
stats.last_error = error or ""
|
||||||
|
log.warning("delivery_retry", sink=sink, record_id=record_id, error=error, delay=delay)
|
||||||
|
else:
|
||||||
|
await self._store.dead(record_id, error or "dead", sink)
|
||||||
|
stats.dead += 1
|
||||||
|
stats.last_error = error or ""
|
||||||
|
log.error("delivery_dead", sink=sink, record_id=record_id, error=error)
|
||||||
|
|
||||||
if result.success:
|
stored = await self._store.get(record_id)
|
||||||
self._backend.ack(record.record_id)
|
if stored is not None and stored.is_terminal():
|
||||||
return
|
async with self._capacity:
|
||||||
|
self._inflight = max(0, self._inflight - 1)
|
||||||
|
self._capacity.notify_all()
|
||||||
|
|
||||||
if result.permanent:
|
async def _idle_delay(self) -> float:
|
||||||
self._backend.dead(record.record_id, result.error or "permanent failure")
|
delay = self._config.pipeline.flush_interval
|
||||||
return
|
earliest = await self._store.earliest_retry_at()
|
||||||
|
if earliest is None:
|
||||||
|
return delay
|
||||||
|
try:
|
||||||
|
when = datetime.fromisoformat(earliest)
|
||||||
|
except ValueError:
|
||||||
|
return delay
|
||||||
|
if when.tzinfo is None:
|
||||||
|
when = when.replace(tzinfo=UTC)
|
||||||
|
remaining = (when - datetime.now(UTC)).total_seconds()
|
||||||
|
if remaining < delay:
|
||||||
|
return max(0.0, remaining)
|
||||||
|
return delay
|
||||||
|
|
||||||
backoff = min(min_delay * (2 ** (attempt - 1)), max_delay)
|
def _flow(self, stage: str, record: Record, **fields: str) -> None:
|
||||||
stored = self._backend.get(record.record_id)
|
if not self._config.logging.verbose:
|
||||||
if stored:
|
return
|
||||||
self._backend.nack(
|
log.info(
|
||||||
record.record_id,
|
"flow",
|
||||||
error=result.error or "unknown",
|
stage=stage,
|
||||||
retry_delay=backoff,
|
source=record.source,
|
||||||
)
|
record_id=record.record_id[:8],
|
||||||
|
message=preview_message(record.body),
|
||||||
if attempt < max_attempts:
|
**{key: value for key, value in fields.items() if value},
|
||||||
await asyncio.sleep(backoff)
|
|
||||||
|
|
||||||
self._backend.dead(
|
|
||||||
record.record_id,
|
|
||||||
f"max retries ({max_attempts}) exceeded: {result.error}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
def _retry_for(self, sink_name: str) -> SinkRetryConfig:
|
||||||
def sources(self) -> list[Source]:
|
for sink_cfg in self._config.sinks:
|
||||||
return list(self._sources)
|
if sink_cfg.name == sink_name:
|
||||||
|
return sink_cfg.retry
|
||||||
|
return SinkRetryConfig()
|
||||||
|
|
||||||
@property
|
async def _retention_loop(self) -> None:
|
||||||
def sinks(self) -> list[Sink]:
|
interval = self._config.retention.interval_seconds
|
||||||
return [d._sink for d in self._dispatchers]
|
while self._running:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
await self._retain()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
log.exception("retention_error")
|
||||||
|
|
||||||
@property
|
async def _retain(self) -> None:
|
||||||
def queue_size(self) -> int:
|
retention = self._config.retention
|
||||||
return self._queue.qsize()
|
now = datetime.now(UTC)
|
||||||
|
delivered_before = (now - timedelta(seconds=retention.delivered_ttl_seconds)).isoformat()
|
||||||
|
dead_before = (now - timedelta(seconds=retention.dead_ttl_seconds)).isoformat()
|
||||||
|
removed = await self._store.delete_expired(delivered_before, dead_before)
|
||||||
|
if removed:
|
||||||
|
log.info("retention_deleted", records=removed)
|
||||||
|
|
||||||
|
async def _cancel(self, task: asyncio.Task[None] | None) -> None:
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
|
||||||
|
def sink_stats_map(stats: dict[str, SinkStats]) -> dict[str, dict[str, int]]:
|
||||||
|
"""Plain counters for metrics rendering."""
|
||||||
|
rendered: dict[str, dict[str, int]] = {}
|
||||||
|
for name, item in stats.items():
|
||||||
|
rendered[name] = field_counts(item)
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
def field_counts(item: SinkStats) -> dict[str, int]:
|
||||||
|
return {"delivered": item.delivered, "failed": item.failed, "dead": item.dead}
|
||||||
|
|||||||
+144
-19
@@ -1,7 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from daena.config import DaenaConfig
|
import asyncio
|
||||||
from daena.core.pipeline import Pipeline
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from daena.config import DaenaConfig, load_config
|
||||||
|
from daena.core.pipeline import Pipeline, SinkStats
|
||||||
from daena.logutil import get_logger
|
from daena.logutil import get_logger
|
||||||
from daena.persistence.backend import Backend
|
from daena.persistence.backend import Backend
|
||||||
from daena.persistence.lmdb import LMDBBackend
|
from daena.persistence.lmdb import LMDBBackend
|
||||||
@@ -11,15 +16,34 @@ from daena.plugins.registry import PluginRegistry, get_registry
|
|||||||
log = get_logger("daena.runtime")
|
log = get_logger("daena.runtime")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HealthReport:
|
||||||
|
healthy: bool
|
||||||
|
running: bool
|
||||||
|
backend: bool
|
||||||
|
plugins_loaded: bool
|
||||||
|
status: str
|
||||||
|
oldest_pending_age_seconds: int
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
class DaenaRuntime:
|
class DaenaRuntime:
|
||||||
"""Top-level service runtime.
|
"""Top-level service runtime.
|
||||||
|
|
||||||
Owns configuration, persistence backend, pipeline, and lifecycle.
|
Owns configuration, the persistence backend, and the pipeline.
|
||||||
Designed to run under asyncio with clean signal-based shutdown.
|
Reload re-reads the config file when one was provided, then restarts
|
||||||
|
the pipeline against the same data directory.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config: DaenaConfig) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: DaenaConfig,
|
||||||
|
config_path: str | Path | None = None,
|
||||||
|
force_verbose: bool = False,
|
||||||
|
) -> None:
|
||||||
self._config = config
|
self._config = config
|
||||||
|
self._config_path = Path(config_path) if config_path is not None else None
|
||||||
|
self._force_verbose = force_verbose
|
||||||
self._registry = get_registry()
|
self._registry = get_registry()
|
||||||
self._backend: Backend | None = None
|
self._backend: Backend | None = None
|
||||||
self._pipeline: Pipeline | None = None
|
self._pipeline: Pipeline | None = None
|
||||||
@@ -29,6 +53,10 @@ class DaenaRuntime:
|
|||||||
def config(self) -> DaenaConfig:
|
def config(self) -> DaenaConfig:
|
||||||
return self._config
|
return self._config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config_path(self) -> Path | None:
|
||||||
|
return self._config_path
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def registry(self) -> PluginRegistry:
|
def registry(self) -> PluginRegistry:
|
||||||
return self._registry
|
return self._registry
|
||||||
@@ -41,9 +69,19 @@ class DaenaRuntime:
|
|||||||
def pipeline(self) -> Pipeline | None:
|
def pipeline(self) -> Pipeline | None:
|
||||||
return self._pipeline
|
return self._pipeline
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
log.info("runtime_starting")
|
log.info("runtime_starting")
|
||||||
|
try:
|
||||||
|
await self._start()
|
||||||
|
except Exception:
|
||||||
|
await self.stop()
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _start(self) -> None:
|
||||||
discover_plugins()
|
discover_plugins()
|
||||||
log.info(
|
log.info(
|
||||||
"plugins_discovered",
|
"plugins_discovered",
|
||||||
@@ -53,16 +91,17 @@ class DaenaRuntime:
|
|||||||
)
|
)
|
||||||
|
|
||||||
persistence_cfg = self._config.persistence
|
persistence_cfg = self._config.persistence
|
||||||
if persistence_cfg.backend == "lmdb":
|
if persistence_cfg.backend != "lmdb":
|
||||||
self._backend = LMDBBackend(
|
|
||||||
path=persistence_cfg.lmdb.path,
|
|
||||||
map_size=persistence_cfg.lmdb.map_size,
|
|
||||||
max_databases=persistence_cfg.lmdb.max_databases,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown backend: {persistence_cfg.backend}")
|
raise ValueError(f"Unknown backend: {persistence_cfg.backend}")
|
||||||
|
self._backend = LMDBBackend(
|
||||||
|
path=persistence_cfg.lmdb.path,
|
||||||
|
map_size=persistence_cfg.lmdb.map_size,
|
||||||
|
max_databases=persistence_cfg.lmdb.max_databases,
|
||||||
|
)
|
||||||
self._backend.open()
|
self._backend.open()
|
||||||
|
recovered = self._backend.recover_inflight()
|
||||||
|
if recovered:
|
||||||
|
log.info("startup_requeued", count=recovered)
|
||||||
log.info("backend_opened", backend=persistence_cfg.backend)
|
log.info("backend_opened", backend=persistence_cfg.backend)
|
||||||
|
|
||||||
self._pipeline = Pipeline(self._config, self._backend, self._registry)
|
self._pipeline = Pipeline(self._config, self._backend, self._registry)
|
||||||
@@ -73,18 +112,104 @@ class DaenaRuntime:
|
|||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
log.info("runtime_stopping")
|
log.info("runtime_stopping")
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|
||||||
if self._pipeline:
|
if self._pipeline:
|
||||||
await self._pipeline.stop()
|
await self._pipeline.stop()
|
||||||
|
self._pipeline = None
|
||||||
if self._backend:
|
if self._backend:
|
||||||
self._backend.close()
|
self._backend.close()
|
||||||
|
self._backend = None
|
||||||
log.info("backend_closed")
|
log.info("backend_closed")
|
||||||
|
|
||||||
log.info("runtime_stopped")
|
log.info("runtime_stopped")
|
||||||
|
|
||||||
async def reload(self) -> None:
|
async def reload(self) -> None:
|
||||||
log.info("runtime_reloading")
|
"""Restart the pipeline. Re-read YAML when a config path is known."""
|
||||||
await self.stop()
|
previous = self._config
|
||||||
await self.start()
|
if self._config_path is not None:
|
||||||
|
self._config = load_config(self._config_path)
|
||||||
|
if self._force_verbose:
|
||||||
|
self._config.logging.verbose = True
|
||||||
|
log.info("runtime_reloading", path=str(self._config_path) if self._config_path else None)
|
||||||
|
try:
|
||||||
|
await self.stop()
|
||||||
|
await self.start()
|
||||||
|
except Exception:
|
||||||
|
log.exception("runtime_reload_failed")
|
||||||
|
self._config = previous
|
||||||
|
if not self._running:
|
||||||
|
await self.start()
|
||||||
|
raise
|
||||||
log.info("runtime_reloaded")
|
log.info("runtime_reloaded")
|
||||||
|
|
||||||
|
async def replay_dead(
|
||||||
|
self,
|
||||||
|
record_id: str | None = None,
|
||||||
|
sink: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> int:
|
||||||
|
if self._backend is None:
|
||||||
|
return 0
|
||||||
|
replayed = await asyncio.to_thread(
|
||||||
|
self._backend.replay,
|
||||||
|
record_id,
|
||||||
|
sink,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if self._pipeline is not None:
|
||||||
|
await self._pipeline.note_replayed(replayed)
|
||||||
|
return replayed
|
||||||
|
|
||||||
|
def health(self) -> HealthReport:
|
||||||
|
running = self._running
|
||||||
|
backend_open = self._backend is not None
|
||||||
|
plugins_loaded = bool(self._registry.list_sources())
|
||||||
|
age = self._oldest_age_seconds() if backend_open else 0
|
||||||
|
threshold = self._config.health.max_pending_age_seconds
|
||||||
|
degraded = backend_open and age > threshold
|
||||||
|
if not running or not backend_open:
|
||||||
|
return HealthReport(
|
||||||
|
healthy=False,
|
||||||
|
running=running,
|
||||||
|
backend=backend_open,
|
||||||
|
plugins_loaded=plugins_loaded,
|
||||||
|
status="down",
|
||||||
|
oldest_pending_age_seconds=age,
|
||||||
|
detail="runtime is not running" if not running else "backend is closed",
|
||||||
|
)
|
||||||
|
if degraded:
|
||||||
|
return HealthReport(
|
||||||
|
healthy=False,
|
||||||
|
running=True,
|
||||||
|
backend=True,
|
||||||
|
plugins_loaded=plugins_loaded,
|
||||||
|
status="degraded",
|
||||||
|
oldest_pending_age_seconds=age,
|
||||||
|
detail=f"oldest inflight record is {age}s old",
|
||||||
|
)
|
||||||
|
return HealthReport(
|
||||||
|
healthy=True,
|
||||||
|
running=True,
|
||||||
|
backend=True,
|
||||||
|
plugins_loaded=plugins_loaded,
|
||||||
|
status="ok",
|
||||||
|
oldest_pending_age_seconds=age,
|
||||||
|
detail="ok",
|
||||||
|
)
|
||||||
|
|
||||||
|
def sink_stats(self) -> dict[str, SinkStats]:
|
||||||
|
if self._pipeline is None:
|
||||||
|
return {}
|
||||||
|
return self._pipeline.stats()
|
||||||
|
|
||||||
|
def _oldest_age_seconds(self) -> int:
|
||||||
|
if self._backend is None:
|
||||||
|
return 0
|
||||||
|
oldest = self._backend.oldest_queued_at()
|
||||||
|
if oldest is None:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
queued = datetime.fromisoformat(oldest)
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
if queued.tzinfo is None:
|
||||||
|
queued = queued.replace(tzinfo=UTC)
|
||||||
|
return max(0, int((datetime.now(UTC) - queued).total_seconds()))
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
from daena.persistence.backend import Backend
|
||||||
|
from daena.persistence.models import RecordState, StoredRecord
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncBackend:
|
||||||
|
"""Runs synchronous backend calls off the event loop."""
|
||||||
|
|
||||||
|
def __init__(self, backend: Backend) -> None:
|
||||||
|
self._backend = backend
|
||||||
|
|
||||||
|
async def put(self, record: StoredRecord) -> None:
|
||||||
|
await asyncio.to_thread(self._backend.put, record)
|
||||||
|
|
||||||
|
async def get(self, record_id: str) -> StoredRecord | None:
|
||||||
|
return await asyncio.to_thread(self._backend.get, record_id)
|
||||||
|
|
||||||
|
async def claim(
|
||||||
|
self,
|
||||||
|
sink: str,
|
||||||
|
limit: int = 100,
|
||||||
|
now: str | None = None,
|
||||||
|
) -> list[StoredRecord]:
|
||||||
|
return await asyncio.to_thread(self._backend.claim, sink, limit, now)
|
||||||
|
|
||||||
|
async def ack(self, record_id: str, sink: str | None = None) -> None:
|
||||||
|
await asyncio.to_thread(self._backend.ack, record_id, sink)
|
||||||
|
|
||||||
|
async def nack(
|
||||||
|
self,
|
||||||
|
record_id: str,
|
||||||
|
error: str,
|
||||||
|
retry_delay: float = 10.0,
|
||||||
|
sink: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
await asyncio.to_thread(self._backend.nack, record_id, error, retry_delay, sink)
|
||||||
|
|
||||||
|
async def dead(self, record_id: str, error: str, sink: str | None = None) -> None:
|
||||||
|
await asyncio.to_thread(self._backend.dead, record_id, error, sink)
|
||||||
|
|
||||||
|
async def recover_inflight(self) -> int:
|
||||||
|
return await asyncio.to_thread(self._backend.recover_inflight)
|
||||||
|
|
||||||
|
async def replay(
|
||||||
|
self,
|
||||||
|
record_id: str | None = None,
|
||||||
|
sink: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> int:
|
||||||
|
return await asyncio.to_thread(self._backend.replay, record_id, sink, limit)
|
||||||
|
|
||||||
|
async def count(self, state: RecordState | None = None, sink: str | None = None) -> int:
|
||||||
|
return await asyncio.to_thread(self._backend.count, state, sink)
|
||||||
|
|
||||||
|
async def sink_counts(self) -> dict[str, dict[str, int]]:
|
||||||
|
return await asyncio.to_thread(self._backend.sink_counts)
|
||||||
|
|
||||||
|
async def count_inflight(self) -> int:
|
||||||
|
return await asyncio.to_thread(self._backend.count_inflight)
|
||||||
|
|
||||||
|
async def oldest_queued_at(self) -> str | None:
|
||||||
|
return await asyncio.to_thread(self._backend.oldest_queued_at)
|
||||||
|
|
||||||
|
async def earliest_retry_at(self) -> str | None:
|
||||||
|
return await asyncio.to_thread(self._backend.earliest_retry_at)
|
||||||
|
|
||||||
|
async def list_by_state(
|
||||||
|
self,
|
||||||
|
state: RecordState,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
sink: str | None = None,
|
||||||
|
) -> list[StoredRecord]:
|
||||||
|
return await asyncio.to_thread(self._backend.list_by_state, state, limit, offset, sink)
|
||||||
|
|
||||||
|
async def delete_expired(self, delivered_before: str, dead_before: str) -> int:
|
||||||
|
return await asyncio.to_thread(self._backend.delete_expired, delivered_before, dead_before)
|
||||||
|
|
||||||
|
def iter_pending(self) -> Iterator[StoredRecord]:
|
||||||
|
return self._backend.iter_pending()
|
||||||
@@ -7,11 +7,10 @@ from daena.persistence.models import RecordState, StoredRecord
|
|||||||
|
|
||||||
|
|
||||||
class Backend(Protocol):
|
class Backend(Protocol):
|
||||||
"""Abstract interface for persistence backends.
|
"""Storage contract for the durable record buffer.
|
||||||
|
|
||||||
This Protocol defines the contract that all storage backends must fulfill.
|
Delivery state is tracked per sink. Index keys for a delivery are replaced
|
||||||
The MVP implementation uses LMDB; future implementations may target
|
on every transition so scans stay exact.
|
||||||
NATS JetStream, filesystem-backed stores, or cloud queues.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def open(self) -> None:
|
def open(self) -> None:
|
||||||
@@ -21,7 +20,7 @@ class Backend(Protocol):
|
|||||||
"""Close the backend and release resources."""
|
"""Close the backend and release resources."""
|
||||||
|
|
||||||
def put(self, record: StoredRecord) -> None:
|
def put(self, record: StoredRecord) -> None:
|
||||||
"""Store a new record."""
|
"""Store a new record. Existing ids are left unchanged."""
|
||||||
|
|
||||||
def get(self, record_id: str) -> StoredRecord | None:
|
def get(self, record_id: str) -> StoredRecord | None:
|
||||||
"""Retrieve a record by ID."""
|
"""Retrieve a record by ID."""
|
||||||
@@ -32,57 +31,94 @@ class Backend(Protocol):
|
|||||||
state: RecordState,
|
state: RecordState,
|
||||||
last_error: str | None = None,
|
last_error: str | None = None,
|
||||||
next_retry_at: str | None = None,
|
next_retry_at: str | None = None,
|
||||||
|
sink: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update a record's delivery state."""
|
"""Set one sink's delivery state. Sink is inferred when unambiguous."""
|
||||||
|
|
||||||
def pending(
|
def pending(
|
||||||
self,
|
self,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
older_than: str | None = None,
|
older_than: str | None = None,
|
||||||
|
sink: str | None = None,
|
||||||
) -> list[StoredRecord]:
|
) -> list[StoredRecord]:
|
||||||
"""Return pending records for delivery, oldest first."""
|
"""Return records with a pending delivery, oldest first."""
|
||||||
|
|
||||||
def ready_for_retry(
|
def ready_for_retry(
|
||||||
self,
|
self,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
now: str | None = None,
|
now: str | None = None,
|
||||||
|
sink: str | None = None,
|
||||||
) -> list[StoredRecord]:
|
) -> list[StoredRecord]:
|
||||||
"""Return failed records whose retry time has arrived."""
|
"""Return records whose failed delivery is due for another attempt."""
|
||||||
|
|
||||||
def ack(self, record_id: str) -> None:
|
def claim(self, sink: str, limit: int = 100, now: str | None = None) -> list[StoredRecord]:
|
||||||
"""Mark a record as successfully delivered."""
|
"""Mark due deliveries for one sink as delivering and return them."""
|
||||||
|
|
||||||
|
def ack(self, record_id: str, sink: str | None = None) -> None:
|
||||||
|
"""Mark one sink's delivery as successful."""
|
||||||
|
|
||||||
def nack(
|
def nack(
|
||||||
self,
|
self,
|
||||||
record_id: str,
|
record_id: str,
|
||||||
error: str,
|
error: str,
|
||||||
retry_delay: float = 10.0,
|
retry_delay: float = 10.0,
|
||||||
|
sink: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Mark delivery failed and schedule retry."""
|
"""Mark one sink's delivery failed and schedule a later attempt."""
|
||||||
|
|
||||||
def dead(self, record_id: str, error: str) -> None:
|
def dead(self, record_id: str, error: str, sink: str | None = None) -> None:
|
||||||
"""Move a record to the dead-letter queue."""
|
"""Move one sink's delivery to the dead-letter state."""
|
||||||
|
|
||||||
def count(self, state: RecordState | None = None) -> int:
|
def recover_inflight(self) -> int:
|
||||||
"""Count records, optionally filtered by state."""
|
"""Return deliveries left in `delivering` back to `pending`."""
|
||||||
|
|
||||||
|
def replay(
|
||||||
|
self,
|
||||||
|
record_id: str | None = None,
|
||||||
|
sink: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> int:
|
||||||
|
"""Requeue dead deliveries. Returns how many records became inflight."""
|
||||||
|
|
||||||
|
def count(self, state: RecordState | None = None, sink: str | None = None) -> int:
|
||||||
|
"""Count records, or deliveries when a state or sink is given."""
|
||||||
|
|
||||||
|
def sink_counts(self) -> dict[str, dict[str, int]]:
|
||||||
|
"""Delivery counts keyed by sink then state."""
|
||||||
|
|
||||||
|
def count_inflight(self) -> int:
|
||||||
|
"""Records that still need work from at least one sink."""
|
||||||
|
|
||||||
|
def oldest_queued_at(self) -> str | None:
|
||||||
|
"""Earliest queue time among pending, delivering, and failed deliveries."""
|
||||||
|
|
||||||
|
def earliest_retry_at(self) -> str | None:
|
||||||
|
"""Soonest scheduled retry timestamp, if any failed delivery is waiting."""
|
||||||
|
|
||||||
def list_by_state(
|
def list_by_state(
|
||||||
self,
|
self,
|
||||||
state: RecordState,
|
state: RecordState,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
|
sink: str | None = None,
|
||||||
) -> list[StoredRecord]:
|
) -> list[StoredRecord]:
|
||||||
"""List records by state with pagination."""
|
"""List records that have a delivery in `state`."""
|
||||||
|
|
||||||
def iter_pending(self) -> Iterator[StoredRecord]:
|
def iter_pending(self) -> Iterator[StoredRecord]:
|
||||||
"""Iterate over all pending records (for recovery)."""
|
"""Iterate records that still have a pending delivery."""
|
||||||
|
|
||||||
def delete_old(
|
def delete_old(self, before: str, state: RecordState | None = None) -> int:
|
||||||
|
"""Delete records whose deliveries are all in `state` and older than `before`."""
|
||||||
|
|
||||||
|
def delete_expired(
|
||||||
self,
|
self,
|
||||||
before: str,
|
delivered_before: str,
|
||||||
state: RecordState | None = None,
|
dead_before: str,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Delete records older than a timestamp. Returns count."""
|
"""Delete fully terminal records once each delivery is past its own cutoff."""
|
||||||
|
|
||||||
|
def index_count(self) -> int:
|
||||||
|
"""Number of secondary index keys. Used to detect leaked indexes."""
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
"""Perform backend-specific maintenance."""
|
"""Rebuild derived counters from stored records."""
|
||||||
|
|||||||
+609
-206
@@ -1,45 +1,90 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
from collections.abc import Iterator
|
from collections.abc import Callable, Iterator
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import lmdb
|
import lmdb
|
||||||
|
|
||||||
from daena.exceptions import BackendError
|
from daena.exceptions import BackendError
|
||||||
from daena.persistence.models import RecordState, StoredRecord
|
from daena.persistence.models import DeliveryStatus, RecordState, StoredRecord
|
||||||
|
|
||||||
_REC_PREFIX = b"rec:"
|
_REC_PREFIX = b"rec:"
|
||||||
_IDX_PENDING = b"idx:pending:"
|
_IDX_PREFIX = b"idx\x00"
|
||||||
_IDX_DELIVERED = b"idx:delivered:"
|
_RETRY_PREFIX = b"rty\x00"
|
||||||
_IDX_FAILED = b"idx:failed:"
|
_META_SCHEMA = b"meta:schema"
|
||||||
_IDX_DEAD = b"idx:dead:"
|
_META_RECORDS = b"meta:records"
|
||||||
_IDX_RETRY = b"idx:retry:"
|
_META_STATE = b"meta:state\x00"
|
||||||
|
_SCHEMA_VERSION = b"2"
|
||||||
|
_QUEUE_STATES = frozenset({RecordState.pending, RecordState.delivering, RecordState.failed})
|
||||||
|
_LEGACY_INDEX_PREFIXES = (
|
||||||
|
b"idx:pending:",
|
||||||
|
b"idx:delivered:",
|
||||||
|
b"idx:failed:",
|
||||||
|
b"idx:dead:",
|
||||||
|
b"idx:retry:",
|
||||||
|
)
|
||||||
|
|
||||||
_PREFIX_MAP: dict[RecordState, bytes] = {
|
|
||||||
RecordState.pending: _IDX_PENDING,
|
def _check_sink(sink: str) -> None:
|
||||||
RecordState.delivered: _IDX_DELIVERED,
|
if not sink or "\x00" in sink:
|
||||||
RecordState.failed: _IDX_FAILED,
|
raise BackendError(f"Invalid sink name: {sink!r}")
|
||||||
RecordState.dead: _IDX_DEAD,
|
|
||||||
}
|
|
||||||
|
def _resolve_sink(record: StoredRecord, sink: str | None) -> str:
|
||||||
|
if sink is not None:
|
||||||
|
_check_sink(sink)
|
||||||
|
if sink not in record.deliveries:
|
||||||
|
raise BackendError(f"Unknown sink {sink} for record {record.record_id}")
|
||||||
|
return sink
|
||||||
|
if len(record.deliveries) == 1:
|
||||||
|
return next(iter(record.deliveries))
|
||||||
|
raise BackendError(
|
||||||
|
f"Record {record.record_id} has {len(record.deliveries)} deliveries; sink is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _state_index(sink: str, state: RecordState, ts: str, record_id: str) -> bytes:
|
||||||
|
return b"\x00".join(
|
||||||
|
(b"idx", sink.encode(), state.value.encode(), ts.encode(), record_id.encode())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _state_prefix(sink: str, state: RecordState) -> bytes:
|
||||||
|
return b"\x00".join((b"idx", sink.encode(), state.value.encode(), b""))
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_index(sink: str, ts: str, record_id: str) -> bytes:
|
||||||
|
return b"\x00".join((b"rty", sink.encode(), ts.encode(), record_id.encode()))
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_prefix(sink: str) -> bytes:
|
||||||
|
return b"\x00".join((b"rty", sink.encode(), b""))
|
||||||
|
|
||||||
|
|
||||||
|
def _meta_state(sink: str, state: RecordState) -> bytes:
|
||||||
|
return _META_STATE + sink.encode() + b"\x00" + state.value.encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_key(key: bytes) -> str:
|
||||||
|
return base64.b64encode(key).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_key(value: str) -> bytes:
|
||||||
|
return base64.b64decode(value.encode("ascii"))
|
||||||
|
|
||||||
|
|
||||||
|
def _as_bytes(value: bytes | memoryview) -> bytes:
|
||||||
|
if isinstance(value, memoryview):
|
||||||
|
return value.tobytes()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class LMDBBackend:
|
class LMDBBackend:
|
||||||
"""LMDB-based persistence backend using key-prefix partitioning.
|
"""LMDB buffer. One payload key per record, one live index key per delivery."""
|
||||||
|
|
||||||
Uses a single LMDB database with key prefixes to emulate named
|
|
||||||
sub-databases.
|
|
||||||
|
|
||||||
Key design:
|
|
||||||
- rec:{record_id} -> serialized record JSON
|
|
||||||
- idx:pending:{ts}\x00{id} -> "" (ordered FIFO queue)
|
|
||||||
- idx:delivered:{ts}\x00{id} -> ""
|
|
||||||
- idx:failed:{ts}\x00{id} -> ""
|
|
||||||
- idx:dead:{ts}\x00{id} -> ""
|
|
||||||
- idx:retry:{next}\x00{id} -> ""
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -59,7 +104,13 @@ class LMDBBackend:
|
|||||||
|
|
||||||
def open(self) -> None:
|
def open(self) -> None:
|
||||||
self._path.mkdir(parents=True, exist_ok=True)
|
self._path.mkdir(parents=True, exist_ok=True)
|
||||||
self._env = lmdb.open(str(self._path), self._map_size, self._max_databases)
|
self._env = lmdb.open(
|
||||||
|
str(self._path),
|
||||||
|
map_size=self._map_size,
|
||||||
|
subdir=True,
|
||||||
|
lock=True,
|
||||||
|
)
|
||||||
|
self._migrate()
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
if self._env is not None:
|
if self._env is not None:
|
||||||
@@ -69,40 +120,103 @@ class LMDBBackend:
|
|||||||
def _rec_key(self, record_id: str) -> bytes:
|
def _rec_key(self, record_id: str) -> bytes:
|
||||||
return _REC_PREFIX + record_id.encode()
|
return _REC_PREFIX + record_id.encode()
|
||||||
|
|
||||||
def _idx_key(self, prefix: bytes, record_id: str) -> bytes:
|
def _read(self, txn: lmdb.Transaction, record_id: str) -> StoredRecord | None:
|
||||||
ts = datetime.now(UTC).isoformat()
|
|
||||||
return prefix + f"{ts}\x00{record_id}".encode()
|
|
||||||
|
|
||||||
def _record_id_from_idx(self, key: bytes, prefix: bytes) -> str:
|
|
||||||
suffix = key[len(prefix) :].decode()
|
|
||||||
return suffix.split("\x00", 1)[1]
|
|
||||||
|
|
||||||
def _read_record(self, txn: lmdb.Transaction, record_id: str) -> StoredRecord | None:
|
|
||||||
raw = txn.get(self._rec_key(record_id))
|
raw = txn.get(self._rec_key(record_id))
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return None
|
return None
|
||||||
return StoredRecord.from_dict(json.loads(raw))
|
payload: dict[str, Any] = json.loads(_as_bytes(raw))
|
||||||
|
return StoredRecord.from_dict(payload)
|
||||||
|
|
||||||
|
def _save(self, txn: lmdb.Transaction, record: StoredRecord) -> None:
|
||||||
|
txn.put(self._rec_key(record.record_id), json.dumps(record.to_dict()).encode())
|
||||||
|
|
||||||
|
def _counter_get(self, txn: lmdb.Transaction, key: bytes) -> int:
|
||||||
|
raw = txn.get(key)
|
||||||
|
if raw is None:
|
||||||
|
return 0
|
||||||
|
return int(_as_bytes(raw))
|
||||||
|
|
||||||
|
def _counter_add(self, txn: lmdb.Transaction, key: bytes, delta: int) -> None:
|
||||||
|
value = self._counter_get(txn, key) + delta
|
||||||
|
if value <= 0:
|
||||||
|
txn.delete(key)
|
||||||
|
return
|
||||||
|
txn.put(key, str(value).encode())
|
||||||
|
|
||||||
|
def _reindex(
|
||||||
|
self,
|
||||||
|
txn: lmdb.Transaction,
|
||||||
|
record: StoredRecord,
|
||||||
|
delivery: DeliveryStatus,
|
||||||
|
previous: RecordState | None,
|
||||||
|
) -> None:
|
||||||
|
if delivery.index_key:
|
||||||
|
txn.delete(_decode_key(delivery.index_key))
|
||||||
|
delivery.index_key = None
|
||||||
|
if delivery.retry_index_key:
|
||||||
|
txn.delete(_decode_key(delivery.retry_index_key))
|
||||||
|
delivery.retry_index_key = None
|
||||||
|
|
||||||
|
ts = delivery.queued_at if delivery.state in _QUEUE_STATES else delivery.updated_at
|
||||||
|
key = _state_index(delivery.sink, delivery.state, ts, record.record_id)
|
||||||
|
txn.put(key, b"")
|
||||||
|
delivery.index_key = _encode_key(key)
|
||||||
|
if delivery.state == RecordState.failed and delivery.next_retry_at:
|
||||||
|
retry_key = _retry_index(delivery.sink, delivery.next_retry_at, record.record_id)
|
||||||
|
txn.put(retry_key, b"")
|
||||||
|
delivery.retry_index_key = _encode_key(retry_key)
|
||||||
|
if previous != delivery.state:
|
||||||
|
if previous is not None:
|
||||||
|
self._counter_add(txn, _meta_state(delivery.sink, previous), -1)
|
||||||
|
self._counter_add(txn, _meta_state(delivery.sink, delivery.state), 1)
|
||||||
|
|
||||||
|
def _delete_record(self, txn: lmdb.Transaction, record: StoredRecord) -> None:
|
||||||
|
for delivery in record.deliveries.values():
|
||||||
|
if delivery.index_key:
|
||||||
|
txn.delete(_decode_key(delivery.index_key))
|
||||||
|
if delivery.retry_index_key:
|
||||||
|
txn.delete(_decode_key(delivery.retry_index_key))
|
||||||
|
self._counter_add(txn, _meta_state(delivery.sink, delivery.state), -1)
|
||||||
|
txn.delete(self._rec_key(record.record_id))
|
||||||
|
self._counter_add(txn, _META_RECORDS, -1)
|
||||||
|
|
||||||
|
def _mutate(
|
||||||
|
self,
|
||||||
|
record_id: str,
|
||||||
|
sink: str | None,
|
||||||
|
change: Callable[[DeliveryStatus], None],
|
||||||
|
) -> StoredRecord | None:
|
||||||
|
env = self._ensure_env()
|
||||||
|
with env.begin(write=True) as txn:
|
||||||
|
record = self._read(txn, record_id)
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
sink_name = _resolve_sink(record, sink)
|
||||||
|
delivery = record.deliveries[sink_name]
|
||||||
|
previous = delivery.state
|
||||||
|
change(delivery)
|
||||||
|
self._reindex(txn, record, delivery, previous)
|
||||||
|
self._save(txn, record)
|
||||||
|
return record
|
||||||
|
|
||||||
def put(self, record: StoredRecord) -> None:
|
def put(self, record: StoredRecord) -> None:
|
||||||
|
for name in record.deliveries:
|
||||||
|
_check_sink(name)
|
||||||
env = self._ensure_env()
|
env = self._ensure_env()
|
||||||
rec_key = self._rec_key(record.record_id)
|
|
||||||
value = json.dumps(record.to_dict()).encode()
|
|
||||||
idx_key = self._idx_key(_IDX_PENDING, record.record_id)
|
|
||||||
|
|
||||||
with env.begin(write=True) as txn:
|
with env.begin(write=True) as txn:
|
||||||
if txn.get(rec_key) is not None:
|
if txn.get(self._rec_key(record.record_id)) is not None:
|
||||||
return
|
return
|
||||||
txn.put(rec_key, value)
|
for delivery in record.deliveries.values():
|
||||||
txn.put(idx_key, b"")
|
delivery.index_key = None
|
||||||
|
delivery.retry_index_key = None
|
||||||
|
self._reindex(txn, record, delivery, None)
|
||||||
|
self._save(txn, record)
|
||||||
|
self._counter_add(txn, _META_RECORDS, 1)
|
||||||
|
|
||||||
def get(self, record_id: str) -> StoredRecord | None:
|
def get(self, record_id: str) -> StoredRecord | None:
|
||||||
env = self._ensure_env()
|
env = self._ensure_env()
|
||||||
key = self._rec_key(record_id)
|
|
||||||
with env.begin() as txn:
|
with env.begin() as txn:
|
||||||
value = txn.get(key)
|
return self._read(txn, record_id)
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
return StoredRecord.from_dict(json.loads(value))
|
|
||||||
|
|
||||||
def update_state(
|
def update_state(
|
||||||
self,
|
self,
|
||||||
@@ -110,195 +224,484 @@ class LMDBBackend:
|
|||||||
state: RecordState,
|
state: RecordState,
|
||||||
last_error: str | None = None,
|
last_error: str | None = None,
|
||||||
next_retry_at: str | None = None,
|
next_retry_at: str | None = None,
|
||||||
|
sink: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
env = self._ensure_env()
|
|
||||||
rec_key = self._rec_key(record_id)
|
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
with env.begin(write=True) as txn:
|
def change(delivery: DeliveryStatus) -> None:
|
||||||
raw = txn.get(rec_key)
|
delivery.state = state
|
||||||
if raw is None:
|
delivery.updated_at = now
|
||||||
return
|
|
||||||
record_dict: dict[str, Any] = json.loads(raw)
|
|
||||||
record_dict["state"] = state.value
|
|
||||||
record_dict["updated_at"] = now
|
|
||||||
if last_error is not None:
|
if last_error is not None:
|
||||||
record_dict["last_error"] = last_error
|
delivery.last_error = last_error
|
||||||
if next_retry_at is not None:
|
if next_retry_at is not None:
|
||||||
record_dict["next_retry_at"] = next_retry_at
|
delivery.next_retry_at = next_retry_at
|
||||||
txn.put(rec_key, json.dumps(record_dict).encode())
|
if state != RecordState.failed:
|
||||||
|
delivery.next_retry_at = None
|
||||||
|
|
||||||
target_prefix = _PREFIX_MAP.get(state)
|
self._mutate(record_id, sink, change)
|
||||||
if target_prefix and state != RecordState.pending:
|
|
||||||
new_idx = self._idx_key(target_prefix, record_id)
|
|
||||||
txn.put(new_idx, b"")
|
|
||||||
|
|
||||||
if state == RecordState.failed and next_retry_at:
|
def claim(self, sink: str, limit: int = 100, now: str | None = None) -> list[StoredRecord]:
|
||||||
retry_key = _IDX_RETRY + f"{next_retry_at}\x00{record_id}".encode()
|
_check_sink(sink)
|
||||||
txn.put(retry_key, b"")
|
|
||||||
|
|
||||||
def pending(self, limit: int = 100, older_than: str | None = None) -> list[StoredRecord]:
|
|
||||||
_ = older_than
|
|
||||||
return self._list_by_state_prefix(_IDX_PENDING, RecordState.pending, limit)
|
|
||||||
|
|
||||||
def ready_for_retry(self, limit: int = 100, now: str | None = None) -> list[StoredRecord]:
|
|
||||||
env = self._ensure_env()
|
|
||||||
now_str = now or datetime.now(UTC).isoformat()
|
now_str = now or datetime.now(UTC).isoformat()
|
||||||
|
env = self._ensure_env()
|
||||||
|
results: list[StoredRecord] = []
|
||||||
|
with env.begin(write=True) as txn:
|
||||||
|
record_ids = self._due_ids(txn, sink, limit, now_str)
|
||||||
|
for record_id in record_ids:
|
||||||
|
record = self._read(txn, record_id)
|
||||||
|
if record is None or sink not in record.deliveries:
|
||||||
|
continue
|
||||||
|
delivery = record.deliveries[sink]
|
||||||
|
if delivery.state not in (RecordState.pending, RecordState.failed):
|
||||||
|
continue
|
||||||
|
previous = delivery.state
|
||||||
|
delivery.state = RecordState.delivering
|
||||||
|
delivery.updated_at = now_str
|
||||||
|
delivery.next_retry_at = None
|
||||||
|
self._reindex(txn, record, delivery, previous)
|
||||||
|
self._save(txn, record)
|
||||||
|
results.append(record)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _due_ids(
|
||||||
|
self,
|
||||||
|
txn: lmdb.Transaction,
|
||||||
|
sink: str,
|
||||||
|
limit: int,
|
||||||
|
now_str: str,
|
||||||
|
) -> list[str]:
|
||||||
|
found: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
self._collect_pending_ids(txn, sink, limit, seen, found)
|
||||||
|
if len(found) < limit:
|
||||||
|
self._collect_retry_ids(txn, sink, limit, now_str, seen, found)
|
||||||
|
return found
|
||||||
|
|
||||||
|
def _collect_pending_ids(
|
||||||
|
self,
|
||||||
|
txn: lmdb.Transaction,
|
||||||
|
sink: str,
|
||||||
|
limit: int,
|
||||||
|
seen: set[str],
|
||||||
|
found: list[str],
|
||||||
|
) -> None:
|
||||||
|
prefix = _state_prefix(sink, RecordState.pending)
|
||||||
|
for key in self._scan(txn, prefix):
|
||||||
|
record_id = key.split(b"\x00")[-1].decode()
|
||||||
|
if record_id in seen:
|
||||||
|
continue
|
||||||
|
record = self._read(txn, record_id)
|
||||||
|
delivery = record.deliveries.get(sink) if record else None
|
||||||
|
if delivery is None or delivery.state != RecordState.pending:
|
||||||
|
continue
|
||||||
|
seen.add(record_id)
|
||||||
|
found.append(record_id)
|
||||||
|
if len(found) >= limit:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _collect_retry_ids(
|
||||||
|
self,
|
||||||
|
txn: lmdb.Transaction,
|
||||||
|
sink: str,
|
||||||
|
limit: int,
|
||||||
|
now_str: str,
|
||||||
|
seen: set[str],
|
||||||
|
found: list[str],
|
||||||
|
) -> None:
|
||||||
|
prefix = _retry_prefix(sink)
|
||||||
|
for key in self._scan(txn, prefix):
|
||||||
|
parts = key.split(b"\x00")
|
||||||
|
if len(parts) < 4:
|
||||||
|
continue
|
||||||
|
retry_at = parts[2].decode()
|
||||||
|
if retry_at > now_str:
|
||||||
|
break
|
||||||
|
record_id = parts[3].decode()
|
||||||
|
if record_id in seen:
|
||||||
|
continue
|
||||||
|
record = self._read(txn, record_id)
|
||||||
|
delivery = record.deliveries.get(sink) if record else None
|
||||||
|
if delivery is None or delivery.state != RecordState.failed:
|
||||||
|
continue
|
||||||
|
seen.add(record_id)
|
||||||
|
found.append(record_id)
|
||||||
|
if len(found) >= limit:
|
||||||
|
return
|
||||||
|
|
||||||
|
def ack(self, record_id: str, sink: str | None = None) -> None:
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
def change(delivery: DeliveryStatus) -> None:
|
||||||
|
delivery.state = RecordState.delivered
|
||||||
|
delivery.updated_at = now
|
||||||
|
delivery.last_error = None
|
||||||
|
delivery.next_retry_at = None
|
||||||
|
|
||||||
|
self._mutate(record_id, sink, change)
|
||||||
|
|
||||||
|
def nack(
|
||||||
|
self,
|
||||||
|
record_id: str,
|
||||||
|
error: str,
|
||||||
|
retry_delay: float = 10.0,
|
||||||
|
sink: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
next_retry = (now + timedelta(seconds=retry_delay)).isoformat()
|
||||||
|
|
||||||
|
def change(delivery: DeliveryStatus) -> None:
|
||||||
|
delivery.state = RecordState.failed
|
||||||
|
delivery.updated_at = now.isoformat()
|
||||||
|
delivery.last_error = error
|
||||||
|
delivery.retry_count += 1
|
||||||
|
delivery.next_retry_at = next_retry
|
||||||
|
|
||||||
|
self._mutate(record_id, sink, change)
|
||||||
|
|
||||||
|
def dead(self, record_id: str, error: str, sink: str | None = None) -> None:
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
def change(delivery: DeliveryStatus) -> None:
|
||||||
|
delivery.state = RecordState.dead
|
||||||
|
delivery.updated_at = now
|
||||||
|
delivery.last_error = error
|
||||||
|
delivery.next_retry_at = None
|
||||||
|
|
||||||
|
self._mutate(record_id, sink, change)
|
||||||
|
|
||||||
|
def recover_inflight(self) -> int:
|
||||||
|
env = self._ensure_env()
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
recovered = 0
|
||||||
|
with env.begin(write=True) as txn:
|
||||||
|
records = list(self._iter_records(txn))
|
||||||
|
for record in records:
|
||||||
|
changed = False
|
||||||
|
for delivery in record.deliveries.values():
|
||||||
|
if delivery.state != RecordState.delivering:
|
||||||
|
continue
|
||||||
|
previous = delivery.state
|
||||||
|
delivery.state = RecordState.pending
|
||||||
|
delivery.updated_at = now
|
||||||
|
self._reindex(txn, record, delivery, previous)
|
||||||
|
changed = True
|
||||||
|
recovered += 1
|
||||||
|
if changed:
|
||||||
|
self._save(txn, record)
|
||||||
|
return recovered
|
||||||
|
|
||||||
|
def replay(
|
||||||
|
self,
|
||||||
|
record_id: str | None = None,
|
||||||
|
sink: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> int:
|
||||||
|
env = self._ensure_env()
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
transitioned = 0
|
||||||
|
with env.begin(write=True) as txn:
|
||||||
|
if record_id is not None:
|
||||||
|
records = []
|
||||||
|
record = self._read(txn, record_id)
|
||||||
|
if record is not None:
|
||||||
|
records.append(record)
|
||||||
|
else:
|
||||||
|
records = self._records_in_state(txn, RecordState.dead, sink, limit)
|
||||||
|
for record in records:
|
||||||
|
if transitioned >= limit and record_id is None:
|
||||||
|
break
|
||||||
|
was_terminal = record.is_terminal()
|
||||||
|
changed = False
|
||||||
|
for name, delivery in record.deliveries.items():
|
||||||
|
if sink is not None and name != sink:
|
||||||
|
continue
|
||||||
|
if delivery.state != RecordState.dead:
|
||||||
|
continue
|
||||||
|
previous = delivery.state
|
||||||
|
delivery.state = RecordState.pending
|
||||||
|
delivery.updated_at = now
|
||||||
|
delivery.queued_at = now
|
||||||
|
delivery.retry_count = 0
|
||||||
|
delivery.last_error = None
|
||||||
|
delivery.next_retry_at = None
|
||||||
|
self._reindex(txn, record, delivery, previous)
|
||||||
|
changed = True
|
||||||
|
if not changed:
|
||||||
|
continue
|
||||||
|
self._save(txn, record)
|
||||||
|
if was_terminal and record.is_inflight():
|
||||||
|
transitioned += 1
|
||||||
|
return transitioned
|
||||||
|
|
||||||
|
def pending(
|
||||||
|
self,
|
||||||
|
limit: int = 100,
|
||||||
|
older_than: str | None = None,
|
||||||
|
sink: str | None = None,
|
||||||
|
) -> list[StoredRecord]:
|
||||||
|
records = self.list_by_state(RecordState.pending, limit=limit, sink=sink)
|
||||||
|
if older_than is None:
|
||||||
|
return records
|
||||||
|
selected: list[StoredRecord] = []
|
||||||
|
for record in records:
|
||||||
|
for delivery in record.deliveries.values():
|
||||||
|
if sink is not None and delivery.sink != sink:
|
||||||
|
continue
|
||||||
|
if delivery.state == RecordState.pending and delivery.queued_at <= older_than:
|
||||||
|
selected.append(record)
|
||||||
|
break
|
||||||
|
return selected[:limit]
|
||||||
|
|
||||||
|
def ready_for_retry(
|
||||||
|
self,
|
||||||
|
limit: int = 100,
|
||||||
|
now: str | None = None,
|
||||||
|
sink: str | None = None,
|
||||||
|
) -> list[StoredRecord]:
|
||||||
|
now_str = now or datetime.now(UTC).isoformat()
|
||||||
|
env = self._ensure_env()
|
||||||
results: list[StoredRecord] = []
|
results: list[StoredRecord] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
with env.begin() as txn:
|
with env.begin() as txn:
|
||||||
cursor = txn.cursor()
|
prefix = _retry_prefix(sink) if sink else _RETRY_PREFIX
|
||||||
if not cursor.set_range(_IDX_RETRY):
|
for key in self._scan(txn, prefix):
|
||||||
return results
|
parts = key.split(b"\x00")
|
||||||
for key, _ in cursor:
|
if len(parts) < 4:
|
||||||
if not key.startswith(_IDX_RETRY):
|
continue
|
||||||
break
|
retry_at = parts[2].decode()
|
||||||
record_id = self._record_id_from_idx(key, _IDX_RETRY)
|
if retry_at > now_str:
|
||||||
retry_ts = key[len(_IDX_RETRY) :].decode().split("\x00", 1)[0]
|
if sink is not None:
|
||||||
if retry_ts > now_str:
|
break
|
||||||
break
|
continue
|
||||||
|
record_id = parts[3].decode()
|
||||||
|
sink_name = parts[1].decode()
|
||||||
if record_id in seen:
|
if record_id in seen:
|
||||||
continue
|
continue
|
||||||
|
record = self._read(txn, record_id)
|
||||||
|
delivery = record.deliveries.get(sink_name) if record else None
|
||||||
|
if record is None or delivery is None or delivery.state != RecordState.failed:
|
||||||
|
continue
|
||||||
seen.add(record_id)
|
seen.add(record_id)
|
||||||
record = self._read_record(txn, record_id)
|
results.append(record)
|
||||||
if record and record.state == RecordState.failed:
|
if len(results) >= limit:
|
||||||
results.append(record)
|
break
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def ack(self, record_id: str) -> None:
|
def count(self, state: RecordState | None = None, sink: str | None = None) -> int:
|
||||||
self.update_state(record_id, RecordState.delivered)
|
|
||||||
|
|
||||||
def nack(self, record_id: str, error: str, retry_delay: float = 10.0) -> None:
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
next_retry = (datetime.now(UTC) + timedelta(seconds=retry_delay)).isoformat()
|
|
||||||
self.update_state(
|
|
||||||
record_id,
|
|
||||||
RecordState.failed,
|
|
||||||
last_error=error,
|
|
||||||
next_retry_at=next_retry,
|
|
||||||
)
|
|
||||||
|
|
||||||
def dead(self, record_id: str, error: str) -> None:
|
|
||||||
self.update_state(record_id, RecordState.dead, last_error=error)
|
|
||||||
|
|
||||||
def count(self, state: RecordState | None = None) -> int:
|
|
||||||
env = self._ensure_env()
|
env = self._ensure_env()
|
||||||
with env.begin() as txn:
|
with env.begin() as txn:
|
||||||
if state is None:
|
if state is None and sink is None:
|
||||||
cursor = txn.cursor()
|
return self._counter_get(txn, _META_RECORDS)
|
||||||
count = 0
|
total = 0
|
||||||
if cursor.set_range(_REC_PREFIX):
|
prefix = _META_STATE + (sink.encode() + b"\x00" if sink else b"")
|
||||||
for key, _ in cursor:
|
for key, value in self._scan_items(txn, prefix):
|
||||||
if not key.startswith(_REC_PREFIX):
|
if state is not None and not key.endswith(b"\x00" + state.value.encode()):
|
||||||
break
|
continue
|
||||||
count += 1
|
total += int(value)
|
||||||
return count
|
return total
|
||||||
prefix = _PREFIX_MAP.get(state)
|
|
||||||
if prefix is None:
|
def sink_counts(self) -> dict[str, dict[str, int]]:
|
||||||
return 0
|
env = self._ensure_env()
|
||||||
cursor = txn.cursor()
|
counts: dict[str, dict[str, int]] = {}
|
||||||
count = 0
|
with env.begin() as txn:
|
||||||
if cursor.set_range(prefix):
|
for key, value in self._scan_items(txn, _META_STATE):
|
||||||
for key, _ in cursor:
|
rest = key[len(_META_STATE) :]
|
||||||
if not key.startswith(prefix):
|
sink_raw, _, state_raw = rest.partition(b"\x00")
|
||||||
break
|
sink_name = sink_raw.decode()
|
||||||
record_id = self._record_id_from_idx(key, prefix)
|
counts.setdefault(sink_name, {})[state_raw.decode()] = int(value)
|
||||||
record = self._read_record(txn, record_id)
|
return counts
|
||||||
if record and record.state == state:
|
|
||||||
count += 1
|
def count_inflight(self) -> int:
|
||||||
return count
|
env = self._ensure_env()
|
||||||
|
total = 0
|
||||||
|
with env.begin() as txn:
|
||||||
|
for record in self._iter_records(txn):
|
||||||
|
if record.is_inflight():
|
||||||
|
total += 1
|
||||||
|
return total
|
||||||
|
|
||||||
|
def earliest_retry_at(self) -> str | None:
|
||||||
|
earliest: str | None = None
|
||||||
|
env = self._ensure_env()
|
||||||
|
with env.begin() as txn:
|
||||||
|
for key in self._scan(txn, _RETRY_PREFIX):
|
||||||
|
parts = key.split(b"\x00")
|
||||||
|
if len(parts) < 4:
|
||||||
|
continue
|
||||||
|
ts = parts[2].decode()
|
||||||
|
if earliest is None or ts < earliest:
|
||||||
|
earliest = ts
|
||||||
|
return earliest
|
||||||
|
|
||||||
|
def oldest_queued_at(self) -> str | None:
|
||||||
|
interesting = {
|
||||||
|
RecordState.pending.value.encode(),
|
||||||
|
RecordState.delivering.value.encode(),
|
||||||
|
RecordState.failed.value.encode(),
|
||||||
|
}
|
||||||
|
oldest: str | None = None
|
||||||
|
env = self._ensure_env()
|
||||||
|
with env.begin() as txn:
|
||||||
|
for key in self._scan(txn, _IDX_PREFIX):
|
||||||
|
parts = key.split(b"\x00")
|
||||||
|
if len(parts) < 5 or parts[2] not in interesting:
|
||||||
|
continue
|
||||||
|
ts = parts[3].decode()
|
||||||
|
if oldest is None or ts < oldest:
|
||||||
|
oldest = ts
|
||||||
|
return oldest
|
||||||
|
|
||||||
def list_by_state(
|
def list_by_state(
|
||||||
self, state: RecordState, limit: int = 100, offset: int = 0
|
|
||||||
) -> list[StoredRecord]:
|
|
||||||
prefix = _PREFIX_MAP.get(state)
|
|
||||||
if prefix is None:
|
|
||||||
return []
|
|
||||||
return self._list_by_state_prefix(prefix, state, limit, offset)
|
|
||||||
|
|
||||||
def iter_pending(self) -> Iterator[StoredRecord]:
|
|
||||||
seen: set[str] = set()
|
|
||||||
env = self._ensure_env()
|
|
||||||
with env.begin() as txn:
|
|
||||||
cursor = txn.cursor()
|
|
||||||
if cursor.set_range(_IDX_PENDING):
|
|
||||||
for key, _ in cursor:
|
|
||||||
if not key.startswith(_IDX_PENDING):
|
|
||||||
break
|
|
||||||
record_id = self._record_id_from_idx(key, _IDX_PENDING)
|
|
||||||
if record_id in seen:
|
|
||||||
continue
|
|
||||||
seen.add(record_id)
|
|
||||||
record = self._read_record(txn, record_id)
|
|
||||||
if record and record.state == RecordState.pending:
|
|
||||||
yield record
|
|
||||||
|
|
||||||
def delete_old(self, before: str, state: RecordState | None = None) -> int:
|
|
||||||
env = self._ensure_env()
|
|
||||||
removed = 0
|
|
||||||
|
|
||||||
prefixes = [_IDX_DELIVERED, _IDX_DEAD]
|
|
||||||
if state:
|
|
||||||
p = _PREFIX_MAP.get(state)
|
|
||||||
prefixes = [p] if p else []
|
|
||||||
|
|
||||||
with env.begin(write=True) as txn:
|
|
||||||
for prefix in prefixes:
|
|
||||||
cursor = txn.cursor()
|
|
||||||
if not cursor.set_range(prefix):
|
|
||||||
continue
|
|
||||||
seen_del: set[str] = set()
|
|
||||||
for key, _ in cursor:
|
|
||||||
if not key.startswith(prefix):
|
|
||||||
break
|
|
||||||
suffix = key[len(prefix) :].decode()
|
|
||||||
ts_part = suffix.split("\x00", 1)[0]
|
|
||||||
if ts_part > before:
|
|
||||||
break
|
|
||||||
record_id = suffix.split("\x00", 1)[1]
|
|
||||||
if record_id in seen_del:
|
|
||||||
continue
|
|
||||||
seen_del.add(record_id)
|
|
||||||
txn.delete(self._rec_key(record_id))
|
|
||||||
txn.delete(key)
|
|
||||||
removed += 1
|
|
||||||
return removed
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _list_by_state_prefix(
|
|
||||||
self,
|
self,
|
||||||
prefix: bytes,
|
|
||||||
state: RecordState,
|
state: RecordState,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
|
sink: str | None = None,
|
||||||
|
) -> list[StoredRecord]:
|
||||||
|
env = self._ensure_env()
|
||||||
|
with env.begin() as txn:
|
||||||
|
return self._records_in_state(txn, state, sink, limit, offset)
|
||||||
|
|
||||||
|
def iter_pending(self) -> Iterator[StoredRecord]:
|
||||||
|
yield from self.list_by_state(RecordState.pending, limit=1_000_000)
|
||||||
|
|
||||||
|
def delete_old(self, before: str, state: RecordState | None = None) -> int:
|
||||||
|
required = {state} if state is not None else {RecordState.delivered, RecordState.dead}
|
||||||
|
return self._delete_where(
|
||||||
|
lambda record: (
|
||||||
|
bool(record.deliveries)
|
||||||
|
and all(item.state in required for item in record.deliveries.values())
|
||||||
|
and all(item.updated_at <= before for item in record.deliveries.values())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_expired(self, delivered_before: str, dead_before: str) -> int:
|
||||||
|
def eligible(record: StoredRecord) -> bool:
|
||||||
|
if not record.is_terminal():
|
||||||
|
return False
|
||||||
|
for delivery in record.deliveries.values():
|
||||||
|
cutoff = (
|
||||||
|
delivered_before if delivery.state == RecordState.delivered else dead_before
|
||||||
|
)
|
||||||
|
if delivery.updated_at > cutoff:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
return self._delete_where(eligible)
|
||||||
|
|
||||||
|
def index_count(self) -> int:
|
||||||
|
env = self._ensure_env()
|
||||||
|
total = 0
|
||||||
|
with env.begin() as txn:
|
||||||
|
total += sum(1 for _ in self._scan(txn, _IDX_PREFIX))
|
||||||
|
total += sum(1 for _ in self._scan(txn, _RETRY_PREFIX))
|
||||||
|
return total
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
env = self._ensure_env()
|
||||||
|
with env.begin(write=True) as txn:
|
||||||
|
self._wipe_prefix(txn, _META_STATE)
|
||||||
|
txn.delete(_META_RECORDS)
|
||||||
|
records = 0
|
||||||
|
for record in self._iter_records(txn):
|
||||||
|
records += 1
|
||||||
|
for delivery in record.deliveries.values():
|
||||||
|
self._counter_add(txn, _meta_state(delivery.sink, delivery.state), 1)
|
||||||
|
if records:
|
||||||
|
txn.put(_META_RECORDS, str(records).encode())
|
||||||
|
|
||||||
|
def _delete_where(self, predicate: Callable[[StoredRecord], bool]) -> int:
|
||||||
|
env = self._ensure_env()
|
||||||
|
removed = 0
|
||||||
|
with env.begin(write=True) as txn:
|
||||||
|
matched = [record for record in self._iter_records(txn) if predicate(record)]
|
||||||
|
for record in matched:
|
||||||
|
self._delete_record(txn, record)
|
||||||
|
removed += 1
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def _records_in_state(
|
||||||
|
self,
|
||||||
|
txn: lmdb.Transaction,
|
||||||
|
state: RecordState,
|
||||||
|
sink: str | None,
|
||||||
|
limit: int,
|
||||||
|
offset: int = 0,
|
||||||
) -> list[StoredRecord]:
|
) -> list[StoredRecord]:
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
env = self._ensure_env()
|
skipped = 0
|
||||||
results: list[StoredRecord] = []
|
results: list[StoredRecord] = []
|
||||||
with env.begin() as txn:
|
prefixes = [_state_prefix(sink, state)] if sink is not None else [_IDX_PREFIX]
|
||||||
cursor = txn.cursor()
|
for prefix in prefixes:
|
||||||
if not cursor.set_range(prefix):
|
for key in self._scan(txn, prefix):
|
||||||
return results
|
parts = key.split(b"\x00")
|
||||||
skipped = 0
|
if len(parts) < 5:
|
||||||
for key, _ in cursor:
|
continue
|
||||||
if not key.startswith(prefix):
|
if parts[2].decode() != state.value:
|
||||||
break
|
continue
|
||||||
record_id = self._record_id_from_idx(key, prefix)
|
record_id = parts[4].decode()
|
||||||
if record_id in seen:
|
if record_id in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(record_id)
|
record = self._read(txn, record_id)
|
||||||
record = self._read_record(txn, record_id)
|
if record is None:
|
||||||
if record is None or record.state != state:
|
|
||||||
continue
|
continue
|
||||||
|
sink_name = parts[1].decode()
|
||||||
|
delivery = record.deliveries.get(sink_name)
|
||||||
|
if delivery is None or delivery.state != state:
|
||||||
|
continue
|
||||||
|
seen.add(record_id)
|
||||||
if skipped < offset:
|
if skipped < offset:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
results.append(record)
|
results.append(record)
|
||||||
if len(results) >= limit:
|
if len(results) >= limit:
|
||||||
break
|
return results
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
def _iter_records(self, txn: lmdb.Transaction) -> Iterator[StoredRecord]:
|
||||||
|
for _key, value in self._scan_items(txn, _REC_PREFIX):
|
||||||
|
payload: dict[str, Any] = json.loads(value)
|
||||||
|
yield StoredRecord.from_dict(payload)
|
||||||
|
|
||||||
|
def _scan(self, txn: lmdb.Transaction, prefix: bytes) -> Iterator[bytes]:
|
||||||
|
for key, _value in self._scan_items(txn, prefix):
|
||||||
|
yield key
|
||||||
|
|
||||||
|
def _scan_items(self, txn: lmdb.Transaction, prefix: bytes) -> Iterator[tuple[bytes, bytes]]:
|
||||||
|
cursor = txn.cursor()
|
||||||
|
if not cursor.set_range(prefix):
|
||||||
|
return
|
||||||
|
for key, value in cursor:
|
||||||
|
key_bytes = _as_bytes(key)
|
||||||
|
if not key_bytes.startswith(prefix):
|
||||||
|
break
|
||||||
|
yield key_bytes, _as_bytes(value)
|
||||||
|
|
||||||
|
def _wipe_prefix(self, txn: lmdb.Transaction, prefix: bytes) -> None:
|
||||||
|
for key in list(self._scan(txn, prefix)):
|
||||||
|
txn.delete(key)
|
||||||
|
|
||||||
|
def _migrate(self) -> None:
|
||||||
|
env = self._ensure_env()
|
||||||
|
with env.begin(write=True) as txn:
|
||||||
|
if txn.get(_META_SCHEMA) == _SCHEMA_VERSION:
|
||||||
|
return
|
||||||
|
records = list(self._iter_records(txn))
|
||||||
|
self._wipe_prefix(txn, _IDX_PREFIX)
|
||||||
|
self._wipe_prefix(txn, _RETRY_PREFIX)
|
||||||
|
self._wipe_prefix(txn, _META_STATE)
|
||||||
|
for prefix in _LEGACY_INDEX_PREFIXES:
|
||||||
|
self._wipe_prefix(txn, prefix)
|
||||||
|
txn.delete(_META_RECORDS)
|
||||||
|
stored = 0
|
||||||
|
for record in records:
|
||||||
|
for delivery in record.deliveries.values():
|
||||||
|
_check_sink(delivery.sink)
|
||||||
|
delivery.index_key = None
|
||||||
|
delivery.retry_index_key = None
|
||||||
|
self._reindex(txn, record, delivery, None)
|
||||||
|
self._save(txn, record)
|
||||||
|
stored += 1
|
||||||
|
if stored:
|
||||||
|
txn.put(_META_RECORDS, str(stored).encode())
|
||||||
|
txn.put(_META_SCHEMA, _SCHEMA_VERSION)
|
||||||
|
|||||||
+175
-21
@@ -14,8 +14,92 @@ class RecordState(StrEnum):
|
|||||||
dead = "dead"
|
dead = "dead"
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
_TERMINAL = frozenset({RecordState.delivered, RecordState.dead})
|
||||||
|
_INFLIGHT = frozenset({RecordState.pending, RecordState.delivering, RecordState.failed})
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryStatus:
|
||||||
|
"""Delivery progress of one record toward one sink."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
sink: str,
|
||||||
|
state: RecordState = RecordState.pending,
|
||||||
|
retry_count: int = 0,
|
||||||
|
next_retry_at: str | None = None,
|
||||||
|
last_error: str | None = None,
|
||||||
|
updated_at: str | None = None,
|
||||||
|
queued_at: str | None = None,
|
||||||
|
index_key: str | None = None,
|
||||||
|
retry_index_key: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
now = _now()
|
||||||
|
self.sink = sink
|
||||||
|
self.state = state
|
||||||
|
self.retry_count = retry_count
|
||||||
|
self.next_retry_at = next_retry_at
|
||||||
|
self.last_error = last_error
|
||||||
|
self.updated_at = updated_at or now
|
||||||
|
self.queued_at = queued_at or now
|
||||||
|
self.index_key = index_key
|
||||||
|
self.retry_index_key = retry_index_key
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"sink": self.sink,
|
||||||
|
"state": self.state.value,
|
||||||
|
"retry_count": self.retry_count,
|
||||||
|
"next_retry_at": self.next_retry_at,
|
||||||
|
"last_error": self.last_error,
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
"queued_at": self.queued_at,
|
||||||
|
"index_key": self.index_key,
|
||||||
|
"retry_index_key": self.retry_index_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, sink: str, data: dict[str, Any]) -> DeliveryStatus:
|
||||||
|
return cls(
|
||||||
|
sink=sink,
|
||||||
|
state=RecordState(data.get("state", RecordState.pending)),
|
||||||
|
retry_count=int(data.get("retry_count", 0)),
|
||||||
|
next_retry_at=data.get("next_retry_at"),
|
||||||
|
last_error=data.get("last_error"),
|
||||||
|
updated_at=data.get("updated_at"),
|
||||||
|
queued_at=data.get("queued_at") or data.get("updated_at"),
|
||||||
|
index_key=data.get("index_key"),
|
||||||
|
retry_index_key=data.get("retry_index_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_state(deliveries: dict[str, DeliveryStatus]) -> RecordState:
|
||||||
|
"""Single state for display. In-flight work wins over terminal states."""
|
||||||
|
states = {item.state for item in deliveries.values()}
|
||||||
|
if not states:
|
||||||
|
return RecordState.pending
|
||||||
|
if RecordState.delivering in states:
|
||||||
|
return RecordState.delivering
|
||||||
|
if RecordState.pending in states:
|
||||||
|
return RecordState.pending
|
||||||
|
if RecordState.failed in states:
|
||||||
|
return RecordState.failed
|
||||||
|
if states <= {RecordState.delivered}:
|
||||||
|
return RecordState.delivered
|
||||||
|
if RecordState.dead in states:
|
||||||
|
return RecordState.dead
|
||||||
|
return RecordState.delivered
|
||||||
|
|
||||||
|
|
||||||
class StoredRecord:
|
class StoredRecord:
|
||||||
"""A record as stored in the persistence backend."""
|
"""A record as stored in the persistence backend.
|
||||||
|
|
||||||
|
Payload is stored once. Each configured sink has its own DeliveryStatus,
|
||||||
|
so one sink's failure cannot rewind another sink's acknowledgement.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -24,26 +108,81 @@ class StoredRecord:
|
|||||||
record_type: str = "",
|
record_type: str = "",
|
||||||
body: dict[str, Any] | None = None,
|
body: dict[str, Any] | None = None,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
state: RecordState = RecordState.pending,
|
|
||||||
created_at: str | None = None,
|
created_at: str | None = None,
|
||||||
updated_at: str | None = None,
|
destination: str | None = None,
|
||||||
|
deliveries: dict[str, DeliveryStatus] | None = None,
|
||||||
|
sinks: list[str] | None = None,
|
||||||
|
state: RecordState = RecordState.pending,
|
||||||
retry_count: int = 0,
|
retry_count: int = 0,
|
||||||
next_retry_at: str | None = None,
|
next_retry_at: str | None = None,
|
||||||
last_error: str | None = None,
|
last_error: str | None = None,
|
||||||
destination: str | None = None,
|
updated_at: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
now = created_at or _now()
|
||||||
self.record_id = record_id or str(uuid.uuid4())
|
self.record_id = record_id or str(uuid.uuid4())
|
||||||
self.source = source
|
self.source = source
|
||||||
self.record_type = record_type
|
self.record_type = record_type
|
||||||
self.body = body or {}
|
self.body = body or {}
|
||||||
self.metadata = metadata or {}
|
self.metadata = metadata or {}
|
||||||
self.state = state
|
self.created_at = now
|
||||||
self.created_at = created_at or datetime.now(UTC).isoformat()
|
|
||||||
self.updated_at = updated_at or self.created_at
|
|
||||||
self.retry_count = retry_count
|
|
||||||
self.next_retry_at = next_retry_at
|
|
||||||
self.last_error = last_error
|
|
||||||
self.destination = destination
|
self.destination = destination
|
||||||
|
if deliveries is not None:
|
||||||
|
self.deliveries = deliveries
|
||||||
|
elif sinks:
|
||||||
|
self.deliveries = {
|
||||||
|
name: DeliveryStatus(
|
||||||
|
sink=name,
|
||||||
|
state=state,
|
||||||
|
retry_count=retry_count,
|
||||||
|
next_retry_at=next_retry_at,
|
||||||
|
last_error=last_error,
|
||||||
|
updated_at=updated_at or now,
|
||||||
|
queued_at=now,
|
||||||
|
)
|
||||||
|
for name in sinks
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
self.deliveries = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> RecordState:
|
||||||
|
return aggregate_state(self.deliveries)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def updated_at(self) -> str:
|
||||||
|
times = [item.updated_at for item in self.deliveries.values()]
|
||||||
|
return max(times) if times else self.created_at
|
||||||
|
|
||||||
|
@property
|
||||||
|
def retry_count(self) -> int:
|
||||||
|
if not self.deliveries:
|
||||||
|
return 0
|
||||||
|
return max(item.retry_count for item in self.deliveries.values())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def next_retry_at(self) -> str | None:
|
||||||
|
times = [
|
||||||
|
item.next_retry_at
|
||||||
|
for item in self.deliveries.values()
|
||||||
|
if item.next_retry_at is not None
|
||||||
|
]
|
||||||
|
return min(times) if times else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> str | None:
|
||||||
|
ordered = sorted(self.deliveries.values(), key=lambda item: item.updated_at, reverse=True)
|
||||||
|
for item in ordered:
|
||||||
|
if item.last_error:
|
||||||
|
return item.last_error
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_terminal(self) -> bool:
|
||||||
|
if not self.deliveries:
|
||||||
|
return False
|
||||||
|
return all(item.state in _TERMINAL for item in self.deliveries.values())
|
||||||
|
|
||||||
|
def is_inflight(self) -> bool:
|
||||||
|
return any(item.state in _INFLIGHT for item in self.deliveries.values())
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -52,28 +191,43 @@ class StoredRecord:
|
|||||||
"record_type": self.record_type,
|
"record_type": self.record_type,
|
||||||
"body": self.body,
|
"body": self.body,
|
||||||
"metadata": self.metadata,
|
"metadata": self.metadata,
|
||||||
"state": self.state.value,
|
|
||||||
"created_at": self.created_at,
|
|
||||||
"updated_at": self.updated_at,
|
|
||||||
"retry_count": self.retry_count,
|
|
||||||
"next_retry_at": self.next_retry_at,
|
|
||||||
"last_error": self.last_error,
|
|
||||||
"destination": self.destination,
|
"destination": self.destination,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"deliveries": {name: item.to_dict() for name, item in self.deliveries.items()},
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict[str, Any]) -> StoredRecord:
|
def from_dict(cls, data: dict[str, Any]) -> StoredRecord:
|
||||||
|
raw_deliveries = data.get("deliveries")
|
||||||
|
deliveries: dict[str, DeliveryStatus] | None
|
||||||
|
if isinstance(raw_deliveries, dict):
|
||||||
|
deliveries = {
|
||||||
|
name: DeliveryStatus.from_dict(name, payload)
|
||||||
|
for name, payload in raw_deliveries.items()
|
||||||
|
if isinstance(payload, dict)
|
||||||
|
}
|
||||||
|
elif "state" in data:
|
||||||
|
sink_name = data.get("destination") or "default"
|
||||||
|
deliveries = {
|
||||||
|
sink_name: DeliveryStatus(
|
||||||
|
sink=sink_name,
|
||||||
|
state=RecordState(data.get("state", RecordState.pending)),
|
||||||
|
retry_count=int(data.get("retry_count", 0)),
|
||||||
|
next_retry_at=data.get("next_retry_at"),
|
||||||
|
last_error=data.get("last_error"),
|
||||||
|
updated_at=data.get("updated_at") or data.get("created_at"),
|
||||||
|
queued_at=data.get("created_at"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
deliveries = {}
|
||||||
return cls(
|
return cls(
|
||||||
record_id=data.get("record_id"),
|
record_id=data.get("record_id"),
|
||||||
source=data.get("source", ""),
|
source=data.get("source", ""),
|
||||||
record_type=data.get("record_type", ""),
|
record_type=data.get("record_type", ""),
|
||||||
body=data.get("body", {}),
|
body=data.get("body", {}),
|
||||||
metadata=data.get("metadata", {}),
|
metadata=data.get("metadata", {}),
|
||||||
state=RecordState(data.get("state", "pending")),
|
|
||||||
created_at=data.get("created_at"),
|
created_at=data.get("created_at"),
|
||||||
updated_at=data.get("updated_at"),
|
|
||||||
retry_count=data.get("retry_count", 0),
|
|
||||||
next_retry_at=data.get("next_retry_at"),
|
|
||||||
last_error=data.get("last_error"),
|
|
||||||
destination=data.get("destination"),
|
destination=data.get("destination"),
|
||||||
|
deliveries=deliveries,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ class Source(ABC):
|
|||||||
def __init__(self, name: str, config: dict[str, Any] | None = None) -> None:
|
def __init__(self, name: str, config: dict[str, Any] | None = None) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
|
self._on_record: Callable[[Record], Awaitable[None]] | None = None
|
||||||
|
|
||||||
|
def set_callback(self, callback: Callable[[Record], Awaitable[None]]) -> None:
|
||||||
|
"""Register the pipeline ingest coroutine. Called before start."""
|
||||||
|
self._on_record = callback
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from daena.pipeline.base import Processor
|
||||||
|
from daena.pipeline.record import Record
|
||||||
|
|
||||||
|
|
||||||
|
class EnricherProcessor(Processor):
|
||||||
|
"""Adds hostname and static fields to each record.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
hostname: write the local hostname (default true)
|
||||||
|
static: mapping copied onto every record
|
||||||
|
target: `metadata` (default) or `body`
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str, config: dict[str, Any] | None = None) -> None:
|
||||||
|
super().__init__(name, config)
|
||||||
|
self._add_hostname = bool(self.config.get("hostname", True))
|
||||||
|
static = self.config.get("static", {})
|
||||||
|
self._static: dict[str, Any] = {}
|
||||||
|
if isinstance(static, dict):
|
||||||
|
self._static = {str(key): value for key, value in static.items()}
|
||||||
|
target = str(self.config.get("target", "metadata"))
|
||||||
|
if target not in ("metadata", "body"):
|
||||||
|
target = "metadata"
|
||||||
|
self._target = target
|
||||||
|
self._hostname = socket.gethostname()
|
||||||
|
|
||||||
|
async def process(self, record: Record) -> Record | None:
|
||||||
|
destination = record.metadata if self._target == "metadata" else record.body
|
||||||
|
if self._add_hostname:
|
||||||
|
destination["hostname"] = self._hostname
|
||||||
|
destination.update(self._static)
|
||||||
|
return record
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from daena.pipeline.base import Processor
|
||||||
|
from daena.pipeline.record import Record
|
||||||
|
|
||||||
|
|
||||||
|
class FilterProcessor(Processor):
|
||||||
|
"""Drops records that match `drop`, or that fail to match `keep`.
|
||||||
|
|
||||||
|
Keys are `source`, `record_type`, `body.<field>`, or `metadata.<field>`.
|
||||||
|
Dotted paths walk nested mappings. Comparison is equality, with string
|
||||||
|
fallback so YAML numbers match string fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str, config: dict[str, Any] | None = None) -> None:
|
||||||
|
super().__init__(name, config)
|
||||||
|
self._drop = _rules(self.config.get("drop"))
|
||||||
|
self._keep = _rules(self.config.get("keep"))
|
||||||
|
|
||||||
|
async def process(self, record: Record) -> Record | None:
|
||||||
|
if self._drop and _matches_all(record, self._drop):
|
||||||
|
return None
|
||||||
|
if self._keep and not _matches_all(record, self._keep):
|
||||||
|
return None
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def _rules(raw: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return {}
|
||||||
|
return {str(key): value for key, value in raw.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_all(record: Record, rules: dict[str, Any]) -> bool:
|
||||||
|
return all(_equals(_lookup(record, path), expected) for path, expected in rules.items())
|
||||||
|
|
||||||
|
|
||||||
|
def _equals(actual: Any, expected: Any) -> bool:
|
||||||
|
if actual == expected:
|
||||||
|
return True
|
||||||
|
if actual is None:
|
||||||
|
return False
|
||||||
|
return str(actual) == str(expected)
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup(record: Record, path: str) -> Any:
|
||||||
|
if path == "source":
|
||||||
|
return record.source
|
||||||
|
if path == "record_type":
|
||||||
|
return record.record_type
|
||||||
|
if path.startswith("body."):
|
||||||
|
return _dig(record.body, path.removeprefix("body."))
|
||||||
|
if path.startswith("metadata."):
|
||||||
|
return _dig(record.metadata, path.removeprefix("metadata."))
|
||||||
|
return _dig(record.body, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _dig(value: Any, path: str) -> Any:
|
||||||
|
current = value
|
||||||
|
for part in path.split("."):
|
||||||
|
if not isinstance(current, dict) or part not in current:
|
||||||
|
return None
|
||||||
|
current = current[part]
|
||||||
|
return current
|
||||||
@@ -1,14 +1,35 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from daena.pipeline.base import DeliveryResult, Sink
|
from daena.pipeline.base import DeliveryResult, Sink
|
||||||
from daena.pipeline.record import Record
|
from daena.pipeline.record import Record
|
||||||
|
|
||||||
|
|
||||||
class DummySink(Sink):
|
class DummySink(Sink):
|
||||||
"""Logs delivered records without sending anywhere.
|
"""Accepts records without sending them anywhere.
|
||||||
|
|
||||||
Useful for testing and development.
|
`fail_times` injects that many failures before success.
|
||||||
|
`permanent` marks those failures as non-retryable.
|
||||||
|
`delay` sleeps before each attempt so tests can observe backpressure.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str, config: dict[str, Any] | None = None) -> None:
|
||||||
|
super().__init__(name, config)
|
||||||
|
self._fail_remaining = int(self.config.get("fail_times", 0))
|
||||||
|
self._permanent = bool(self.config.get("permanent", False))
|
||||||
|
self._delay = float(self.config.get("delay", 0))
|
||||||
|
|
||||||
async def deliver(self, record: Record) -> DeliveryResult:
|
async def deliver(self, record: Record) -> DeliveryResult:
|
||||||
|
if self._delay:
|
||||||
|
await asyncio.sleep(self._delay)
|
||||||
|
if self._fail_remaining > 0:
|
||||||
|
self._fail_remaining -= 1
|
||||||
|
return DeliveryResult(
|
||||||
|
success=False,
|
||||||
|
record_id=record.record_id,
|
||||||
|
error="dummy failure",
|
||||||
|
permanent=self._permanent,
|
||||||
|
)
|
||||||
return DeliveryResult(success=True, record_id=record.record_id)
|
return DeliveryResult(success=True, record_id=record.record_id)
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from daena.pipeline.base import DeliveryResult, Sink
|
||||||
|
from daena.pipeline.record import Record
|
||||||
|
|
||||||
|
|
||||||
|
class LokiSink(Sink):
|
||||||
|
"""Pushes one record to a Loki `/loki/api/v1/push` endpoint.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
url: push URL
|
||||||
|
labels: static stream labels
|
||||||
|
tenant: optional `X-Scope-OrgID` value
|
||||||
|
timeout: request timeout in seconds
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str, config: dict[str, Any] | None = None) -> None:
|
||||||
|
super().__init__(name, config)
|
||||||
|
self._url = str(self.config.get("url", "http://127.0.0.1:3100/loki/api/v1/push"))
|
||||||
|
labels = self.config.get("labels", {})
|
||||||
|
self._labels = (
|
||||||
|
{str(key): _label_value(value) for key, value in labels.items()}
|
||||||
|
if isinstance(labels, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
tenant = self.config.get("tenant")
|
||||||
|
self._tenant = str(tenant) if tenant else None
|
||||||
|
self._timeout = float(self.config.get("timeout", 30.0))
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if self._tenant:
|
||||||
|
headers["X-Scope-OrgID"] = self._tenant
|
||||||
|
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout), headers=headers)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._client is not None:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
async def deliver(self, record: Record) -> DeliveryResult:
|
||||||
|
if self._client is None:
|
||||||
|
return DeliveryResult(
|
||||||
|
success=False,
|
||||||
|
record_id=record.record_id,
|
||||||
|
error="Loki sink not initialized",
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"streams": [
|
||||||
|
{
|
||||||
|
"stream": {**self._labels, "source": _label_value(record.source)},
|
||||||
|
"values": [[_timestamp_ns(record.timestamp), _line(record)]],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = await self._client.post(self._url, json=payload)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
return DeliveryResult(
|
||||||
|
success=False,
|
||||||
|
record_id=record.record_id,
|
||||||
|
error=f"Timeout: {exc}",
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
return DeliveryResult(success=False, record_id=record.record_id, error=str(exc))
|
||||||
|
if response.is_success:
|
||||||
|
return DeliveryResult(success=True, record_id=record.record_id)
|
||||||
|
permanent = 400 <= response.status_code < 500 and response.status_code != 429
|
||||||
|
return DeliveryResult(
|
||||||
|
success=False,
|
||||||
|
record_id=record.record_id,
|
||||||
|
error=f"HTTP {response.status_code}: {response.text[:200]}",
|
||||||
|
permanent=permanent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _line(record: Record) -> str:
|
||||||
|
message = record.body.get("message")
|
||||||
|
if isinstance(message, str) and message:
|
||||||
|
return message
|
||||||
|
return json.dumps(record.body, default=str, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp_ns(timestamp: str) -> str:
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(timestamp)
|
||||||
|
except ValueError:
|
||||||
|
parsed = datetime.now(UTC)
|
||||||
|
return str(int(parsed.timestamp() * 1_000_000_000))
|
||||||
|
|
||||||
|
|
||||||
|
def _label_value(value: Any) -> str:
|
||||||
|
text = str(value).replace("\n", " ").replace('"', "'").strip()
|
||||||
|
return text or "unknown"
|
||||||
@@ -20,12 +20,9 @@ class DummySource(Source):
|
|||||||
self._message: str = (
|
self._message: str = (
|
||||||
config.get("message", "daena test event") if config else "daena test event"
|
config.get("message", "daena test event") if config else "daena test event"
|
||||||
)
|
)
|
||||||
|
self._max_count = int(self.config.get("count", 0))
|
||||||
self._task: asyncio.Task[None] | None = None
|
self._task: asyncio.Task[None] | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._on_record: callable | None = None
|
|
||||||
|
|
||||||
def set_callback(self, cb: callable) -> None:
|
|
||||||
self._on_record = cb
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
self._running = True
|
self._running = True
|
||||||
@@ -53,4 +50,7 @@ class DummySource(Source):
|
|||||||
)
|
)
|
||||||
if self._on_record:
|
if self._on_record:
|
||||||
await self._on_record(record)
|
await self._on_record(record)
|
||||||
|
if self._max_count and counter >= self._max_count:
|
||||||
|
self._running = False
|
||||||
|
break
|
||||||
await asyncio.sleep(self._interval)
|
await asyncio.sleep(self._interval)
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from daena.exceptions import SourceError
|
||||||
|
from daena.logutil import get_logger
|
||||||
|
from daena.pipeline.base import Source
|
||||||
|
from daena.pipeline.record import Record
|
||||||
|
|
||||||
|
log = get_logger("daena.journald")
|
||||||
|
|
||||||
|
JournalHandler = Callable[[dict[str, Any]], Awaitable[None]]
|
||||||
|
|
||||||
|
_JOURNAL_FIELDS = (
|
||||||
|
"MESSAGE",
|
||||||
|
"SYSLOG_IDENTIFIER",
|
||||||
|
"PRIORITY",
|
||||||
|
"_PID",
|
||||||
|
"_UID",
|
||||||
|
"_GID",
|
||||||
|
"_SYSTEMD_UNIT",
|
||||||
|
"_HOSTNAME",
|
||||||
|
"_COMM",
|
||||||
|
"SYSLOG_FACILITY",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JournalReader(Protocol):
|
||||||
|
async def run(self, on_entry: JournalHandler) -> None:
|
||||||
|
"""Emit journal entries until stopped."""
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop emitting."""
|
||||||
|
|
||||||
|
|
||||||
|
def build_journalctl_command(
|
||||||
|
*,
|
||||||
|
cursor: str | None,
|
||||||
|
units: list[str],
|
||||||
|
matches: list[str],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build a follow command. Without a cursor, only entries after start are read."""
|
||||||
|
command = ["journalctl", "--follow", "--output=json", "--no-pager"]
|
||||||
|
if cursor:
|
||||||
|
command.append(f"--after-cursor={cursor}")
|
||||||
|
else:
|
||||||
|
command.append("--lines=0")
|
||||||
|
for unit in units:
|
||||||
|
command.extend(["--unit", unit])
|
||||||
|
command.extend(matches)
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def _log_reader_error(task: asyncio.Task[None]) -> None:
|
||||||
|
if task.cancelled():
|
||||||
|
return
|
||||||
|
error = task.exception()
|
||||||
|
if error is not None:
|
||||||
|
log.error("journald_reader_failed", error=str(error))
|
||||||
|
|
||||||
|
|
||||||
|
def entry_to_record(source: str, entry: dict[str, Any]) -> Record:
|
||||||
|
message = entry.get("MESSAGE", "")
|
||||||
|
if not isinstance(message, str):
|
||||||
|
message = str(message)
|
||||||
|
body: dict[str, Any] = {"message": message}
|
||||||
|
for field in _JOURNAL_FIELDS:
|
||||||
|
if field == "MESSAGE" or field not in entry:
|
||||||
|
continue
|
||||||
|
value = entry[field]
|
||||||
|
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||||
|
body[field.lower()] = value
|
||||||
|
timestamp = _journal_timestamp(entry.get("__REALTIME_TIMESTAMP"))
|
||||||
|
metadata = {
|
||||||
|
"cursor": entry.get("__CURSOR", ""),
|
||||||
|
"unit": entry.get("_SYSTEMD_UNIT", ""),
|
||||||
|
}
|
||||||
|
return Record(
|
||||||
|
source=source,
|
||||||
|
record_type="journald",
|
||||||
|
body=body,
|
||||||
|
metadata=metadata,
|
||||||
|
timestamp=timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _journal_timestamp(raw: Any) -> str:
|
||||||
|
if isinstance(raw, str) and raw.isdigit():
|
||||||
|
seconds = int(raw) / 1_000_000
|
||||||
|
return datetime.fromtimestamp(seconds, UTC).isoformat()
|
||||||
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class JournalctlReader:
|
||||||
|
"""Follows `journalctl --output=json` and parses one object per line."""
|
||||||
|
|
||||||
|
def __init__(self, command: list[str]) -> None:
|
||||||
|
self._command = command
|
||||||
|
self._process: asyncio.subprocess.Process | None = None
|
||||||
|
self._stopped = False
|
||||||
|
|
||||||
|
async def run(self, on_entry: JournalHandler) -> None:
|
||||||
|
try:
|
||||||
|
self._process = await asyncio.create_subprocess_exec(
|
||||||
|
*self._command,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise SourceError("journalctl was not found on PATH") from exc
|
||||||
|
stdout = self._process.stdout
|
||||||
|
if stdout is None:
|
||||||
|
raise SourceError("journalctl produced no stdout pipe")
|
||||||
|
while not self._stopped:
|
||||||
|
line = await stdout.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
text = line.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
await on_entry(payload)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._stopped = True
|
||||||
|
process = self._process
|
||||||
|
if process is None or process.returncode is not None:
|
||||||
|
return
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=2)
|
||||||
|
except TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
class JournaldSource(Source):
|
||||||
|
"""Tails the systemd journal.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
units: systemd unit names passed to `--unit`
|
||||||
|
matches: extra journalctl match arguments
|
||||||
|
cursor_path: file storing the last `__CURSOR` so restarts do not replay
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
config: dict[str, Any] | None = None,
|
||||||
|
reader: JournalReader | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(name, config)
|
||||||
|
self._units = [str(unit) for unit in self.config.get("units", [])]
|
||||||
|
self._matches = [str(match) for match in self.config.get("matches", [])]
|
||||||
|
cursor_path = self.config.get("cursor_path")
|
||||||
|
self._cursor_path = Path(cursor_path) if cursor_path else None
|
||||||
|
self._reader = reader
|
||||||
|
self._task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._reader is None:
|
||||||
|
cursor = self._read_cursor()
|
||||||
|
command = build_journalctl_command(
|
||||||
|
cursor=cursor,
|
||||||
|
units=self._units,
|
||||||
|
matches=self._matches,
|
||||||
|
)
|
||||||
|
self._reader = JournalctlReader(command)
|
||||||
|
self._task = asyncio.create_task(self._reader.run(self._on_entry))
|
||||||
|
self._task.add_done_callback(_log_reader_error)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._reader is not None:
|
||||||
|
await self._reader.stop()
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await self._task
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
async def _on_entry(self, entry: dict[str, Any]) -> None:
|
||||||
|
record = entry_to_record(self.name, entry)
|
||||||
|
if self._on_record is not None:
|
||||||
|
await self._on_record(record)
|
||||||
|
cursor = entry.get("__CURSOR")
|
||||||
|
if isinstance(cursor, str) and self._cursor_path is not None:
|
||||||
|
await asyncio.to_thread(self._write_cursor, cursor)
|
||||||
|
|
||||||
|
def _read_cursor(self) -> str | None:
|
||||||
|
if self._cursor_path is None or not self._cursor_path.exists():
|
||||||
|
return None
|
||||||
|
text = self._cursor_path.read_text(encoding="utf-8").strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
def _write_cursor(self, cursor: str) -> None:
|
||||||
|
if self._cursor_path is None:
|
||||||
|
return
|
||||||
|
self._cursor_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = self._cursor_path.with_suffix(self._cursor_path.suffix + ".tmp")
|
||||||
|
temporary.write_text(cursor + "\n", encoding="utf-8")
|
||||||
|
temporary.replace(self._cursor_path)
|
||||||
@@ -3,7 +3,15 @@ from __future__ import annotations
|
|||||||
from importlib.metadata import entry_points
|
from importlib.metadata import entry_points
|
||||||
|
|
||||||
from daena.exceptions import PluginError
|
from daena.exceptions import PluginError
|
||||||
from daena.plugins.registry import get_registry
|
from daena.pipeline.processors.enricher import EnricherProcessor
|
||||||
|
from daena.pipeline.processors.filter import FilterProcessor
|
||||||
|
from daena.pipeline.processors.passthrough import PassthroughProcessor
|
||||||
|
from daena.pipeline.sinks.dummy import DummySink
|
||||||
|
from daena.pipeline.sinks.http import HTTPSink
|
||||||
|
from daena.pipeline.sinks.loki import LokiSink
|
||||||
|
from daena.pipeline.sources.dummy import DummySource
|
||||||
|
from daena.pipeline.sources.journald import JournaldSource
|
||||||
|
from daena.plugins.registry import PluginRegistry, get_registry
|
||||||
|
|
||||||
SOURCE_EP = "daena.sources"
|
SOURCE_EP = "daena.sources"
|
||||||
PROCESSOR_EP = "daena.processors"
|
PROCESSOR_EP = "daena.processors"
|
||||||
@@ -13,6 +21,7 @@ SINK_EP = "daena.sinks"
|
|||||||
def discover_plugins() -> None:
|
def discover_plugins() -> None:
|
||||||
"""Discover plugins via package entry points and register them."""
|
"""Discover plugins via package entry points and register them."""
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
|
registry.clear()
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
|
||||||
for ep in entry_points(group=SOURCE_EP):
|
for ep in entry_points(group=SOURCE_EP):
|
||||||
@@ -38,3 +47,25 @@ def discover_plugins() -> None:
|
|||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
raise PluginError("Plugin loading encountered errors:\n " + "\n ".join(errors))
|
raise PluginError("Plugin loading encountered errors:\n " + "\n ".join(errors))
|
||||||
|
_register_builtins(registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _register_builtins(registry: PluginRegistry) -> None:
|
||||||
|
"""Register in-tree plugins when an entry point did not already provide them."""
|
||||||
|
builtins: tuple[tuple[str, str, type], ...] = (
|
||||||
|
("source", "dummy", DummySource),
|
||||||
|
("source", "journald", JournaldSource),
|
||||||
|
("processor", "passthrough", PassthroughProcessor),
|
||||||
|
("processor", "enricher", EnricherProcessor),
|
||||||
|
("processor", "filter", FilterProcessor),
|
||||||
|
("sink", "dummy", DummySink),
|
||||||
|
("sink", "http", HTTPSink),
|
||||||
|
("sink", "loki", LokiSink),
|
||||||
|
)
|
||||||
|
for kind, name, cls in builtins:
|
||||||
|
if kind == "source" and registry.get_source(name) is None:
|
||||||
|
registry.register_source(name, cls)
|
||||||
|
elif kind == "processor" and registry.get_processor(name) is None:
|
||||||
|
registry.register_processor(name, cls)
|
||||||
|
elif kind == "sink" and registry.get_sink(name) is None:
|
||||||
|
registry.register_sink(name, cls)
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ class PluginRegistry:
|
|||||||
"sinks": self.list_sinks(),
|
"sinks": self.list_sinks(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._sources.clear()
|
||||||
|
self._processors.clear()
|
||||||
|
self._sinks.clear()
|
||||||
|
|
||||||
|
|
||||||
_registry = PluginRegistry()
|
_registry = PluginRegistry()
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ def sample_record() -> StoredRecord:
|
|||||||
record_type="test",
|
record_type="test",
|
||||||
body={"message": "hello"},
|
body={"message": "hello"},
|
||||||
metadata={"env": "test"},
|
metadata={"env": "test"},
|
||||||
|
sinks=["out"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -60,7 +60,7 @@ async def smoke_test() -> int:
|
|||||||
try:
|
try:
|
||||||
resp = await stub.GetStatus(pb2.Empty(), timeout=5)
|
resp = await stub.GetStatus(pb2.Empty(), timeout=5)
|
||||||
assert resp.service == "daena"
|
assert resp.service == "daena"
|
||||||
assert resp.version == "0.1.0"
|
assert resp.version == "0.2.0"
|
||||||
assert resp.running is True
|
assert resp.running is True
|
||||||
print(f"[PASS] GetStatus: service={resp.service}, running={resp.running}")
|
print(f"[PASS] GetStatus: service={resp.service}, running={resp.running}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from daena.cli.main import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_command() -> None:
|
||||||
|
result = runner.invoke(app, ["version"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "daena v" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_reports_connection_failure() -> None:
|
||||||
|
result = runner.invoke(app, ["--target", "127.0.0.1:1", "status"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Cannot connect" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_records_rejects_unknown_kind() -> None:
|
||||||
|
result = runner.invoke(app, ["records", "other"])
|
||||||
|
assert result.exit_code == 2
|
||||||
|
assert "pending or dead" in result.output
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from daena.config import (
|
||||||
|
DaenaConfig,
|
||||||
|
HealthConfig,
|
||||||
|
PipelineConfig,
|
||||||
|
ProcessorConfig,
|
||||||
|
SinkConfig,
|
||||||
|
SinkRetryConfig,
|
||||||
|
SourceConfig,
|
||||||
|
load_config,
|
||||||
|
)
|
||||||
|
from daena.core.runtime import DaenaRuntime
|
||||||
|
from daena.persistence.models import RecordState
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime(tmp_path: Path, **overrides: object) -> DaenaRuntime:
|
||||||
|
config = DaenaConfig()
|
||||||
|
config.persistence.lmdb.path = str(tmp_path / "lmdb")
|
||||||
|
config.pipeline = PipelineConfig(batch_size=10, flush_interval=0.05, max_queue_size=10_000)
|
||||||
|
config.retention.interval_seconds = 3600
|
||||||
|
for key, value in overrides.items():
|
||||||
|
setattr(config, key, value)
|
||||||
|
return DaenaRuntime(config)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_does_not_block_until_success(tmp_path: Path) -> None:
|
||||||
|
runtime = _runtime(tmp_path)
|
||||||
|
runtime.config.sources = [
|
||||||
|
SourceConfig(
|
||||||
|
name="once",
|
||||||
|
type="dummy",
|
||||||
|
options={"interval": 0.05, "count": 1, "message": "retry"},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
runtime.config.sinks = [
|
||||||
|
SinkConfig(
|
||||||
|
name="out",
|
||||||
|
type="dummy",
|
||||||
|
retry=SinkRetryConfig(max_attempts=3, min_delay=0, max_delay=0, multiplier=1),
|
||||||
|
options={"fail_times": 1},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
await runtime.start()
|
||||||
|
delivered = 0
|
||||||
|
for _ in range(40):
|
||||||
|
assert runtime.backend is not None
|
||||||
|
delivered = runtime.backend.count(RecordState.delivered)
|
||||||
|
if delivered == 1:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
dead = runtime.backend.count(RecordState.dead) if runtime.backend else -1
|
||||||
|
await runtime.stop()
|
||||||
|
assert delivered == 1
|
||||||
|
assert dead == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sinks_keep_independent_delivery_state(tmp_path: Path) -> None:
|
||||||
|
runtime = _runtime(tmp_path)
|
||||||
|
runtime.config.sources = [
|
||||||
|
SourceConfig(name="once", type="dummy", options={"interval": 0.05, "count": 1})
|
||||||
|
]
|
||||||
|
runtime.config.sinks = [
|
||||||
|
SinkConfig(name="good", type="dummy"),
|
||||||
|
SinkConfig(name="bad", type="dummy", options={"fail_times": 5, "permanent": True}),
|
||||||
|
]
|
||||||
|
await runtime.start()
|
||||||
|
stored = None
|
||||||
|
for _ in range(40):
|
||||||
|
assert runtime.backend is not None
|
||||||
|
records = runtime.backend.list_by_state(RecordState.dead, limit=10)
|
||||||
|
if records and records[0].deliveries["good"].state == RecordState.delivered:
|
||||||
|
stored = records[0]
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
await runtime.stop()
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.deliveries["good"].state == RecordState.delivered
|
||||||
|
assert stored.deliveries["bad"].state == RecordState.dead
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backpressure_stops_ingest(tmp_path: Path) -> None:
|
||||||
|
runtime = _runtime(tmp_path)
|
||||||
|
runtime.config.pipeline = PipelineConfig(batch_size=10, flush_interval=0.05, max_queue_size=2)
|
||||||
|
runtime.config.sources = [
|
||||||
|
SourceConfig(name="fast", type="dummy", options={"interval": 0.01, "message": "bp"})
|
||||||
|
]
|
||||||
|
runtime.config.sinks = [SinkConfig(name="slow", type="dummy", options={"delay": 30})]
|
||||||
|
await runtime.start()
|
||||||
|
await asyncio.sleep(0.4)
|
||||||
|
assert runtime.backend is not None
|
||||||
|
stored = runtime.backend.count()
|
||||||
|
await runtime.stop()
|
||||||
|
assert stored <= 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disabled_source_emits_nothing(tmp_path: Path) -> None:
|
||||||
|
runtime = _runtime(tmp_path)
|
||||||
|
runtime.config.sources = [
|
||||||
|
SourceConfig(name="off", type="dummy", enabled=False, options={"interval": 0.05})
|
||||||
|
]
|
||||||
|
runtime.config.sinks = [SinkConfig(name="out", type="dummy")]
|
||||||
|
await runtime.start()
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
assert runtime.backend is not None
|
||||||
|
total = runtime.backend.count()
|
||||||
|
await runtime.stop()
|
||||||
|
assert total == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reload_rereads_file(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "daena.yaml"
|
||||||
|
data = {
|
||||||
|
"persistence": {"lmdb": {"path": str(tmp_path / "lmdb")}},
|
||||||
|
"pipeline": {"batch_size": 10, "flush_interval": 0.05, "max_queue_size": 100},
|
||||||
|
"retention": {"interval_seconds": 3600},
|
||||||
|
"sources": [{"name": "live", "type": "dummy", "enabled": True, "options": {"interval": 1}}],
|
||||||
|
"sinks": [{"name": "out", "type": "dummy", "enabled": True}],
|
||||||
|
}
|
||||||
|
path.write_text(yaml.dump(data), encoding="utf-8")
|
||||||
|
runtime = DaenaRuntime(load_config(path), config_path=path)
|
||||||
|
await runtime.start()
|
||||||
|
data["sources"][0]["enabled"] = False
|
||||||
|
path.write_text(yaml.dump(data), encoding="utf-8")
|
||||||
|
await runtime.reload()
|
||||||
|
assert runtime.config.sources[0].enabled is False
|
||||||
|
assert runtime.running is True
|
||||||
|
await runtime.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_degrades_when_delivery_stalls(tmp_path: Path) -> None:
|
||||||
|
runtime = _runtime(tmp_path)
|
||||||
|
runtime.config.health = HealthConfig(max_pending_age_seconds=0)
|
||||||
|
runtime.config.sources = [
|
||||||
|
SourceConfig(name="fast", type="dummy", options={"interval": 0.05, "count": 1})
|
||||||
|
]
|
||||||
|
runtime.config.sinks = [SinkConfig(name="slow", type="dummy", options={"delay": 30})]
|
||||||
|
await runtime.start()
|
||||||
|
await asyncio.sleep(1.2)
|
||||||
|
report = runtime.health()
|
||||||
|
await runtime.stop()
|
||||||
|
assert report.status == "degraded"
|
||||||
|
assert report.healthy is False
|
||||||
|
assert report.oldest_pending_age_seconds >= 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_filter_drops_record_before_persist(tmp_path: Path) -> None:
|
||||||
|
runtime = _runtime(tmp_path)
|
||||||
|
runtime.config.sources = [
|
||||||
|
SourceConfig(
|
||||||
|
name="once",
|
||||||
|
type="dummy",
|
||||||
|
options={"interval": 0.05, "count": 1, "message": "drop-me"},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
runtime.config.processors = [
|
||||||
|
ProcessorConfig(
|
||||||
|
name="drop",
|
||||||
|
type="filter",
|
||||||
|
options={"drop": {"body.message": "drop-me #1"}},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
runtime.config.sinks = [SinkConfig(name="out", type="dummy")]
|
||||||
|
await runtime.start()
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
assert runtime.backend is not None
|
||||||
|
total = runtime.backend.count()
|
||||||
|
await runtime.stop()
|
||||||
|
assert total == 0
|
||||||
+31
-5
@@ -8,18 +8,20 @@ import pytest
|
|||||||
from daena.api.grpc_server import DaenaGRPCServer
|
from daena.api.grpc_server import DaenaGRPCServer
|
||||||
from daena.api.pb2 import daena_pb2 as pb2
|
from daena.api.pb2 import daena_pb2 as pb2
|
||||||
from daena.api.pb2 import daena_pb2_grpc as pb2_grpc
|
from daena.api.pb2 import daena_pb2_grpc as pb2_grpc
|
||||||
from daena.config import DaenaConfig, SinkConfig, SourceConfig
|
from daena.config import DaenaConfig, PipelineConfig, SinkConfig, SourceConfig
|
||||||
from daena.core.runtime import DaenaRuntime
|
from daena.core.runtime import DaenaRuntime
|
||||||
|
from daena.persistence.models import RecordState
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def grpc_env():
|
async def grpc_env(tmp_path):
|
||||||
config = DaenaConfig()
|
config = DaenaConfig()
|
||||||
config.sources = [
|
config.sources = [
|
||||||
SourceConfig(name="dummy", type="dummy", options={"interval": 0.2, "message": "test"})
|
SourceConfig(name="dummy", type="dummy", options={"interval": 0.2, "message": "test"})
|
||||||
]
|
]
|
||||||
config.sinks = [SinkConfig(name="log", type="dummy")]
|
config.sinks = [SinkConfig(name="log", type="dummy")]
|
||||||
config.persistence.lmdb.path = "/tmp/daena_grpc_test"
|
config.persistence.lmdb.path = str(tmp_path / "lmdb")
|
||||||
|
config.pipeline = PipelineConfig(batch_size=100, flush_interval=5.0, max_queue_size=10_000)
|
||||||
config.api.grpc.port = 18642
|
config.api.grpc.port = 18642
|
||||||
|
|
||||||
runtime = DaenaRuntime(config)
|
runtime = DaenaRuntime(config)
|
||||||
@@ -43,7 +45,7 @@ async def test_get_status(grpc_env) -> None:
|
|||||||
_, stub, _ = grpc_env
|
_, stub, _ = grpc_env
|
||||||
resp = await stub.GetStatus(pb2.Empty(), timeout=5)
|
resp = await stub.GetStatus(pb2.Empty(), timeout=5)
|
||||||
assert resp.service == "daena"
|
assert resp.service == "daena"
|
||||||
assert resp.version == "0.1.0"
|
assert resp.version == "0.2.0"
|
||||||
assert resp.running is True
|
assert resp.running is True
|
||||||
|
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ async def test_get_queue_state(grpc_env) -> None:
|
|||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
resp = await stub.GetQueueState(pb2.Empty(), timeout=5)
|
resp = await stub.GetQueueState(pb2.Empty(), timeout=5)
|
||||||
assert resp.total > 0
|
assert resp.total > 0
|
||||||
assert resp.pending > 0
|
assert resp.pending + resp.delivered > 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -98,6 +100,30 @@ async def test_list_sinks(grpc_env) -> None:
|
|||||||
assert resp.sinks[0].name == "log"
|
assert resp.sinks[0].name == "log"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_replay_dead(grpc_env) -> None:
|
||||||
|
runtime, stub, _ = grpc_env
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
assert runtime.backend is not None
|
||||||
|
stored = None
|
||||||
|
for state in (RecordState.pending, RecordState.delivered, RecordState.failed):
|
||||||
|
found = runtime.backend.list_by_state(state, limit=1)
|
||||||
|
if found:
|
||||||
|
stored = found[0]
|
||||||
|
break
|
||||||
|
assert stored is not None
|
||||||
|
for sink in stored.deliveries:
|
||||||
|
runtime.backend.dead(stored.record_id, "grpc replay", sink=sink)
|
||||||
|
resp = await stub.ReplayDead(
|
||||||
|
pb2.ReplayRequest(record_id=stored.record_id, limit=10),
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
assert resp.replayed == 1
|
||||||
|
again = runtime.backend.get(stored.record_id)
|
||||||
|
assert again is not None
|
||||||
|
assert again.deliveries["log"].state == RecordState.pending
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reload_config(grpc_env) -> None:
|
async def test_reload_config(grpc_env) -> None:
|
||||||
_, stub, _ = grpc_env
|
_, stub, _ = grpc_env
|
||||||
|
|||||||
+32
-11
@@ -10,7 +10,7 @@ from typing import Any
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from daena.config import DaenaConfig, SinkConfig, SourceConfig
|
from daena.config import DaenaConfig, PipelineConfig, SinkConfig, SourceConfig
|
||||||
from daena.core.runtime import DaenaRuntime
|
from daena.core.runtime import DaenaRuntime
|
||||||
from daena.logutil import setup_logging
|
from daena.logutil import setup_logging
|
||||||
from daena.persistence.models import RecordState
|
from daena.persistence.models import RecordState
|
||||||
@@ -20,6 +20,20 @@ setup_logging(level="error", fmt="json")
|
|||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
|
||||||
|
def _fast_pipeline(config: DaenaConfig) -> None:
|
||||||
|
config.pipeline = PipelineConfig(batch_size=100, flush_interval=0.05, max_queue_size=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_until(predicate: Any, timeout: float = 3.0) -> bool:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
deadline = loop.time() + timeout
|
||||||
|
while loop.time() < deadline:
|
||||||
|
if predicate():
|
||||||
|
return True
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class _TestHTTPServer:
|
class _TestHTTPServer:
|
||||||
"""Minimal async HTTP server to receive forwarded records."""
|
"""Minimal async HTTP server to receive forwarded records."""
|
||||||
|
|
||||||
@@ -74,11 +88,12 @@ async def test_http_sink_forwards_records() -> None:
|
|||||||
config.data_dir = str(tmpdir)
|
config.data_dir = str(tmpdir)
|
||||||
config.log_dir = str(tmpdir)
|
config.log_dir = str(tmpdir)
|
||||||
config.persistence.lmdb.path = str(data_dir / "lmdb")
|
config.persistence.lmdb.path = str(data_dir / "lmdb")
|
||||||
|
_fast_pipeline(config)
|
||||||
config.sources = [
|
config.sources = [
|
||||||
SourceConfig(
|
SourceConfig(
|
||||||
name="test_source",
|
name="test_source",
|
||||||
type="dummy",
|
type="dummy",
|
||||||
options={"interval": 0.2, "message": "http_sink_test"},
|
options={"interval": 0.05, "count": 1, "message": "http_sink_test"},
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
config.sinks = [
|
config.sinks = [
|
||||||
@@ -91,7 +106,7 @@ async def test_http_sink_forwards_records() -> None:
|
|||||||
|
|
||||||
runtime = DaenaRuntime(config)
|
runtime = DaenaRuntime(config)
|
||||||
await runtime.start()
|
await runtime.start()
|
||||||
await asyncio.sleep(3.0)
|
await _wait_until(lambda: len(server.received) > 0)
|
||||||
await runtime.stop()
|
await runtime.stop()
|
||||||
|
|
||||||
await server.stop()
|
await server.stop()
|
||||||
@@ -119,11 +134,12 @@ async def test_http_sink_permanent_failure_on_404() -> None:
|
|||||||
config.data_dir = str(tmpdir)
|
config.data_dir = str(tmpdir)
|
||||||
config.log_dir = str(tmpdir)
|
config.log_dir = str(tmpdir)
|
||||||
config.persistence.lmdb.path = str(data_dir / "lmdb")
|
config.persistence.lmdb.path = str(data_dir / "lmdb")
|
||||||
|
_fast_pipeline(config)
|
||||||
config.sources = [
|
config.sources = [
|
||||||
SourceConfig(
|
SourceConfig(
|
||||||
name="test_source",
|
name="test_source",
|
||||||
type="dummy",
|
type="dummy",
|
||||||
options={"interval": 0.2, "message": "http_404_test"},
|
options={"interval": 0.05, "count": 1, "message": "http_404_test"},
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
config.sinks = [
|
config.sinks = [
|
||||||
@@ -140,11 +156,13 @@ async def test_http_sink_permanent_failure_on_404() -> None:
|
|||||||
|
|
||||||
runtime = DaenaRuntime(config)
|
runtime = DaenaRuntime(config)
|
||||||
await runtime.start()
|
await runtime.start()
|
||||||
await asyncio.sleep(3.0)
|
assert runtime.backend is not None
|
||||||
|
backend = runtime.backend
|
||||||
|
await _wait_until(lambda: backend.count(RecordState.dead) > 0)
|
||||||
|
|
||||||
dead = runtime.backend.count(RecordState.dead)
|
dead = backend.count(RecordState.dead)
|
||||||
failed = runtime.backend.count(RecordState.failed)
|
failed = backend.count(RecordState.failed)
|
||||||
total = runtime.backend.count()
|
total = backend.count()
|
||||||
|
|
||||||
await runtime.stop()
|
await runtime.stop()
|
||||||
|
|
||||||
@@ -168,11 +186,12 @@ async def test_http_sink_delivery_result() -> None:
|
|||||||
config.data_dir = str(tmpdir)
|
config.data_dir = str(tmpdir)
|
||||||
config.log_dir = str(tmpdir)
|
config.log_dir = str(tmpdir)
|
||||||
config.persistence.lmdb.path = str(data_dir / "lmdb")
|
config.persistence.lmdb.path = str(data_dir / "lmdb")
|
||||||
|
_fast_pipeline(config)
|
||||||
config.sources = [
|
config.sources = [
|
||||||
SourceConfig(
|
SourceConfig(
|
||||||
name="test_source",
|
name="test_source",
|
||||||
type="dummy",
|
type="dummy",
|
||||||
options={"interval": 0.2, "message": "http_delivery_test"},
|
options={"interval": 0.05, "count": 1, "message": "http_delivery_test"},
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
config.sinks = [
|
config.sinks = [
|
||||||
@@ -185,9 +204,11 @@ async def test_http_sink_delivery_result() -> None:
|
|||||||
|
|
||||||
runtime = DaenaRuntime(config)
|
runtime = DaenaRuntime(config)
|
||||||
await runtime.start()
|
await runtime.start()
|
||||||
await asyncio.sleep(3.0)
|
assert runtime.backend is not None
|
||||||
|
backend = runtime.backend
|
||||||
|
await _wait_until(lambda: backend.count(RecordState.delivered) > 0)
|
||||||
|
|
||||||
delivered = runtime.backend.count(RecordState.delivered)
|
delivered = backend.count(RecordState.delivered)
|
||||||
await runtime.stop()
|
await runtime.stop()
|
||||||
|
|
||||||
await server.stop()
|
await server.stop()
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from daena.config import (
|
||||||
|
DaenaConfig,
|
||||||
|
MetricsConfig,
|
||||||
|
ProcessorConfig,
|
||||||
|
SinkConfig,
|
||||||
|
SourceConfig,
|
||||||
|
)
|
||||||
|
from daena.core.layout import preview_message, render_layout
|
||||||
|
|
||||||
|
|
||||||
|
def _config() -> DaenaConfig:
|
||||||
|
config = DaenaConfig()
|
||||||
|
config.persistence.lmdb.path = "/tmp/daena/data"
|
||||||
|
config.sources = [
|
||||||
|
SourceConfig(name="dummy", type="dummy", options={"interval": 2.0}),
|
||||||
|
SourceConfig(
|
||||||
|
name="system",
|
||||||
|
type="journald",
|
||||||
|
enabled=False,
|
||||||
|
options={"units": ["ssh.service"]},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
config.processors = [
|
||||||
|
ProcessorConfig(name="host", type="enricher", options={"static": {"env": "local"}}),
|
||||||
|
ProcessorConfig(name="keep", type="filter", options={"drop": {"body.priority": "7"}}),
|
||||||
|
]
|
||||||
|
config.sinks = [
|
||||||
|
SinkConfig(name="log", type="dummy"),
|
||||||
|
SinkConfig(
|
||||||
|
name="loki",
|
||||||
|
type="loki",
|
||||||
|
options={"url": "http://127.0.0.1:3100/loki/api/v1/push"},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
config.logging.verbose = True
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def test_layout_is_a_single_pipeline_line() -> None:
|
||||||
|
text = render_layout(_config(), "./daena.yaml")
|
||||||
|
assert "┌" not in text
|
||||||
|
assert "▼" not in text
|
||||||
|
flow = text.splitlines()[1]
|
||||||
|
assert flow.index("dummy") < flow.index("host")
|
||||||
|
assert flow.index("host") < flow.index("lmdb")
|
||||||
|
assert flow.index("lmdb") < flow.index("log")
|
||||||
|
assert "daena 0.2.0 ./daena.yaml" in text
|
||||||
|
assert "/tmp/daena/data" in text
|
||||||
|
assert "127.0.0.1:8642 127.0.0.1:8643" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_layout_names_each_plugin_once() -> None:
|
||||||
|
text = render_layout(_config(), None)
|
||||||
|
flow = text.splitlines()[1]
|
||||||
|
assert flow == " → ".join(
|
||||||
|
[
|
||||||
|
"dummy (2s), system (journald, off)",
|
||||||
|
"host (enricher), keep (filter)",
|
||||||
|
"lmdb",
|
||||||
|
"log (dummy), loki (127.0.0.1:3100)",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert "daena 0.2.0 defaults" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_layout_skips_empty_stages_and_disabled_metrics() -> None:
|
||||||
|
config = DaenaConfig()
|
||||||
|
config.api.metrics = MetricsConfig(enabled=False)
|
||||||
|
text = render_layout(config, None)
|
||||||
|
assert "→ lmdb" not in text
|
||||||
|
assert text.splitlines()[1] == "lmdb"
|
||||||
|
assert text.splitlines()[-1] == "127.0.0.1:8642"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_message_uses_message_then_clips() -> None:
|
||||||
|
assert preview_message({"message": "daena heartbeat #1"}) == "daena heartbeat #1"
|
||||||
|
assert preview_message({"message": "a\nb"}) == "a b"
|
||||||
|
assert preview_message({"counter": 1}) == '{"counter":1}'
|
||||||
|
clipped = preview_message({"message": "x" * 130}, limit=10)
|
||||||
|
assert clipped.endswith("…")
|
||||||
|
assert len(clipped) == 10
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from daena.api.metrics import MetricsServer, MetricsSnapshot, render_prometheus
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_prometheus_includes_sink_state() -> None:
|
||||||
|
text = render_prometheus(
|
||||||
|
MetricsSnapshot(
|
||||||
|
running=True,
|
||||||
|
stored_records=3,
|
||||||
|
oldest_inflight_seconds=12,
|
||||||
|
deliveries={("central", "pending"): 2, ("central", "delivered"): 1},
|
||||||
|
sink_results={"central": {"delivered": 4, "failed": 1, "dead": 0}},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert "daena_up 1" in text
|
||||||
|
assert "daena_records 3" in text
|
||||||
|
assert "daena_oldest_inflight_seconds 12" in text
|
||||||
|
assert 'daena_delivery_records{sink="central",state="pending"} 2' in text
|
||||||
|
assert 'daena_sink_deliveries_total{sink="central",result="failed"} 1' in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_metrics_server_serves_text() -> None:
|
||||||
|
snapshot = MetricsSnapshot(
|
||||||
|
running=True,
|
||||||
|
stored_records=0,
|
||||||
|
oldest_inflight_seconds=0,
|
||||||
|
deliveries={},
|
||||||
|
sink_results={},
|
||||||
|
)
|
||||||
|
server = MetricsServer("127.0.0.1", 0, lambda: snapshot, lambda: {"status": "ok"})
|
||||||
|
await server.start()
|
||||||
|
assert server._server is not None
|
||||||
|
sockets = server._server.sockets
|
||||||
|
assert sockets is not None
|
||||||
|
port = sockets[0].getsockname()[1]
|
||||||
|
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
||||||
|
writer.write(b"GET /metrics HTTP/1.1\r\nHost: localhost\r\n\r\n")
|
||||||
|
await writer.drain()
|
||||||
|
data = await reader.read()
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
await server.stop()
|
||||||
|
assert b"200 OK" in data
|
||||||
|
assert b"daena_up 1" in data
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from daena.persistence.lmdb import LMDBBackend
|
from daena.persistence.lmdb import LMDBBackend
|
||||||
from daena.persistence.models import RecordState, StoredRecord
|
from daena.persistence.models import RecordState, StoredRecord
|
||||||
|
|
||||||
@@ -105,3 +107,109 @@ def test_ready_for_retry(lmdb_backend: LMDBBackend, sample_record: StoredRecord)
|
|||||||
lmdb_backend.nack(sample_record.record_id, "retry later", retry_delay=0)
|
lmdb_backend.nack(sample_record.record_id, "retry later", retry_delay=0)
|
||||||
ready = lmdb_backend.ready_for_retry(limit=10)
|
ready = lmdb_backend.ready_for_retry(limit=10)
|
||||||
assert len(ready) == 1
|
assert len(ready) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_keys_do_not_accumulate(
|
||||||
|
lmdb_backend: LMDBBackend, sample_record: StoredRecord
|
||||||
|
) -> None:
|
||||||
|
lmdb_backend.put(sample_record)
|
||||||
|
assert lmdb_backend.index_count() == 1
|
||||||
|
lmdb_backend.nack(sample_record.record_id, "later", retry_delay=0)
|
||||||
|
assert lmdb_backend.index_count() == 2
|
||||||
|
lmdb_backend.ack(sample_record.record_id)
|
||||||
|
assert lmdb_backend.ready_for_retry() == []
|
||||||
|
assert lmdb_backend.pending() == []
|
||||||
|
assert lmdb_backend.count(RecordState.failed) == 0
|
||||||
|
assert lmdb_backend.count(RecordState.delivered) == 1
|
||||||
|
assert lmdb_backend.index_count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_and_recover(lmdb_backend: LMDBBackend, sample_record: StoredRecord) -> None:
|
||||||
|
lmdb_backend.put(sample_record)
|
||||||
|
claimed = lmdb_backend.claim("out", limit=10)
|
||||||
|
assert len(claimed) == 1
|
||||||
|
assert claimed[0].deliveries["out"].state == RecordState.delivering
|
||||||
|
assert lmdb_backend.pending() == []
|
||||||
|
assert lmdb_backend.recover_inflight() == 1
|
||||||
|
pending = lmdb_backend.pending()
|
||||||
|
assert len(pending) == 1
|
||||||
|
assert pending[0].deliveries["out"].state == RecordState.pending
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_sink_ack_leaves_other_sink_pending(lmdb_backend: LMDBBackend) -> None:
|
||||||
|
record = StoredRecord(source="s", record_type="t", body={"n": 1}, sinks=["a", "b"])
|
||||||
|
lmdb_backend.put(record)
|
||||||
|
lmdb_backend.ack(record.record_id, sink="a")
|
||||||
|
stored = lmdb_backend.get(record.record_id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.deliveries["a"].state == RecordState.delivered
|
||||||
|
assert stored.deliveries["b"].state == RecordState.pending
|
||||||
|
assert lmdb_backend.count(RecordState.pending) == 1
|
||||||
|
assert lmdb_backend.count(RecordState.delivered) == 1
|
||||||
|
assert lmdb_backend.pending(sink="a") == []
|
||||||
|
assert len(lmdb_backend.pending(sink="b")) == 1
|
||||||
|
removed = lmdb_backend.delete_old("2100-01-01T00:00:00+00:00", state=RecordState.delivered)
|
||||||
|
assert removed == 0
|
||||||
|
assert lmdb_backend.get(record.record_id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_dead_becomes_pending(
|
||||||
|
lmdb_backend: LMDBBackend, sample_record: StoredRecord
|
||||||
|
) -> None:
|
||||||
|
lmdb_backend.put(sample_record)
|
||||||
|
lmdb_backend.dead(sample_record.record_id, error="nope")
|
||||||
|
replayed = lmdb_backend.replay(record_id=sample_record.record_id)
|
||||||
|
stored = lmdb_backend.get(sample_record.record_id)
|
||||||
|
assert replayed == 1
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.deliveries["out"].state == RecordState.pending
|
||||||
|
assert stored.deliveries["out"].retry_count == 0
|
||||||
|
assert stored.deliveries["out"].last_error is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_expired_keeps_inflight(lmdb_backend: LMDBBackend) -> None:
|
||||||
|
record = StoredRecord(source="s", record_type="t", body={}, sinks=["a", "b"])
|
||||||
|
lmdb_backend.put(record)
|
||||||
|
lmdb_backend.ack(record.record_id, sink="a")
|
||||||
|
lmdb_backend.dead(record.record_id, "boom", sink="b")
|
||||||
|
removed = lmdb_backend.delete_expired("2100-01-01T00:00:00+00:00", "2000-01-01T00:00:00+00:00")
|
||||||
|
assert removed == 0
|
||||||
|
removed = lmdb_backend.delete_expired("2100-01-01T00:00:00+00:00", "2100-01-01T00:00:00+00:00")
|
||||||
|
assert removed == 1
|
||||||
|
assert lmdb_backend.get(record.record_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrates_legacy_record(tmp_db_path: Path) -> None:
|
||||||
|
import json
|
||||||
|
|
||||||
|
import lmdb
|
||||||
|
|
||||||
|
tmp_db_path.mkdir(parents=True)
|
||||||
|
env = lmdb.open(str(tmp_db_path), map_size=10_485_760, subdir=True)
|
||||||
|
payload = {
|
||||||
|
"record_id": "legacy",
|
||||||
|
"source": "s",
|
||||||
|
"record_type": "t",
|
||||||
|
"body": {"message": "old"},
|
||||||
|
"metadata": {},
|
||||||
|
"state": "failed",
|
||||||
|
"created_at": "2020-01-01T00:00:00+00:00",
|
||||||
|
"updated_at": "2020-01-01T00:00:00+00:00",
|
||||||
|
"retry_count": 2,
|
||||||
|
"next_retry_at": "2020-01-01T00:01:00+00:00",
|
||||||
|
"last_error": "boom",
|
||||||
|
"destination": "central",
|
||||||
|
}
|
||||||
|
with env.begin(write=True) as txn:
|
||||||
|
txn.put(b"rec:legacy", json.dumps(payload).encode())
|
||||||
|
env.close()
|
||||||
|
|
||||||
|
backend = LMDBBackend(path=str(tmp_db_path), map_size=10_485_760)
|
||||||
|
backend.open()
|
||||||
|
stored = backend.get("legacy")
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.deliveries["central"].state == RecordState.failed
|
||||||
|
assert stored.deliveries["central"].retry_count == 2
|
||||||
|
assert stored.deliveries["central"].last_error == "boom"
|
||||||
|
assert backend.count(RecordState.failed) == 1
|
||||||
|
backend.close()
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from daena.pipeline.processors.enricher import EnricherProcessor
|
||||||
|
from daena.pipeline.processors.filter import FilterProcessor
|
||||||
|
from daena.pipeline.record import Record
|
||||||
|
from daena.pipeline.sinks.loki import LokiSink
|
||||||
|
from daena.pipeline.sources.journald import (
|
||||||
|
JournaldSource,
|
||||||
|
build_journalctl_command,
|
||||||
|
entry_to_record,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_journalctl_command_tails_new_entries_without_cursor() -> None:
|
||||||
|
command = build_journalctl_command(cursor=None, units=["ssh.service"], matches=["_PID=1"])
|
||||||
|
assert command[:4] == ["journalctl", "--follow", "--output=json", "--no-pager"]
|
||||||
|
assert "--lines=0" in command
|
||||||
|
assert command[command.index("--unit") + 1] == "ssh.service"
|
||||||
|
assert "_PID=1" in command
|
||||||
|
|
||||||
|
|
||||||
|
def test_journalctl_command_uses_cursor() -> None:
|
||||||
|
command = build_journalctl_command(cursor="abc", units=[], matches=[])
|
||||||
|
assert "--after-cursor=abc" in command
|
||||||
|
assert "--lines=0" not in command
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_to_record_uses_journal_timestamp() -> None:
|
||||||
|
record = entry_to_record(
|
||||||
|
"system",
|
||||||
|
{
|
||||||
|
"MESSAGE": "hello",
|
||||||
|
"SYSLOG_IDENTIFIER": "sshd",
|
||||||
|
"_SYSTEMD_UNIT": "ssh.service",
|
||||||
|
"__CURSOR": "cur",
|
||||||
|
"__REALTIME_TIMESTAMP": "1600000000000000",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert record.source == "system"
|
||||||
|
assert record.record_type == "journald"
|
||||||
|
assert record.body["message"] == "hello"
|
||||||
|
assert record.body["syslog_identifier"] == "sshd"
|
||||||
|
assert record.metadata["unit"] == "ssh.service"
|
||||||
|
assert record.timestamp.startswith("2020-")
|
||||||
|
|
||||||
|
|
||||||
|
class _ListReader:
|
||||||
|
def __init__(self, entries: list[dict[str, Any]]) -> None:
|
||||||
|
self._entries = entries
|
||||||
|
self.stopped = False
|
||||||
|
|
||||||
|
async def run(self, on_entry) -> None: # type: ignore[no-untyped-def]
|
||||||
|
for entry in self._entries:
|
||||||
|
await on_entry(entry)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self.stopped = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_journald_source_emits_and_stores_cursor(tmp_path: Path) -> None:
|
||||||
|
cursor_path = tmp_path / "cursor"
|
||||||
|
reader = _ListReader(
|
||||||
|
[
|
||||||
|
{"MESSAGE": "one", "__CURSOR": "c1"},
|
||||||
|
{"MESSAGE": "two", "__CURSOR": "c2"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
source = JournaldSource(
|
||||||
|
"journal",
|
||||||
|
config={"cursor_path": str(cursor_path)},
|
||||||
|
reader=reader,
|
||||||
|
)
|
||||||
|
seen: list[Record] = []
|
||||||
|
|
||||||
|
async def capture(record: Record) -> None:
|
||||||
|
seen.append(record)
|
||||||
|
|
||||||
|
source.set_callback(capture)
|
||||||
|
await source.start()
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
await source.stop()
|
||||||
|
assert [record.body["message"] for record in seen] == ["one", "two"]
|
||||||
|
assert cursor_path.read_text(encoding="utf-8").strip() == "c2"
|
||||||
|
assert reader.stopped is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enricher_sets_hostname_and_static_fields() -> None:
|
||||||
|
processor = EnricherProcessor(
|
||||||
|
"host",
|
||||||
|
config={"hostname": True, "static": {"env": "prod"}, "target": "metadata"},
|
||||||
|
)
|
||||||
|
record = Record(source="s", record_type="t", body={"message": "m"})
|
||||||
|
result = await processor.process(record)
|
||||||
|
assert result is not None
|
||||||
|
assert result.metadata["hostname"]
|
||||||
|
assert result.metadata["env"] == "prod"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_filter_drop_and_keep() -> None:
|
||||||
|
dropper = FilterProcessor("drop", config={"drop": {"body.level": "debug"}})
|
||||||
|
debug = Record(source="s", record_type="t", body={"level": "debug"})
|
||||||
|
info = Record(source="s", record_type="t", body={"level": "info"})
|
||||||
|
assert await dropper.process(debug) is None
|
||||||
|
assert await dropper.process(info) is not None
|
||||||
|
|
||||||
|
keeper = FilterProcessor("keep", config={"keep": {"source": "journal"}})
|
||||||
|
assert await keeper.process(Record(source="other", record_type="t", body={})) is None
|
||||||
|
kept = await keeper.process(Record(source="journal", record_type="t", body={}))
|
||||||
|
assert kept is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_loki_sink_posts_stream() -> None:
|
||||||
|
response = MagicMock()
|
||||||
|
response.is_success = True
|
||||||
|
response.status_code = 204
|
||||||
|
client = AsyncMock()
|
||||||
|
client.post = AsyncMock(return_value=response)
|
||||||
|
client.aclose = AsyncMock()
|
||||||
|
with patch("daena.pipeline.sinks.loki.httpx.AsyncClient", return_value=client):
|
||||||
|
sink = LokiSink("loki", config={"url": "http://loki/push", "labels": {"job": "daena"}})
|
||||||
|
await sink.start()
|
||||||
|
record = Record(
|
||||||
|
source="system",
|
||||||
|
record_type="journald",
|
||||||
|
body={"message": "hello"},
|
||||||
|
timestamp="2020-09-13T12:26:40+00:00",
|
||||||
|
)
|
||||||
|
result = await sink.deliver(record)
|
||||||
|
await sink.stop()
|
||||||
|
assert result.success is True
|
||||||
|
payload = client.post.await_args.kwargs["json"]
|
||||||
|
stream = payload["streams"][0]
|
||||||
|
assert stream["stream"]["job"] == "daena"
|
||||||
|
assert stream["stream"]["source"] == "system"
|
||||||
|
assert stream["values"][0][1] == "hello"
|
||||||
|
assert stream["values"][0][0].isdigit()
|
||||||
|
json.dumps(payload)
|
||||||
Reference in New Issue
Block a user