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:
@@ -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