feat: implement daena MVP core service
Complete implementation of the daena log/event collection service:
Core runtime:
- DaenaRuntime service lifecycle (start/stop/reload)
- Pipeline orchestrator with source/persist/dispatch flow
- LMDB persistence backend with key-prefix partitioning
- Plugin discovery via Python entry points
Pipeline:
- Source/Processor/Sink ABC interfaces with DeliveryResult
- DummySource for testing and development
- DummySink and HTTPSink implementations
- PassthroughProcessor
- Record dataclass for in-memory records
- StoredRecord model with RecordState tracking
REST API (FastAPI, v1):
- GET /system/{status,health,config}
- GET /plugins
- GET /queue, /queue/{pending,dead}
- GET /sinks
- Versioned under /api/v1 prefix
CLI (Typer):
- status, health, queue, sinks, plugins, config, version
Persistence (LMDB):
- Single-database key-prefix design (no named DBs)
- Index keys: idx:{state}:{ts}\x00{id}
- Record keys: rec:{id}
- Full CRUD: put/get/ack/nack/dead/update_state
- Pending/retry/dead-letter queries with state filtering
- delete_old for TTL-based retention
Deployment:
- systemd unit file (deploy/daena.service)
- Example YAML config (config/daena.yaml)
- Entry points: daena (CLI), daena-serve (uvicorn server)
Quality:
- 23 unit tests covering config, persistence, pipeline
- ruff linting and formatting pass clean
- Py3.13 compatible, uv-managed, pyenv-pinned
- GPG-signed commit on dev branch
This commit is contained in:
@@ -1,2 +1,212 @@
|
|||||||
# daena
|
# daena
|
||||||
Collect it. Keep it. Ship it. That’s Daena! 🤠
|
|
||||||
|
Collect, buffer, and forward logs and events from Linux to central observability backends.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Daena is a modular, extensible Linux service that:
|
||||||
|
|
||||||
|
- Collects logs, events, and telemetry from pluggable sources
|
||||||
|
- Normalizes and processes records through a configurable pipeline
|
||||||
|
- Persists data locally in LMDB for durability, replay, and buffering
|
||||||
|
- Forwards records reliably to one or more remote sinks
|
||||||
|
- Exposes a REST API for management and inspection
|
||||||
|
- Provides a CLI for operational tasks
|
||||||
|
- Degrades gracefully under failures with retry, backoff, and dead-letter queues
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Sources → [Processors] → Pipeline → Persistence (LMDB) → Sink Dispatcher → Remote
|
||||||
|
↻ retry queue
|
||||||
|
```
|
||||||
|
|
||||||
|
Key design decisions:
|
||||||
|
|
||||||
|
- **Plugin architecture**: Sources, processors, and sinks are loaded via
|
||||||
|
Python entry points. Add new ones by installing a package or registering
|
||||||
|
in `pyproject.toml`.
|
||||||
|
- **Pluggable persistence**: The `Backend` protocol abstracts storage.
|
||||||
|
MVP uses LMDB. Future: NATS JetStream.
|
||||||
|
- **Reliability-first**: Records survive restarts, sink failures trigger
|
||||||
|
exponential backoff, and permanently failed records land in a dead-letter
|
||||||
|
queue.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prerequisites: pyenv with Python 3.13, uv installed
|
||||||
|
|
||||||
|
# Clone and set up
|
||||||
|
git clone <repo> && cd daena
|
||||||
|
pyenv local 3.13.5
|
||||||
|
uv sync --extra dev
|
||||||
|
|
||||||
|
# Run with dummy source/sink (no external dependencies)
|
||||||
|
uv run python -c "
|
||||||
|
import asyncio
|
||||||
|
from daena.config import DaenaConfig, SourceConfig, SinkConfig
|
||||||
|
from daena.core.runtime import DaenaRuntime
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
config = DaenaConfig()
|
||||||
|
config.sources = [SourceConfig(name='test', type='dummy',
|
||||||
|
options={'interval': 1.0, 'message': 'hello'})]
|
||||||
|
config.sinks = [SinkConfig(name='log', type='dummy')]
|
||||||
|
config.persistence.lmdb.path = '/tmp/daena_data'
|
||||||
|
|
||||||
|
runtime = DaenaRuntime(config)
|
||||||
|
await runtime.start()
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
print(f'Records: {runtime.backend.count()}')
|
||||||
|
await runtime.stop()
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
"
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
uv run pytest tests/ -v
|
||||||
|
|
||||||
|
# Use the CLI (point at a running daena API)
|
||||||
|
uv run daena status
|
||||||
|
uv run daena health
|
||||||
|
uv run daena queue
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
daena/
|
||||||
|
├── pyproject.toml # Project metadata, dependencies, entry points
|
||||||
|
├── config/daena.yaml # Example configuration
|
||||||
|
├── deploy/daena.service # systemd unit file
|
||||||
|
├── src/daena/
|
||||||
|
│ ├── app.py # `daena-serve` entry point (uvicorn)
|
||||||
|
│ ├── config.py # YAML → pydantic config
|
||||||
|
│ ├── logutil.py # structlog setup
|
||||||
|
│ ├── exceptions.py # Domain exceptions
|
||||||
|
│ ├── api/ # FastAPI REST API (v1 versioned)
|
||||||
|
│ ├── cli/ # Typer CLI
|
||||||
|
│ ├── core/
|
||||||
|
│ │ ├── runtime.py # Service lifecycle
|
||||||
|
│ │ └── pipeline.py # Collection → persist → dispatch
|
||||||
|
│ ├── persistence/
|
||||||
|
│ │ ├── backend.py # Abstract Backend protocol
|
||||||
|
│ │ ├── models.py # RecordState, StoredRecord
|
||||||
|
│ │ └── lmdb.py # LMDB implementation
|
||||||
|
│ ├── pipeline/
|
||||||
|
│ │ ├── base.py # Source/Processor/Sink ABCs
|
||||||
|
│ │ ├── record.py # In-memory Record dataclass
|
||||||
|
│ │ ├── sources/ # Built-in sources (dummy)
|
||||||
|
│ │ ├── sinks/ # Built-in sinks (dummy, http)
|
||||||
|
│ │ └── processors/ # Built-in processors (passthrough)
|
||||||
|
│ └── plugins/
|
||||||
|
│ ├── loader.py # Entry-point discovery
|
||||||
|
│ └── registry.py # Plugin registry
|
||||||
|
└── tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Daena loads configuration from `/etc/daena/daena.yaml`, `./daena.yaml`, or
|
||||||
|
`./config/daena.yaml`. See `config/daena.yaml` for a full example.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
logging:
|
||||||
|
level: info
|
||||||
|
format: json
|
||||||
|
|
||||||
|
api:
|
||||||
|
http:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8642
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
backend: lmdb
|
||||||
|
lmdb:
|
||||||
|
path: /var/lib/daena/data
|
||||||
|
map_size: 104857600
|
||||||
|
|
||||||
|
sources:
|
||||||
|
- name: system_logs
|
||||||
|
type: journald # future
|
||||||
|
options: {}
|
||||||
|
|
||||||
|
processors:
|
||||||
|
- name: add_hostname
|
||||||
|
type: enricher # future
|
||||||
|
options: {}
|
||||||
|
|
||||||
|
sinks:
|
||||||
|
- name: central
|
||||||
|
type: http
|
||||||
|
options:
|
||||||
|
url: https://logs.example.com/ingest
|
||||||
|
```
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
The API runs on `http://127.0.0.1:8642/api/v1`:
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `GET /system/status` | Service status |
|
||||||
|
| `GET /system/health` | Health check |
|
||||||
|
| `GET /system/config` | Loaded configuration |
|
||||||
|
| `GET /plugins` | Registered plugins |
|
||||||
|
| `GET /queue` | Queue state counts |
|
||||||
|
| `GET /queue/pending` | Pending records |
|
||||||
|
| `GET /queue/dead` | Dead-letter records |
|
||||||
|
| `GET /sinks` | Configured sinks |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### systemd
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create daena user
|
||||||
|
sudo useradd -r -s /sbin/nologin -d /var/lib/daena daena
|
||||||
|
|
||||||
|
# Install package
|
||||||
|
uv pip install .
|
||||||
|
|
||||||
|
# Set up directories
|
||||||
|
sudo mkdir -p /etc/daena /var/lib/daena /var/log/daena /run/daena
|
||||||
|
sudo cp config/daena.yaml /etc/daena/
|
||||||
|
sudo cp deploy/daena.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now daena
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extending
|
||||||
|
|
||||||
|
Add a new source by creating a class that inherits `daena.pipeline.base.Source`
|
||||||
|
and registering it as an entry point:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# my_sources.py
|
||||||
|
from daena.pipeline.base import Source
|
||||||
|
from daena.pipeline.record import Record
|
||||||
|
|
||||||
|
class MySource(Source):
|
||||||
|
async def start(self):
|
||||||
|
pass
|
||||||
|
async def stop(self):
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# pyproject.toml
|
||||||
|
[project.entry-points."daena.sources"]
|
||||||
|
my_source = "my_sources:MySource"
|
||||||
|
```
|
||||||
|
|
||||||
|
Same pattern applies for `Processor` and `Sink` base classes.
|
||||||
|
|
||||||
|
## Branch workflow
|
||||||
|
|
||||||
|
- `main` — stable integration branch
|
||||||
|
- `dev` — active development branch
|
||||||
|
|
||||||
|
Feature work happens on `dev` or topic branches. Merges to `main` represent
|
||||||
|
stable release checkpoints.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# daena configuration
|
||||||
|
# Path: /etc/daena/daena.yaml
|
||||||
|
|
||||||
|
data_dir: /var/lib/daena
|
||||||
|
log_dir: /var/log/daena
|
||||||
|
pid_file: /run/daena/daena.pid
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: info
|
||||||
|
format: json
|
||||||
|
|
||||||
|
api:
|
||||||
|
http:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 8642
|
||||||
|
workers: 1
|
||||||
|
|
||||||
|
pipeline:
|
||||||
|
batch_size: 100
|
||||||
|
flush_interval: 5.0
|
||||||
|
max_queue_size: 10000
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
backend: lmdb
|
||||||
|
lmdb:
|
||||||
|
path: /var/lib/daena/data
|
||||||
|
map_size: 104857600
|
||||||
|
max_databases: 16
|
||||||
|
|
||||||
|
sources:
|
||||||
|
- name: dummy
|
||||||
|
type: dummy
|
||||||
|
enabled: true
|
||||||
|
options:
|
||||||
|
interval: 5.0
|
||||||
|
message: "daena heartbeat"
|
||||||
|
|
||||||
|
processors:
|
||||||
|
- name: passthrough
|
||||||
|
type: passthrough
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
sinks:
|
||||||
|
- name: log
|
||||||
|
type: dummy
|
||||||
|
enabled: true
|
||||||
|
options: {}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daena - Linux log and event collector
|
||||||
|
Documentation=https://github.com/anomalyco/daena
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=daena
|
||||||
|
Group=daena
|
||||||
|
|
||||||
|
ExecStart=/usr/local/bin/daena-serve
|
||||||
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
AmbientCapabilities=CAP_SYS_PTRACE
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
StateDirectory=daena
|
||||||
|
LogsDirectory=daena
|
||||||
|
RuntimeDirectory=daena
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
Environment=DAENA_CONFIG_PATH=/etc/daena/daena.yaml
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1 +1 @@
|
|||||||
from daena._version import __version__
|
from daena._version import __version__ as __version__
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from daena.cli.main import app
|
||||||
|
|
||||||
|
app()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from daena.api.routes.plugins import router as plugins_router
|
||||||
|
from daena.api.routes.queue import router as queue_router
|
||||||
|
from daena.api.routes.sinks import router as sinks_router
|
||||||
|
from daena.api.routes.system import router as system_router
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
api_router.include_router(system_router, prefix="/system", tags=["system"])
|
||||||
|
api_router.include_router(plugins_router, prefix="/plugins", tags=["plugins"])
|
||||||
|
api_router.include_router(queue_router, prefix="/queue", tags=["queue"])
|
||||||
|
api_router.include_router(sinks_router, prefix="/sinks", tags=["sinks"])
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_plugins(request: Request) -> dict:
|
||||||
|
runtime = request.app.state.runtime
|
||||||
|
registry = runtime.registry
|
||||||
|
plugins = registry.all_plugins()
|
||||||
|
result: dict[str, list[dict]] = {}
|
||||||
|
for category, entries in plugins.items():
|
||||||
|
result[category] = [{"name": name, "class": cls.__name__} for name, cls in entries.items()]
|
||||||
|
return {"plugins": result}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query, Request
|
||||||
|
|
||||||
|
from daena.persistence.models import RecordState
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_queue_state(request: Request) -> dict:
|
||||||
|
runtime = request.app.state.runtime
|
||||||
|
backend = runtime.backend
|
||||||
|
if backend is None:
|
||||||
|
return {"error": "backend not initialized"}
|
||||||
|
return {
|
||||||
|
"pending": backend.count(RecordState.pending),
|
||||||
|
"delivered": backend.count(RecordState.delivered),
|
||||||
|
"failed": backend.count(RecordState.failed),
|
||||||
|
"dead": backend.count(RecordState.dead),
|
||||||
|
"total": backend.count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending")
|
||||||
|
async def get_pending(
|
||||||
|
request: Request,
|
||||||
|
limit: int = Query(default=100, le=1000),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
) -> dict:
|
||||||
|
runtime = request.app.state.runtime
|
||||||
|
backend = runtime.backend
|
||||||
|
if backend is None:
|
||||||
|
return {"error": "backend not initialized", "records": []}
|
||||||
|
records = backend.list_by_state(RecordState.pending, limit=limit, offset=offset)
|
||||||
|
return {"records": [r.to_dict() for r in records], "count": len(records)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dead")
|
||||||
|
async def get_dead(
|
||||||
|
request: Request,
|
||||||
|
limit: int = Query(default=100, le=1000),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
) -> dict:
|
||||||
|
runtime = request.app.state.runtime
|
||||||
|
backend = runtime.backend
|
||||||
|
if backend is None:
|
||||||
|
return {"error": "backend not initialized", "records": []}
|
||||||
|
records = backend.list_by_state(RecordState.dead, limit=limit, offset=offset)
|
||||||
|
return {"records": [r.to_dict() for r in records], "count": len(records)}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_sinks(request: Request) -> dict:
|
||||||
|
runtime = request.app.state.runtime
|
||||||
|
pipeline = runtime.pipeline
|
||||||
|
if pipeline is None:
|
||||||
|
return {"sinks": []}
|
||||||
|
sinks = pipeline.sinks
|
||||||
|
return {
|
||||||
|
"sinks": [{"name": s.name, "type": type(s).__name__, "config": s.config} for s in sinks]
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
|
from daena.core.runtime import DaenaRuntime
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_runtime(request: Request) -> DaenaRuntime:
|
||||||
|
return request.app.state.runtime # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def get_status(request: Request) -> dict:
|
||||||
|
runtime = _resolve_runtime(request)
|
||||||
|
return {
|
||||||
|
"service": "daena",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"running": runtime._running,
|
||||||
|
"uptime": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def get_health(request: Request) -> dict:
|
||||||
|
runtime = _resolve_runtime(request)
|
||||||
|
healthy = runtime._running
|
||||||
|
return {
|
||||||
|
"healthy": healthy,
|
||||||
|
"running": runtime._running,
|
||||||
|
"backend": runtime.backend is not None,
|
||||||
|
"plugins_loaded": bool(runtime.registry.list_sources()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_config(request: Request) -> dict:
|
||||||
|
runtime = _resolve_runtime(request)
|
||||||
|
cfg = runtime.config
|
||||||
|
return {
|
||||||
|
"data_dir": cfg.data_dir,
|
||||||
|
"log_dir": cfg.log_dir,
|
||||||
|
"api": cfg.api.model_dump(),
|
||||||
|
"pipeline": cfg.pipeline.model_dump(),
|
||||||
|
"persistence": cfg.persistence.model_dump(),
|
||||||
|
"sources": [s.model_dump() for s in cfg.sources],
|
||||||
|
"processors": [p.model_dump() for p in cfg.processors],
|
||||||
|
"sinks": [s.model_dump() for s in cfg.sinks],
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from daena.api.router import api_router
|
||||||
|
from daena.core.runtime import DaenaRuntime
|
||||||
|
from daena.logutil import get_logger
|
||||||
|
|
||||||
|
log = get_logger("daena.api")
|
||||||
|
|
||||||
|
|
||||||
|
class DaenaAPI:
|
||||||
|
"""Wraps the FastAPI application with daena runtime integration."""
|
||||||
|
|
||||||
|
def __init__(self, runtime: DaenaRuntime) -> None:
|
||||||
|
self._runtime = runtime
|
||||||
|
self._app: FastAPI | None = None
|
||||||
|
|
||||||
|
def build(self) -> FastAPI:
|
||||||
|
runtime = self._runtime
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
log.info("api_starting")
|
||||||
|
await runtime.start()
|
||||||
|
yield
|
||||||
|
log.info("api_stopping")
|
||||||
|
await runtime.stop()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Daena",
|
||||||
|
description="Daena service management API",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.state.runtime = runtime
|
||||||
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
|
||||||
|
self._app = app
|
||||||
|
return app
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app(self) -> FastAPI:
|
||||||
|
if self._app is None:
|
||||||
|
return self.build()
|
||||||
|
return self._app
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from daena.api.server import DaenaAPI
|
||||||
|
from daena.config import load_config
|
||||||
|
from daena.logutil import get_logger, setup_logging
|
||||||
|
|
||||||
|
log = get_logger("daena.app")
|
||||||
|
|
||||||
|
|
||||||
|
def serve() -> None:
|
||||||
|
"""Entry point for `daena-serve` (systemd/uvicorn mode)."""
|
||||||
|
config_path = _resolve_config_path()
|
||||||
|
config = load_config(config_path)
|
||||||
|
setup_logging(
|
||||||
|
level=config.logging.level,
|
||||||
|
fmt=config.logging.format,
|
||||||
|
log_file=config.logging.file,
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime = _build_runtime(config)
|
||||||
|
api = DaenaAPI(runtime)
|
||||||
|
app = api.build()
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"starting_server",
|
||||||
|
host=config.api.http.host,
|
||||||
|
port=config.api.http.port,
|
||||||
|
)
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host=config.api.http.host,
|
||||||
|
port=config.api.http.port,
|
||||||
|
workers=config.api.http.workers,
|
||||||
|
log_level=config.logging.level,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_config_path() -> str | None:
|
||||||
|
for candidate in (
|
||||||
|
"/etc/daena/daena.yaml",
|
||||||
|
"./daena.yaml",
|
||||||
|
"./config/daena.yaml",
|
||||||
|
):
|
||||||
|
p = Path(candidate)
|
||||||
|
if p.exists():
|
||||||
|
return str(p)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_runtime(config):
|
||||||
|
"""Lazy import to prevent premature backend init."""
|
||||||
|
from daena.core.runtime import DaenaRuntime
|
||||||
|
|
||||||
|
return DaenaRuntime(config)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
serve()
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import typer
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
from daena._version import __version__
|
||||||
|
|
||||||
|
app = typer.Typer(
|
||||||
|
name="daena",
|
||||||
|
help="Daena: collect, buffer, and forward logs and events.",
|
||||||
|
)
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
DEFAULT_API_URL = "http://127.0.0.1:8642/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _client(timeout: float = 10.0) -> httpx.Client:
|
||||||
|
return httpx.Client(timeout=httpx.Timeout(timeout))
|
||||||
|
|
||||||
|
|
||||||
|
def _api_url() -> str:
|
||||||
|
return DEFAULT_API_URL # TODO: make configurable via env/flag
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def status() -> None:
|
||||||
|
"""Show daemon status."""
|
||||||
|
try:
|
||||||
|
with _client(5.0) as c:
|
||||||
|
resp = c.get(f"{_api_url()}/system/status")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
console.print(f"[bold]Daena[/bold] v{data.get('version', '?')}")
|
||||||
|
console.print(f"Running: {data.get('running', '?')}")
|
||||||
|
except httpx.ConnectError:
|
||||||
|
console.print("[red]Cannot connect to daena service[/red]")
|
||||||
|
raise typer.Exit(1) from None
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def health() -> None:
|
||||||
|
"""Check service health."""
|
||||||
|
try:
|
||||||
|
with _client(5.0) as c:
|
||||||
|
resp = c.get(f"{_api_url()}/system/health")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("healthy"):
|
||||||
|
console.print("[green]Healthy[/green]")
|
||||||
|
else:
|
||||||
|
console.print("[red]Unhealthy[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
except httpx.ConnectError:
|
||||||
|
console.print("[red]Cannot connect to daena service[/red]")
|
||||||
|
raise typer.Exit(1) from None
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def queue() -> None:
|
||||||
|
"""Inspect queue state."""
|
||||||
|
try:
|
||||||
|
with _client() as c:
|
||||||
|
resp = c.get(f"{_api_url()}/queue")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
table = Table("State", "Count")
|
||||||
|
for key in ("pending", "delivered", "failed", "dead", "total"):
|
||||||
|
table.add_row(key, str(data.get(key, 0)))
|
||||||
|
console.print(table)
|
||||||
|
except httpx.ConnectError:
|
||||||
|
console.print("[red]Cannot connect to daena service[/red]")
|
||||||
|
raise typer.Exit(1) from None
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def sinks() -> None:
|
||||||
|
"""List configured sinks."""
|
||||||
|
try:
|
||||||
|
with _client() as c:
|
||||||
|
resp = c.get(f"{_api_url()}/sinks")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
sinks_list = data.get("sinks", [])
|
||||||
|
if not sinks_list:
|
||||||
|
console.print("No sinks configured")
|
||||||
|
return
|
||||||
|
table = Table("Name", "Type")
|
||||||
|
for s in sinks_list:
|
||||||
|
table.add_row(s["name"], s["type"])
|
||||||
|
console.print(table)
|
||||||
|
except httpx.ConnectError:
|
||||||
|
console.print("[red]Cannot connect to daena service[/red]")
|
||||||
|
raise typer.Exit(1) from None
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def plugins() -> None:
|
||||||
|
"""List loaded plugins."""
|
||||||
|
try:
|
||||||
|
with _client() as c:
|
||||||
|
resp = c.get(f"{_api_url()}/plugins")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
plugins_data = data.get("plugins", {})
|
||||||
|
for category, items in plugins_data.items():
|
||||||
|
if items:
|
||||||
|
table = Table(f"{category.title()}")
|
||||||
|
for item in items:
|
||||||
|
table.add_row(f"{item['name']} ({item['class']})")
|
||||||
|
console.print(table)
|
||||||
|
console.print()
|
||||||
|
except httpx.ConnectError:
|
||||||
|
console.print("[red]Cannot connect to daena service[/red]")
|
||||||
|
raise typer.Exit(1) from None
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def config() -> None:
|
||||||
|
"""Show loaded service configuration."""
|
||||||
|
try:
|
||||||
|
with _client() as c:
|
||||||
|
resp = c.get(f"{_api_url()}/system/config")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
console.print(data)
|
||||||
|
except httpx.ConnectError:
|
||||||
|
console.print("[red]Cannot connect to daena service[/red]")
|
||||||
|
raise typer.Exit(1) from None
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def version() -> None:
|
||||||
|
"""Show version."""
|
||||||
|
console.print(f"daena v{__version__}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import Field, field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class LoggingConfig(BaseSettings):
|
||||||
|
level: str = "info"
|
||||||
|
format: str = "json"
|
||||||
|
file: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class APIHTTPConfig(BaseSettings):
|
||||||
|
host: str = "127.0.0.1"
|
||||||
|
port: int = 8642
|
||||||
|
workers: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class APIConfig(BaseSettings):
|
||||||
|
http: APIHTTPConfig = APIHTTPConfig()
|
||||||
|
|
||||||
|
|
||||||
|
class SinkRetryConfig(BaseSettings):
|
||||||
|
max_attempts: int = 5
|
||||||
|
min_delay: float = 1.0
|
||||||
|
max_delay: float = 120.0
|
||||||
|
multiplier: float = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
class SinkConfig(BaseSettings):
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
enabled: bool = True
|
||||||
|
retry: SinkRetryConfig = SinkRetryConfig()
|
||||||
|
options: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceConfig(BaseSettings):
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
enabled: bool = True
|
||||||
|
options: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessorConfig(BaseSettings):
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
enabled: bool = True
|
||||||
|
options: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class LMDBConfig(BaseSettings):
|
||||||
|
path: str = "/var/lib/daena/data"
|
||||||
|
map_size: int = 104_857_600
|
||||||
|
max_databases: int = 16
|
||||||
|
|
||||||
|
|
||||||
|
class PersistenceConfig(BaseSettings):
|
||||||
|
backend: str = "lmdb"
|
||||||
|
lmdb: LMDBConfig = LMDBConfig()
|
||||||
|
options: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineConfig(BaseSettings):
|
||||||
|
batch_size: int = 100
|
||||||
|
flush_interval: float = 5.0
|
||||||
|
max_queue_size: int = 10_000
|
||||||
|
|
||||||
|
|
||||||
|
class DaenaConfig(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_prefix="DAENA_", extra="ignore")
|
||||||
|
|
||||||
|
data_dir: str = "/var/lib/daena"
|
||||||
|
log_dir: str = "/var/log/daena"
|
||||||
|
pid_file: str = "/run/daena/daena.pid"
|
||||||
|
|
||||||
|
logging: LoggingConfig = LoggingConfig()
|
||||||
|
api: APIConfig = APIConfig()
|
||||||
|
pipeline: PipelineConfig = PipelineConfig()
|
||||||
|
persistence: PersistenceConfig = PersistenceConfig()
|
||||||
|
|
||||||
|
sources: list[SourceConfig] = Field(default_factory=list)
|
||||||
|
processors: list[ProcessorConfig] = Field(default_factory=list)
|
||||||
|
sinks: list[SinkConfig] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@field_validator("data_dir", "log_dir", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def ensure_trailing(cls, v: str) -> str:
|
||||||
|
return v.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: str | Path | None = None) -> DaenaConfig:
|
||||||
|
config = DaenaConfig()
|
||||||
|
if path:
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
raise FileNotFoundError(f"Configuration file not found: {p}")
|
||||||
|
with open(p) as f:
|
||||||
|
raw: dict[str, Any] = yaml.safe_load(f) or {}
|
||||||
|
merged = config.model_dump()
|
||||||
|
merged.update(raw)
|
||||||
|
config = DaenaConfig(**merged)
|
||||||
|
return config
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class Container:
|
||||||
|
"""Minimal dependency injection container.
|
||||||
|
|
||||||
|
Provides singleton and factory resolution for service components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._instances: dict[str, Any] = {}
|
||||||
|
self._factories: dict[str, callable] = {}
|
||||||
|
|
||||||
|
def register(self, key: str, instance: Any) -> None:
|
||||||
|
self._instances[key] = instance
|
||||||
|
|
||||||
|
def register_factory(self, key: str, factory: callable) -> None:
|
||||||
|
self._factories[key] = factory
|
||||||
|
|
||||||
|
def resolve(self, key: str) -> Any:
|
||||||
|
if key in self._instances:
|
||||||
|
return self._instances[key]
|
||||||
|
if key in self._factories:
|
||||||
|
instance = self._factories[key]()
|
||||||
|
self._instances[key] = instance
|
||||||
|
return instance
|
||||||
|
raise KeyError(f"No component registered for: {key}")
|
||||||
|
|
||||||
|
def has(self, key: str) -> bool:
|
||||||
|
return key in self._instances or key in self._factories
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from daena.config import DaenaConfig
|
||||||
|
from daena.logutil import get_logger
|
||||||
|
from daena.persistence.backend import Backend
|
||||||
|
from daena.persistence.models import StoredRecord
|
||||||
|
from daena.pipeline.base import DeliveryResult, Processor, Sink, Source
|
||||||
|
from daena.pipeline.record import Record
|
||||||
|
from daena.plugins.registry import PluginRegistry
|
||||||
|
|
||||||
|
log = get_logger("daena.pipeline")
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRunner:
|
||||||
|
"""Wraps a Source and handles record ingestion into the pipeline."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source: Source,
|
||||||
|
on_record: Callable[[Record], Any],
|
||||||
|
) -> None:
|
||||||
|
self._source = source
|
||||||
|
self._on_record = on_record
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if hasattr(self._source, "set_callback"):
|
||||||
|
self._source.set_callback(self._on_record)
|
||||||
|
log.info("source_starting", source=self._source.name)
|
||||||
|
await self._source.start()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
log.info("source_stopping", source=self._source.name)
|
||||||
|
await self._source.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class SinkDispatcher:
|
||||||
|
"""Manages delivery of records to a sink with retry logic."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
sink: Sink,
|
||||||
|
backend: Backend,
|
||||||
|
config: DaenaConfig,
|
||||||
|
) -> None:
|
||||||
|
self._sink = sink
|
||||||
|
self._backend = backend
|
||||||
|
self._config = config
|
||||||
|
self._logger = log.bind(sink=sink.name)
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._logger.info("sink_starting")
|
||||||
|
await self._sink.start()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._logger.info("sink_stopping")
|
||||||
|
await self._sink.stop()
|
||||||
|
|
||||||
|
async def deliver(self, record: Record) -> DeliveryResult:
|
||||||
|
return await self._sink.deliver(record)
|
||||||
|
|
||||||
|
|
||||||
|
class Pipeline:
|
||||||
|
"""Orchestrates the full collection → persist → forward data flow.
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
1. Sources emit Records
|
||||||
|
2. Processors transform/filter Records
|
||||||
|
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__(
|
||||||
|
self,
|
||||||
|
config: DaenaConfig,
|
||||||
|
backend: Backend,
|
||||||
|
registry: PluginRegistry,
|
||||||
|
) -> None:
|
||||||
|
self._config = config
|
||||||
|
self._backend = backend
|
||||||
|
self._registry = registry
|
||||||
|
self._sources: list[Source] = []
|
||||||
|
self._processors: list[Processor] = []
|
||||||
|
self._dispatchers: list[SinkDispatcher] = []
|
||||||
|
self._source_runners: list[SourceRunner] = []
|
||||||
|
self._dispatch_task: asyncio.Task[None] | None = None
|
||||||
|
self._running = False
|
||||||
|
self._queue: asyncio.Queue[Record] = asyncio.Queue(maxsize=config.pipeline.max_queue_size)
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._running = True
|
||||||
|
self._build_pipeline()
|
||||||
|
for runner in self._source_runners:
|
||||||
|
await runner.start()
|
||||||
|
for dispatcher in self._dispatchers:
|
||||||
|
await dispatcher.start()
|
||||||
|
self._dispatch_task = asyncio.create_task(self._dispatch_loop())
|
||||||
|
log.info(
|
||||||
|
"pipeline_started",
|
||||||
|
sources=len(self._source_runners),
|
||||||
|
sinks=len(self._dispatchers),
|
||||||
|
processors=len(self._processors),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
if self._dispatch_task:
|
||||||
|
self._dispatch_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await self._dispatch_task
|
||||||
|
for dispatcher in self._dispatchers:
|
||||||
|
await dispatcher.stop()
|
||||||
|
for runner in reversed(self._source_runners):
|
||||||
|
await runner.stop()
|
||||||
|
log.info("pipeline_stopped")
|
||||||
|
|
||||||
|
def _build_pipeline(self) -> None:
|
||||||
|
for sc in self._config.sources:
|
||||||
|
cls = self._registry.get_source(sc.type)
|
||||||
|
if not cls:
|
||||||
|
log.warning("source_type_not_found", type=sc.type)
|
||||||
|
continue
|
||||||
|
source = cls(name=sc.name, config=sc.options)
|
||||||
|
self._sources.append(source)
|
||||||
|
runner = SourceRunner(source, self._ingest)
|
||||||
|
self._source_runners.append(runner)
|
||||||
|
|
||||||
|
for pc in self._config.processors:
|
||||||
|
cls = self._registry.get_processor(pc.type)
|
||||||
|
if not cls:
|
||||||
|
log.warning("processor_type_not_found", type=pc.type)
|
||||||
|
continue
|
||||||
|
proc = cls(name=pc.name, config=pc.options)
|
||||||
|
self._processors.append(proc)
|
||||||
|
|
||||||
|
for sc in self._config.sinks:
|
||||||
|
cls = self._registry.get_sink(sc.type)
|
||||||
|
if not cls:
|
||||||
|
log.warning("sink_type_not_found", type=sc.type)
|
||||||
|
continue
|
||||||
|
sink = cls(name=sc.name, config=sc.options)
|
||||||
|
self._dispatchers.append(SinkDispatcher(sink, self._backend, self._config))
|
||||||
|
|
||||||
|
async def _ingest(self, record: Record) -> None:
|
||||||
|
"""Called by sources. Runs processors and persists."""
|
||||||
|
for proc in self._processors:
|
||||||
|
result = await proc.process(record)
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
record = result
|
||||||
|
|
||||||
|
stored = StoredRecord(
|
||||||
|
record_id=record.record_id,
|
||||||
|
source=record.source,
|
||||||
|
record_type=record.record_type,
|
||||||
|
body=record.body,
|
||||||
|
metadata=record.metadata,
|
||||||
|
destination=record.destination,
|
||||||
|
created_at=record.timestamp,
|
||||||
|
)
|
||||||
|
self._backend.put(stored)
|
||||||
|
log.debug("record_persisted", record_id=record.record_id)
|
||||||
|
|
||||||
|
async def _dispatch_loop(self) -> None:
|
||||||
|
"""Background loop: pull pending records from backend and deliver."""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
await self._dispatch_batch()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
log.exception("dispatch_loop_error")
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
async def _dispatch_batch(self) -> None:
|
||||||
|
pending = self._backend.pending(limit=self._config.pipeline.batch_size)
|
||||||
|
retry = self._backend.ready_for_retry(limit=self._config.pipeline.batch_size)
|
||||||
|
records = pending + retry
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
await asyncio.sleep(self._config.pipeline.flush_interval)
|
||||||
|
return
|
||||||
|
|
||||||
|
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:
|
||||||
|
await self._deliver_with_retry(dispatcher, record)
|
||||||
|
|
||||||
|
if len(records) < self._config.pipeline.batch_size:
|
||||||
|
await asyncio.sleep(self._config.pipeline.flush_interval)
|
||||||
|
|
||||||
|
async def _deliver_with_retry(self, dispatcher: SinkDispatcher, record: Record) -> None:
|
||||||
|
retry_cfg = None
|
||||||
|
for sc in self._config.sinks:
|
||||||
|
if sc.name == dispatcher._sink.name:
|
||||||
|
retry_cfg = sc.retry
|
||||||
|
break
|
||||||
|
|
||||||
|
max_attempts = retry_cfg.max_attempts if retry_cfg else 3
|
||||||
|
min_delay = retry_cfg.min_delay if retry_cfg else 1.0
|
||||||
|
max_delay = retry_cfg.max_delay if retry_cfg else 120.0
|
||||||
|
|
||||||
|
attempt = 0
|
||||||
|
while attempt < max_attempts:
|
||||||
|
attempt += 1
|
||||||
|
try:
|
||||||
|
result = await dispatcher.deliver(record)
|
||||||
|
except Exception as exc:
|
||||||
|
result = DeliveryResult(
|
||||||
|
success=False,
|
||||||
|
record_id=record.record_id,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
self._backend.ack(record.record_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if result.permanent:
|
||||||
|
self._backend.dead(record.record_id, result.error or "permanent failure")
|
||||||
|
return
|
||||||
|
|
||||||
|
backoff = min(min_delay * (2 ** (attempt - 1)), max_delay)
|
||||||
|
stored = self._backend.get(record.record_id)
|
||||||
|
if stored:
|
||||||
|
self._backend.nack(
|
||||||
|
record.record_id,
|
||||||
|
error=result.error or "unknown",
|
||||||
|
retry_delay=backoff,
|
||||||
|
)
|
||||||
|
|
||||||
|
if attempt < max_attempts:
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
|
||||||
|
self._backend.dead(
|
||||||
|
record.record_id,
|
||||||
|
f"max retries ({max_attempts}) exceeded: {result.error}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sources(self) -> list[Source]:
|
||||||
|
return list(self._sources)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sinks(self) -> list[Sink]:
|
||||||
|
return [d._sink for d in self._dispatchers]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def queue_size(self) -> int:
|
||||||
|
return self._queue.qsize()
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from daena.config import DaenaConfig
|
||||||
|
from daena.core.pipeline import Pipeline
|
||||||
|
from daena.logutil import get_logger
|
||||||
|
from daena.persistence.backend import Backend
|
||||||
|
from daena.persistence.lmdb import LMDBBackend
|
||||||
|
from daena.plugins.loader import discover_plugins
|
||||||
|
from daena.plugins.registry import PluginRegistry, get_registry
|
||||||
|
|
||||||
|
log = get_logger("daena.runtime")
|
||||||
|
|
||||||
|
|
||||||
|
class DaenaRuntime:
|
||||||
|
"""Top-level service runtime.
|
||||||
|
|
||||||
|
Owns configuration, persistence backend, pipeline, and lifecycle.
|
||||||
|
Designed to run under asyncio with clean signal-based shutdown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: DaenaConfig) -> None:
|
||||||
|
self._config = config
|
||||||
|
self._registry = get_registry()
|
||||||
|
self._backend: Backend | None = None
|
||||||
|
self._pipeline: Pipeline | None = None
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> DaenaConfig:
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def registry(self) -> PluginRegistry:
|
||||||
|
return self._registry
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backend(self) -> Backend | None:
|
||||||
|
return self._backend
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pipeline(self) -> Pipeline | None:
|
||||||
|
return self._pipeline
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
log.info("runtime_starting")
|
||||||
|
|
||||||
|
discover_plugins()
|
||||||
|
log.info(
|
||||||
|
"plugins_discovered",
|
||||||
|
sources=list(self._registry.list_sources().keys()),
|
||||||
|
processors=list(self._registry.list_processors().keys()),
|
||||||
|
sinks=list(self._registry.list_sinks().keys()),
|
||||||
|
)
|
||||||
|
|
||||||
|
persistence_cfg = self._config.persistence
|
||||||
|
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}")
|
||||||
|
|
||||||
|
self._backend.open()
|
||||||
|
log.info("backend_opened", backend=persistence_cfg.backend)
|
||||||
|
|
||||||
|
self._pipeline = Pipeline(self._config, self._backend, self._registry)
|
||||||
|
await self._pipeline.start()
|
||||||
|
self._running = True
|
||||||
|
log.info("runtime_started")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
log.info("runtime_stopping")
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
if self._pipeline:
|
||||||
|
await self._pipeline.stop()
|
||||||
|
|
||||||
|
if self._backend:
|
||||||
|
self._backend.close()
|
||||||
|
log.info("backend_closed")
|
||||||
|
|
||||||
|
log.info("runtime_stopped")
|
||||||
|
|
||||||
|
async def reload(self) -> None:
|
||||||
|
log.info("runtime_reloading")
|
||||||
|
await self.stop()
|
||||||
|
await self.start()
|
||||||
|
log.info("runtime_reloaded")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class DaenaError(Exception):
|
||||||
|
"""Base exception for all daena errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurationError(DaenaError):
|
||||||
|
"""Configuration loading or validation failed."""
|
||||||
|
|
||||||
|
|
||||||
|
class BackendError(DaenaError):
|
||||||
|
"""Persistence backend operation failed."""
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineError(DaenaError):
|
||||||
|
"""Pipeline processing error."""
|
||||||
|
|
||||||
|
|
||||||
|
class SinkError(DaenaError):
|
||||||
|
"""Sink delivery failed."""
|
||||||
|
|
||||||
|
|
||||||
|
class SinkPermanentError(SinkError):
|
||||||
|
"""Non-retryable sink failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class SourceError(DaenaError):
|
||||||
|
"""Source collection error."""
|
||||||
|
|
||||||
|
|
||||||
|
class PluginError(DaenaError):
|
||||||
|
"""Plugin loading or registration error."""
|
||||||
|
|
||||||
|
|
||||||
|
class RecordInvalidError(DaenaError):
|
||||||
|
"""Record validation failed."""
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(
|
||||||
|
level: str = "info",
|
||||||
|
fmt: str = "json",
|
||||||
|
log_file: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
level_map: dict[str, int] = {
|
||||||
|
"debug": logging.DEBUG,
|
||||||
|
"info": logging.INFO,
|
||||||
|
"warning": logging.WARNING,
|
||||||
|
"error": logging.ERROR,
|
||||||
|
"critical": logging.CRITICAL,
|
||||||
|
}
|
||||||
|
log_level = level_map.get(level.lower(), logging.INFO)
|
||||||
|
|
||||||
|
processors: list[structlog.typing.Processor] = [
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.stdlib.add_logger_name,
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.dev.set_exc_info,
|
||||||
|
structlog.processors.format_exc_info,
|
||||||
|
]
|
||||||
|
|
||||||
|
if fmt == "json":
|
||||||
|
processors.append(structlog.processors.JSONRenderer())
|
||||||
|
else:
|
||||||
|
processors.append(structlog.dev.ConsoleRenderer())
|
||||||
|
|
||||||
|
structlog.configure(
|
||||||
|
processors=processors,
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
context_class=dict,
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
level=log_level,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if log_file:
|
||||||
|
p = Path(log_file)
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handler = logging.FileHandler(str(p))
|
||||||
|
handler.setLevel(log_level)
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
logging.getLogger().addHandler(handler)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
|
||||||
|
return structlog.get_logger(name or __name__)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from daena.persistence.models import RecordState, StoredRecord
|
||||||
|
|
||||||
|
|
||||||
|
class Backend(Protocol):
|
||||||
|
"""Abstract interface for persistence backends.
|
||||||
|
|
||||||
|
This Protocol defines the contract that all storage backends must fulfill.
|
||||||
|
The MVP implementation uses LMDB; future implementations may target
|
||||||
|
NATS JetStream, filesystem-backed stores, or cloud queues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
"""Open or initialize the backend."""
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the backend and release resources."""
|
||||||
|
|
||||||
|
def put(self, record: StoredRecord) -> None:
|
||||||
|
"""Store a new record."""
|
||||||
|
|
||||||
|
def get(self, record_id: str) -> StoredRecord | None:
|
||||||
|
"""Retrieve a record by ID."""
|
||||||
|
|
||||||
|
def update_state(
|
||||||
|
self,
|
||||||
|
record_id: str,
|
||||||
|
state: RecordState,
|
||||||
|
last_error: str | None = None,
|
||||||
|
next_retry_at: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Update a record's delivery state."""
|
||||||
|
|
||||||
|
def pending(
|
||||||
|
self,
|
||||||
|
limit: int = 100,
|
||||||
|
older_than: str | None = None,
|
||||||
|
) -> list[StoredRecord]:
|
||||||
|
"""Return pending records for delivery, oldest first."""
|
||||||
|
|
||||||
|
def ready_for_retry(
|
||||||
|
self,
|
||||||
|
limit: int = 100,
|
||||||
|
now: str | None = None,
|
||||||
|
) -> list[StoredRecord]:
|
||||||
|
"""Return failed records whose retry time has arrived."""
|
||||||
|
|
||||||
|
def ack(self, record_id: str) -> None:
|
||||||
|
"""Mark a record as successfully delivered."""
|
||||||
|
|
||||||
|
def nack(
|
||||||
|
self,
|
||||||
|
record_id: str,
|
||||||
|
error: str,
|
||||||
|
retry_delay: float = 10.0,
|
||||||
|
) -> None:
|
||||||
|
"""Mark delivery failed and schedule retry."""
|
||||||
|
|
||||||
|
def dead(self, record_id: str, error: str) -> None:
|
||||||
|
"""Move a record to the dead-letter queue."""
|
||||||
|
|
||||||
|
def count(self, state: RecordState | None = None) -> int:
|
||||||
|
"""Count records, optionally filtered by state."""
|
||||||
|
|
||||||
|
def list_by_state(
|
||||||
|
self,
|
||||||
|
state: RecordState,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[StoredRecord]:
|
||||||
|
"""List records by state with pagination."""
|
||||||
|
|
||||||
|
def iter_pending(self) -> Iterator[StoredRecord]:
|
||||||
|
"""Iterate over all pending records (for recovery)."""
|
||||||
|
|
||||||
|
def delete_old(
|
||||||
|
self,
|
||||||
|
before: str,
|
||||||
|
state: RecordState | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Delete records older than a timestamp. Returns count."""
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Perform backend-specific maintenance."""
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import lmdb
|
||||||
|
|
||||||
|
from daena.exceptions import BackendError
|
||||||
|
from daena.persistence.models import RecordState, StoredRecord
|
||||||
|
|
||||||
|
_REC_PREFIX = b"rec:"
|
||||||
|
_IDX_PENDING = b"idx:pending:"
|
||||||
|
_IDX_DELIVERED = b"idx:delivered:"
|
||||||
|
_IDX_FAILED = b"idx:failed:"
|
||||||
|
_IDX_DEAD = b"idx:dead:"
|
||||||
|
_IDX_RETRY = b"idx:retry:"
|
||||||
|
|
||||||
|
_PREFIX_MAP: dict[RecordState, bytes] = {
|
||||||
|
RecordState.pending: _IDX_PENDING,
|
||||||
|
RecordState.delivered: _IDX_DELIVERED,
|
||||||
|
RecordState.failed: _IDX_FAILED,
|
||||||
|
RecordState.dead: _IDX_DEAD,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LMDBBackend:
|
||||||
|
"""LMDB-based persistence backend using key-prefix partitioning.
|
||||||
|
|
||||||
|
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__(
|
||||||
|
self,
|
||||||
|
path: str | Path = "/var/lib/daena/data",
|
||||||
|
map_size: int = 104_857_600,
|
||||||
|
max_databases: int = 16,
|
||||||
|
) -> None:
|
||||||
|
self._path = Path(path)
|
||||||
|
self._map_size = map_size
|
||||||
|
self._max_databases = max_databases
|
||||||
|
self._env: lmdb.Environment | None = None
|
||||||
|
|
||||||
|
def _ensure_env(self) -> lmdb.Environment:
|
||||||
|
if self._env is None:
|
||||||
|
raise BackendError("Backend not opened")
|
||||||
|
return self._env
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
self._path.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._env = lmdb.open(str(self._path), self._map_size, self._max_databases)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._env is not None:
|
||||||
|
self._env.close()
|
||||||
|
self._env = None
|
||||||
|
|
||||||
|
def _rec_key(self, record_id: str) -> bytes:
|
||||||
|
return _REC_PREFIX + record_id.encode()
|
||||||
|
|
||||||
|
def _idx_key(self, prefix: bytes, record_id: str) -> bytes:
|
||||||
|
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))
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
return StoredRecord.from_dict(json.loads(raw))
|
||||||
|
|
||||||
|
def put(self, record: StoredRecord) -> None:
|
||||||
|
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:
|
||||||
|
if txn.get(rec_key) is not None:
|
||||||
|
return
|
||||||
|
txn.put(rec_key, value)
|
||||||
|
txn.put(idx_key, b"")
|
||||||
|
|
||||||
|
def get(self, record_id: str) -> StoredRecord | None:
|
||||||
|
env = self._ensure_env()
|
||||||
|
key = self._rec_key(record_id)
|
||||||
|
with env.begin() as txn:
|
||||||
|
value = txn.get(key)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return StoredRecord.from_dict(json.loads(value))
|
||||||
|
|
||||||
|
def update_state(
|
||||||
|
self,
|
||||||
|
record_id: str,
|
||||||
|
state: RecordState,
|
||||||
|
last_error: str | None = None,
|
||||||
|
next_retry_at: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
env = self._ensure_env()
|
||||||
|
rec_key = self._rec_key(record_id)
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
with env.begin(write=True) as txn:
|
||||||
|
raw = txn.get(rec_key)
|
||||||
|
if raw is None:
|
||||||
|
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:
|
||||||
|
record_dict["last_error"] = last_error
|
||||||
|
if next_retry_at is not None:
|
||||||
|
record_dict["next_retry_at"] = next_retry_at
|
||||||
|
txn.put(rec_key, json.dumps(record_dict).encode())
|
||||||
|
|
||||||
|
target_prefix = _PREFIX_MAP.get(state)
|
||||||
|
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:
|
||||||
|
retry_key = _IDX_RETRY + f"{next_retry_at}\x00{record_id}".encode()
|
||||||
|
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()
|
||||||
|
results: list[StoredRecord] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
with env.begin() as txn:
|
||||||
|
cursor = txn.cursor()
|
||||||
|
if not cursor.set_range(_IDX_RETRY):
|
||||||
|
return results
|
||||||
|
for key, _ in cursor:
|
||||||
|
if not key.startswith(_IDX_RETRY):
|
||||||
|
break
|
||||||
|
record_id = self._record_id_from_idx(key, _IDX_RETRY)
|
||||||
|
retry_ts = key[len(_IDX_RETRY) :].decode().split("\x00", 1)[0]
|
||||||
|
if retry_ts > now_str:
|
||||||
|
break
|
||||||
|
if record_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(record_id)
|
||||||
|
record = self._read_record(txn, record_id)
|
||||||
|
if record and record.state == RecordState.failed:
|
||||||
|
results.append(record)
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
return results
|
||||||
|
|
||||||
|
def ack(self, record_id: str) -> None:
|
||||||
|
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()
|
||||||
|
with env.begin() as txn:
|
||||||
|
if state is None:
|
||||||
|
cursor = txn.cursor()
|
||||||
|
count = 0
|
||||||
|
if cursor.set_range(_REC_PREFIX):
|
||||||
|
for key, _ in cursor:
|
||||||
|
if not key.startswith(_REC_PREFIX):
|
||||||
|
break
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
prefix = _PREFIX_MAP.get(state)
|
||||||
|
if prefix is None:
|
||||||
|
return 0
|
||||||
|
cursor = txn.cursor()
|
||||||
|
count = 0
|
||||||
|
if cursor.set_range(prefix):
|
||||||
|
for key, _ in cursor:
|
||||||
|
if not key.startswith(prefix):
|
||||||
|
break
|
||||||
|
record_id = self._record_id_from_idx(key, prefix)
|
||||||
|
record = self._read_record(txn, record_id)
|
||||||
|
if record and record.state == state:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
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,
|
||||||
|
prefix: bytes,
|
||||||
|
state: RecordState,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[StoredRecord]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
env = self._ensure_env()
|
||||||
|
results: list[StoredRecord] = []
|
||||||
|
with env.begin() as txn:
|
||||||
|
cursor = txn.cursor()
|
||||||
|
if not cursor.set_range(prefix):
|
||||||
|
return results
|
||||||
|
skipped = 0
|
||||||
|
for key, _ in cursor:
|
||||||
|
if not key.startswith(prefix):
|
||||||
|
break
|
||||||
|
record_id = self._record_id_from_idx(key, prefix)
|
||||||
|
if record_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(record_id)
|
||||||
|
record = self._read_record(txn, record_id)
|
||||||
|
if record is None or record.state != state:
|
||||||
|
continue
|
||||||
|
if skipped < offset:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
results.append(record)
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
return results
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class RecordState(StrEnum):
|
||||||
|
pending = "pending"
|
||||||
|
delivering = "delivering"
|
||||||
|
delivered = "delivered"
|
||||||
|
failed = "failed"
|
||||||
|
dead = "dead"
|
||||||
|
|
||||||
|
|
||||||
|
class StoredRecord:
|
||||||
|
"""A record as stored in the persistence backend."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
record_id: str | None = None,
|
||||||
|
source: str = "",
|
||||||
|
record_type: str = "",
|
||||||
|
body: dict[str, Any] | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
state: RecordState = RecordState.pending,
|
||||||
|
created_at: str | None = None,
|
||||||
|
updated_at: str | None = None,
|
||||||
|
retry_count: int = 0,
|
||||||
|
next_retry_at: str | None = None,
|
||||||
|
last_error: str | None = None,
|
||||||
|
destination: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.record_id = record_id or str(uuid.uuid4())
|
||||||
|
self.source = source
|
||||||
|
self.record_type = record_type
|
||||||
|
self.body = body or {}
|
||||||
|
self.metadata = metadata or {}
|
||||||
|
self.state = state
|
||||||
|
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
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"record_id": self.record_id,
|
||||||
|
"source": self.source,
|
||||||
|
"record_type": self.record_type,
|
||||||
|
"body": self.body,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> StoredRecord:
|
||||||
|
return cls(
|
||||||
|
record_id=data.get("record_id"),
|
||||||
|
source=data.get("source", ""),
|
||||||
|
record_type=data.get("record_type", ""),
|
||||||
|
body=data.get("body", {}),
|
||||||
|
metadata=data.get("metadata", {}),
|
||||||
|
state=RecordState(data.get("state", "pending")),
|
||||||
|
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"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from daena.pipeline.record import Record
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeliveryResult:
|
||||||
|
success: bool
|
||||||
|
record_id: str
|
||||||
|
error: str | None = None
|
||||||
|
permanent: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class Source(ABC):
|
||||||
|
"""Base class for all record sources.
|
||||||
|
|
||||||
|
Sources collect or receive data and push Records into the pipeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def __init__(self, name: str, config: dict[str, Any] | None = None) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.config = config or {}
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start collecting records. Called during service startup."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop collecting. Called during shutdown."""
|
||||||
|
|
||||||
|
|
||||||
|
class Processor(ABC):
|
||||||
|
"""Base class for record processors.
|
||||||
|
|
||||||
|
Processors transform, filter, or enrich Records passing through the pipeline.
|
||||||
|
Return None to drop the record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def __init__(self, name: str, config: dict[str, Any] | None = None) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.config = config or {}
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def process(self, record: Record) -> Record | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Sink(ABC):
|
||||||
|
"""Base class for record sinks.
|
||||||
|
|
||||||
|
Sinks deliver Records to external destinations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def __init__(self, name: str, config: dict[str, Any] | None = None) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.config = config or {}
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def deliver(self, record: Record) -> DeliveryResult: ...
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
return
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from daena.pipeline.base import Processor
|
||||||
|
from daena.pipeline.record import Record
|
||||||
|
|
||||||
|
|
||||||
|
class PassthroughProcessor(Processor):
|
||||||
|
"""Passes records through without modification."""
|
||||||
|
|
||||||
|
async def process(self, record: Record) -> Record | None:
|
||||||
|
return record
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Record:
|
||||||
|
"""In-memory representation of a collected event or log."""
|
||||||
|
|
||||||
|
source: str
|
||||||
|
record_type: str
|
||||||
|
body: dict[str, Any]
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
destination: str | None = None
|
||||||
|
record_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from daena.pipeline.base import DeliveryResult, Sink
|
||||||
|
from daena.pipeline.record import Record
|
||||||
|
|
||||||
|
|
||||||
|
class DummySink(Sink):
|
||||||
|
"""Logs delivered records without sending anywhere.
|
||||||
|
|
||||||
|
Useful for testing and development.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def deliver(self, record: Record) -> DeliveryResult:
|
||||||
|
return DeliveryResult(success=True, record_id=record.record_id)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from daena.pipeline.base import DeliveryResult, Sink
|
||||||
|
from daena.pipeline.record import Record
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPSink(Sink):
|
||||||
|
"""Delivers records to a remote HTTP endpoint.
|
||||||
|
|
||||||
|
Supports configurable URL, method, headers, timeouts, and batching.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str, config: dict[str, Any] | None = None) -> None:
|
||||||
|
super().__init__(name, config)
|
||||||
|
cfg = config or {}
|
||||||
|
self._url: str = cfg.get("url", "http://localhost:9999/ingest")
|
||||||
|
self._method: str = cfg.get("method", "POST").upper()
|
||||||
|
self._headers: dict[str, str] = cfg.get("headers", {})
|
||||||
|
self._timeout: float = float(cfg.get("timeout", 30.0))
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(self._timeout),
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._client:
|
||||||
|
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="HTTP sink not initialized",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"id": record.record_id,
|
||||||
|
"source": record.source,
|
||||||
|
"type": record.record_type,
|
||||||
|
"timestamp": record.timestamp,
|
||||||
|
"body": record.body,
|
||||||
|
"metadata": record.metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._client.request(
|
||||||
|
method=self._method,
|
||||||
|
url=self._url,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
if response.is_success:
|
||||||
|
return DeliveryResult(success=True, record_id=record.record_id)
|
||||||
|
if 400 <= response.status_code < 500 and response.status_code not in (429,):
|
||||||
|
return DeliveryResult(
|
||||||
|
success=False,
|
||||||
|
record_id=record.record_id,
|
||||||
|
error=f"HTTP {response.status_code}: {response.text[:200]}",
|
||||||
|
permanent=True,
|
||||||
|
)
|
||||||
|
return DeliveryResult(
|
||||||
|
success=False,
|
||||||
|
record_id=record.record_id,
|
||||||
|
error=f"HTTP {response.status_code}: {response.text[:200]}",
|
||||||
|
)
|
||||||
|
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),
|
||||||
|
)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from daena.pipeline.base import Source
|
||||||
|
from daena.pipeline.record import Record
|
||||||
|
|
||||||
|
|
||||||
|
class DummySource(Source):
|
||||||
|
"""Generates test records at a configurable interval.
|
||||||
|
|
||||||
|
Useful for development, testing, and demo purposes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str, config: dict[str, Any] | None = None) -> None:
|
||||||
|
super().__init__(name, config)
|
||||||
|
self._interval: float = float(config.get("interval", 5.0) if config else 5.0)
|
||||||
|
self._message: str = (
|
||||||
|
config.get("message", "daena test event") if config else "daena test event"
|
||||||
|
)
|
||||||
|
self._task: asyncio.Task[None] | None = None
|
||||||
|
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:
|
||||||
|
self._running = True
|
||||||
|
self._task = asyncio.create_task(self._emit_loop())
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await self._task
|
||||||
|
|
||||||
|
async def _emit_loop(self) -> None:
|
||||||
|
counter = 0
|
||||||
|
while self._running:
|
||||||
|
counter += 1
|
||||||
|
record = Record(
|
||||||
|
source=self.name,
|
||||||
|
record_type="test",
|
||||||
|
body={
|
||||||
|
"message": f"{self._message} #{counter}",
|
||||||
|
"counter": counter,
|
||||||
|
},
|
||||||
|
metadata={"dummy": True},
|
||||||
|
)
|
||||||
|
if self._on_record:
|
||||||
|
await self._on_record(record)
|
||||||
|
await asyncio.sleep(self._interval)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib.metadata import entry_points
|
||||||
|
|
||||||
|
from daena.exceptions import PluginError
|
||||||
|
from daena.plugins.registry import get_registry
|
||||||
|
|
||||||
|
SOURCE_EP = "daena.sources"
|
||||||
|
PROCESSOR_EP = "daena.processors"
|
||||||
|
SINK_EP = "daena.sinks"
|
||||||
|
|
||||||
|
|
||||||
|
def discover_plugins() -> None:
|
||||||
|
"""Discover plugins via package entry points and register them."""
|
||||||
|
registry = get_registry()
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for ep in entry_points(group=SOURCE_EP):
|
||||||
|
try:
|
||||||
|
cls = ep.load()
|
||||||
|
registry.register_source(ep.name, cls)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"source {ep.name}: {exc}")
|
||||||
|
|
||||||
|
for ep in entry_points(group=PROCESSOR_EP):
|
||||||
|
try:
|
||||||
|
cls = ep.load()
|
||||||
|
registry.register_processor(ep.name, cls)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"processor {ep.name}: {exc}")
|
||||||
|
|
||||||
|
for ep in entry_points(group=SINK_EP):
|
||||||
|
try:
|
||||||
|
cls = ep.load()
|
||||||
|
registry.register_sink(ep.name, cls)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"sink {ep.name}: {exc}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise PluginError("Plugin loading encountered errors:\n " + "\n ".join(errors))
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class PluginRegistry:
|
||||||
|
"""Registry for plugin factories.
|
||||||
|
|
||||||
|
Maintains separate maps for sources, processors, and sinks.
|
||||||
|
Each entry maps plugin type name → factory callable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._sources: dict[str, type] = {}
|
||||||
|
self._processors: dict[str, type] = {}
|
||||||
|
self._sinks: dict[str, type] = {}
|
||||||
|
|
||||||
|
def register_source(self, name: str, cls: type) -> None:
|
||||||
|
self._sources[name] = cls
|
||||||
|
|
||||||
|
def register_processor(self, name: str, cls: type) -> None:
|
||||||
|
self._processors[name] = cls
|
||||||
|
|
||||||
|
def register_sink(self, name: str, cls: type) -> None:
|
||||||
|
self._sinks[name] = cls
|
||||||
|
|
||||||
|
def get_source(self, name: str) -> type | None:
|
||||||
|
return self._sources.get(name)
|
||||||
|
|
||||||
|
def get_processor(self, name: str) -> type | None:
|
||||||
|
return self._processors.get(name)
|
||||||
|
|
||||||
|
def get_sink(self, name: str) -> type | None:
|
||||||
|
return self._sinks.get(name)
|
||||||
|
|
||||||
|
def list_sources(self) -> dict[str, type]:
|
||||||
|
return dict(self._sources)
|
||||||
|
|
||||||
|
def list_processors(self) -> dict[str, type]:
|
||||||
|
return dict(self._processors)
|
||||||
|
|
||||||
|
def list_sinks(self) -> dict[str, type]:
|
||||||
|
return dict(self._sinks)
|
||||||
|
|
||||||
|
def all_plugins(self) -> dict[str, dict[str, type]]:
|
||||||
|
return {
|
||||||
|
"sources": self.list_sources(),
|
||||||
|
"processors": self.list_processors(),
|
||||||
|
"sinks": self.list_sinks(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_registry = PluginRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> PluginRegistry:
|
||||||
|
return _registry
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from daena.config import DaenaConfig
|
||||||
|
from daena.persistence.lmdb import LMDBBackend
|
||||||
|
from daena.persistence.models import StoredRecord
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_db_path(tmp_path: Path) -> Path:
|
||||||
|
return tmp_path / "lmdb"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lmdb_backend(tmp_db_path: Path) -> Iterator[LMDBBackend]:
|
||||||
|
backend = LMDBBackend(
|
||||||
|
path=str(tmp_db_path),
|
||||||
|
map_size=10_485_760,
|
||||||
|
)
|
||||||
|
backend.open()
|
||||||
|
yield backend
|
||||||
|
backend.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_record() -> StoredRecord:
|
||||||
|
return StoredRecord(
|
||||||
|
source="test_source",
|
||||||
|
record_type="test",
|
||||||
|
body={"message": "hello"},
|
||||||
|
metadata={"env": "test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def minimal_config() -> DaenaConfig:
|
||||||
|
return DaenaConfig()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_file(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "daena.yaml"
|
||||||
|
data = {
|
||||||
|
"data_dir": "/tmp/daena_data",
|
||||||
|
"logging": {"level": "debug", "format": "json"},
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": "test",
|
||||||
|
"type": "dummy",
|
||||||
|
"enabled": True,
|
||||||
|
"options": {"interval": 1.0},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sinks": [
|
||||||
|
{
|
||||||
|
"name": "output",
|
||||||
|
"type": "dummy",
|
||||||
|
"enabled": True,
|
||||||
|
"options": {},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with open(path, "w") as f:
|
||||||
|
yaml.dump(data, f)
|
||||||
|
return path
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from daena.config import DaenaConfig, load_config
|
||||||
|
|
||||||
|
|
||||||
|
def test_minimal_config_has_defaults() -> None:
|
||||||
|
config = DaenaConfig()
|
||||||
|
assert config.data_dir == "/var/lib/daena"
|
||||||
|
assert config.logging.level == "info"
|
||||||
|
assert config.api.http.port == 8642
|
||||||
|
assert config.pipeline.batch_size == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_from_file(config_file: Path) -> None:
|
||||||
|
config = load_config(str(config_file))
|
||||||
|
assert config.data_dir == "/tmp/daena_data"
|
||||||
|
assert config.logging.level == "debug"
|
||||||
|
assert len(config.sources) == 1
|
||||||
|
assert config.sources[0].name == "test"
|
||||||
|
assert config.sources[0].type == "dummy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_nonexistent_file() -> None:
|
||||||
|
try:
|
||||||
|
load_config("/nonexistent/daena.yaml")
|
||||||
|
raise AssertionError("Expected FileNotFoundError") # noqa: B011
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_can_set_persistence_backend() -> None:
|
||||||
|
config = DaenaConfig()
|
||||||
|
assert config.persistence.backend == "lmdb"
|
||||||
|
assert config.persistence.lmdb.map_size == 104_857_600
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_source_options(minimal_config: DaenaConfig) -> None:
|
||||||
|
cfg = minimal_config
|
||||||
|
assert cfg.sources == []
|
||||||
|
assert cfg.processors == []
|
||||||
|
assert cfg.sinks == []
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from daena.persistence.lmdb import LMDBBackend
|
||||||
|
from daena.persistence.models import RecordState, StoredRecord
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_and_get(lmdb_backend: LMDBBackend, sample_record: StoredRecord) -> None:
|
||||||
|
lmdb_backend.put(sample_record)
|
||||||
|
retrieved = lmdb_backend.get(sample_record.record_id)
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.record_id == sample_record.record_id
|
||||||
|
assert retrieved.source == "test_source"
|
||||||
|
assert retrieved.body == {"message": "hello"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_returns_new_records(
|
||||||
|
lmdb_backend: LMDBBackend, sample_record: StoredRecord
|
||||||
|
) -> None:
|
||||||
|
lmdb_backend.put(sample_record)
|
||||||
|
pending = lmdb_backend.pending(limit=10)
|
||||||
|
assert len(pending) == 1
|
||||||
|
assert pending[0].record_id == sample_record.record_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_ack(lmdb_backend: LMDBBackend, sample_record: StoredRecord) -> None:
|
||||||
|
lmdb_backend.put(sample_record)
|
||||||
|
lmdb_backend.ack(sample_record.record_id)
|
||||||
|
retrieved = lmdb_backend.get(sample_record.record_id)
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.state == RecordState.delivered
|
||||||
|
pending = lmdb_backend.pending()
|
||||||
|
assert all(r.record_id != sample_record.record_id for r in pending)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nack(lmdb_backend: LMDBBackend, sample_record: StoredRecord) -> None:
|
||||||
|
lmdb_backend.put(sample_record)
|
||||||
|
lmdb_backend.nack(sample_record.record_id, error="test error", retry_delay=1.0)
|
||||||
|
retrieved = lmdb_backend.get(sample_record.record_id)
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.state == RecordState.failed
|
||||||
|
assert retrieved.last_error == "test error"
|
||||||
|
assert retrieved.next_retry_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dead(lmdb_backend: LMDBBackend, sample_record: StoredRecord) -> None:
|
||||||
|
lmdb_backend.put(sample_record)
|
||||||
|
lmdb_backend.dead(sample_record.record_id, error="permanent failure")
|
||||||
|
retrieved = lmdb_backend.get(sample_record.record_id)
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.state == RecordState.dead
|
||||||
|
|
||||||
|
|
||||||
|
def test_count(lmdb_backend: LMDBBackend, sample_record: StoredRecord) -> None:
|
||||||
|
assert lmdb_backend.count() == 0
|
||||||
|
lmdb_backend.put(sample_record)
|
||||||
|
assert lmdb_backend.count() == 1
|
||||||
|
assert lmdb_backend.count(RecordState.pending) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_by_state(lmdb_backend: LMDBBackend, sample_record: StoredRecord) -> None:
|
||||||
|
lmdb_backend.put(sample_record)
|
||||||
|
pending = lmdb_backend.list_by_state(RecordState.pending)
|
||||||
|
assert len(pending) == 1
|
||||||
|
lmdb_backend.ack(sample_record.record_id)
|
||||||
|
delivered = lmdb_backend.list_by_state(RecordState.delivered)
|
||||||
|
assert len(delivered) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_old(lmdb_backend: LMDBBackend, sample_record: StoredRecord) -> None:
|
||||||
|
lmdb_backend.put(sample_record)
|
||||||
|
lmdb_backend.ack(sample_record.record_id)
|
||||||
|
before = "2100-01-01T00:00:00"
|
||||||
|
removed = lmdb_backend.delete_old(before, state=RecordState.delivered)
|
||||||
|
assert removed == 1
|
||||||
|
assert lmdb_backend.get(sample_record.record_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_iter_pending(lmdb_backend: LMDBBackend, sample_record: StoredRecord) -> None:
|
||||||
|
lmdb_backend.put(sample_record)
|
||||||
|
records = list(lmdb_backend.iter_pending())
|
||||||
|
assert len(records) == 1
|
||||||
|
assert records[0].record_id == sample_record.record_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_state(lmdb_backend: LMDBBackend, sample_record: StoredRecord) -> None:
|
||||||
|
lmdb_backend.put(sample_record)
|
||||||
|
lmdb_backend.update_state(sample_record.record_id, RecordState.failed, last_error="oops")
|
||||||
|
retrieved = lmdb_backend.get(sample_record.record_id)
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.state == RecordState.failed
|
||||||
|
assert retrieved.last_error == "oops"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stored_record_to_from_dict(sample_record: StoredRecord) -> None:
|
||||||
|
d = sample_record.to_dict()
|
||||||
|
restored = StoredRecord.from_dict(d)
|
||||||
|
assert restored.record_id == sample_record.record_id
|
||||||
|
assert restored.source == sample_record.source
|
||||||
|
assert restored.body == sample_record.body
|
||||||
|
assert restored.state == sample_record.state
|
||||||
|
|
||||||
|
|
||||||
|
def test_ready_for_retry(lmdb_backend: LMDBBackend, sample_record: StoredRecord) -> None:
|
||||||
|
lmdb_backend.put(sample_record)
|
||||||
|
lmdb_backend.nack(sample_record.record_id, "retry later", retry_delay=0)
|
||||||
|
ready = lmdb_backend.ready_for_retry(limit=10)
|
||||||
|
assert len(ready) == 1
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from daena.pipeline.base import DeliveryResult, Processor
|
||||||
|
from daena.pipeline.processors.passthrough import PassthroughProcessor
|
||||||
|
from daena.pipeline.record import Record
|
||||||
|
from daena.pipeline.sinks.dummy import DummySink
|
||||||
|
from daena.pipeline.sinks.http import HTTPSink
|
||||||
|
from daena.pipeline.sources.dummy import DummySource
|
||||||
|
|
||||||
|
|
||||||
|
def test_dummy_source_config() -> None:
|
||||||
|
source = DummySource(
|
||||||
|
name="test",
|
||||||
|
config={"interval": 1.0, "message": "hello"},
|
||||||
|
)
|
||||||
|
assert source.name == "test"
|
||||||
|
assert source._interval == 1.0
|
||||||
|
assert source._message == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dummy_sink_delivery() -> None:
|
||||||
|
sink = DummySink(name="test")
|
||||||
|
record = Record(source="test", record_type="test", body={"key": "value"})
|
||||||
|
result = await sink.deliver(record)
|
||||||
|
assert isinstance(result, DeliveryResult)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.record_id == record.record_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_passthrough_processor() -> None:
|
||||||
|
proc = PassthroughProcessor(name="test")
|
||||||
|
record = Record(source="test", record_type="test", body={"key": "value"})
|
||||||
|
result = await proc.process(record)
|
||||||
|
assert result is not None
|
||||||
|
assert result.record_id == record.record_id
|
||||||
|
assert result.body == {"key": "value"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_sink_requires_url() -> None:
|
||||||
|
sink = HTTPSink(name="test", config={"url": "http://example.com/ingest"})
|
||||||
|
assert sink._url == "http://example.com/ingest"
|
||||||
|
assert sink._method == "POST"
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_has_id() -> None:
|
||||||
|
r1 = Record(source="s1", record_type="t1", body={})
|
||||||
|
r2 = Record(source="s2", record_type="t2", body={})
|
||||||
|
assert r1.record_id != r2.record_id
|
||||||
|
assert r1.timestamp is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_processor_drop_record() -> None:
|
||||||
|
class DropProcessor(Processor):
|
||||||
|
async def process(self, record: Record) -> Record | None: # noqa: ARG002
|
||||||
|
return None
|
||||||
|
|
||||||
|
proc = DropProcessor(name="dropper")
|
||||||
|
record = Record(source="test", record_type="test", body={})
|
||||||
|
result = await proc.process(record)
|
||||||
|
assert result is None
|
||||||
Reference in New Issue
Block a user