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:
ahp
2026-07-15 09:37:43 +03:30
parent ab344efb8c
commit b3243427b7
45 changed files with 2400 additions and 2 deletions
View File
+70
View File
@@ -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
+43
View File
@@ -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 == []
+107
View File
@@ -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
+65
View File
@@ -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