3 Commits
Author SHA1 Message Date
ahp ab9cd43282 chore: remove the GitHub Actions workflow 2026-10-14 02:05:17 +03:30
ahp 8874ef9c43 chore: set version to 0.2.0
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / release (push) Canceled after 0s
2026-10-14 02:00:31 +03:30
ahp 06d233054b feat: per-sink delivery, journal forwarding, and operational API
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / release (push) Canceled after 0s
Persistence:
- one delivery state per sink, with claim, crash recovery, and index replacement
- retention for delivered and dead records
- LMDB writes moved off the event loop

Pipeline:
- one attempt per dispatch, with the next try scheduled by next_retry_at
- backpressure through max_queue_size
- reload re-reads YAML; SIGTERM drains and SIGHUP reloads

Plugins:
- journald source, enricher and filter processors, Loki sink
- built-ins register when entry points are missing

API and CLI:
- health, metrics, per-sink queue, and dead-letter replay
- startup prints the pipeline on one line

Tests:
- dispatch, plugins, metrics, CLI, and persistence coverage
2026-10-14 01:58:07 +03:30
42 changed files with 3287 additions and 638 deletions
-76
View File
@@ -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
+2
View File
@@ -28,6 +28,8 @@ data/
logs/ logs/
*.pid *.pid
*.sock *.sock
/daena.yaml
/deploy/graylog/
# Env # Env
.env .env
+1 -1
View File
@@ -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"]
+29 -9
View File
@@ -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
+41
View File
@@ -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:
+35
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -1 +1 @@
__version__ = "0.1.0" __version__ = "0.2.0"
+111 -49
View File
@@ -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}"
+112
View File
@@ -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()
+38 -30
View File
@@ -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)
+47
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+20
View File
@@ -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)
+106
View File
@@ -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
View File
@@ -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
View File
@@ -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()))
+84
View File
@@ -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()
+58 -22
View File
@@ -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
View File
@@ -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
View File
@@ -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,
) )
+6
View File
@@ -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:
+37
View File
@@ -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
+66
View File
@@ -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
+23 -2
View File
@@ -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)
+101
View File
@@ -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"
+4 -4
View File
@@ -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)
+211
View File
@@ -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)
+32 -1
View File
@@ -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)
+5
View File
@@ -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()
+1
View File
@@ -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
View File
@@ -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:
+25
View File
@@ -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
+182
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+83
View File
@@ -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
+50
View File
@@ -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
+108
View File
@@ -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()
+149
View File
@@ -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)