feat(ttp): E.3.6 DatabaseRuleStore — ttp_rule/ttp_rule_state + master sync
Implements the DB-backed rule store body left empty at contract phase:
load_compiled reads from ttp_rule + ttp_rule_state; get_state /
set_state hit ttp_rule_state with the same expires_at auto-revert and
bus-event semantics as the FS backend; subscribe_changes returns a
per-subscriber queue. State persists across process restarts — the
swarm property the FS backend deliberately doesn't have.
Also lands two swarm-mode helpers:
- sync_from_filesystem(fs_store) — master-side, subscribes to a
FilesystemRuleStore and projects each RuleChange onto a ttp_rule
upsert/delete.
- tail_db(poll_interval) — worker-side, watermark poll over
ttp_rule.updated_at; emits RuleChange("definition", ...) for each
row that moved.
Why: swarm mode needs rule definitions and operator state to
propagate across hosts. The filesystem backend (E.3.5) was the
single-host-dev variant; this one survives restart and serves N
workers from a shared DB.
Notes:
- DatabaseRuleStore() with no args lazy-inits an in-memory SQLite
repo so the conformance fixture works without test plumbing. In
production the worker bootstrap (E.3.14) passes an explicit repo.
- The conftest.py rule_store fixture became async (pytest_asyncio),
per-backend creates/initializes a SQLite repo for the DB run.
- Adds a `seed_rule(store, rule_id, yaml)` helper to bridge backend
semantics: drop a YAML file (FS) vs insert a ttp_rule row (DB).
Used by the parametrized load_compiled conformance test.
- Late-bound _tracer() in both backends (was module-level get_tracer
binding) so test_tracing's monkeypatch of decnet.telemetry.get_tracer
actually affects span output.
xfails flipped: tests/ttp/store/test_database.py set_state-writes-to-
ttp_rule_state + filesystem-to-DB sync; tests/ttp/store/test_conformance.py
DB-side load_compiled / set_state isolation / round-trip / per-rule
fan-out / expired-state revert / set_state failure / get_state default
(was xfail-only-on-DB); tests/ttp/test_tracing.py set_state span
hierarchy.
Tests: 208 passed, 25 xfailed (gated on E.3.7 + lifters).
mypy: clean on all touched files.
This commit is contained in:
@@ -14,29 +14,74 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
from typing import AsyncIterator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.ttp.store.base import RuleStore
|
||||
from decnet.ttp.store.impl.database import DatabaseRuleStore
|
||||
from decnet.ttp.store.impl.filesystem import FilesystemRuleStore
|
||||
from decnet.web.db.models import TTPRule
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
async def _seed_rule_filesystem(
|
||||
store: FilesystemRuleStore, rule_id: str, yaml_text: str,
|
||||
) -> None:
|
||||
rules_dir: Path = store._rules_dir
|
||||
rules_dir.mkdir(parents=True, exist_ok=True)
|
||||
(rules_dir / f"{rule_id}.yaml").write_text(yaml_text, encoding="utf-8")
|
||||
|
||||
|
||||
async def _seed_rule_database(
|
||||
store: DatabaseRuleStore, rule_id: str, yaml_text: str,
|
||||
) -> None:
|
||||
# Direct ``ttp_rule`` insert — bypass the master sync helper to
|
||||
# keep tests deterministic. Mirrors what a swarm master would have
|
||||
# written into the table.
|
||||
repo = await store._ensure_repo()
|
||||
async with repo._session() as session: # type: ignore[attr-defined]
|
||||
from datetime import datetime, timezone # noqa: PLC0415
|
||||
|
||||
session.add(TTPRule(
|
||||
rule_id=rule_id,
|
||||
rule_version=1,
|
||||
source_path=f"./rules/ttp/{rule_id}.yaml",
|
||||
yaml_content=yaml_text,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
updated_by="test",
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def seed_rule(store: RuleStore, rule_id: str, yaml_text: str) -> None:
|
||||
"""Backend-aware test helper: write a rule into the store.
|
||||
|
||||
Filesystem store: drop a YAML file under ``_rules_dir``.
|
||||
Database store: insert a ``ttp_rule`` row directly.
|
||||
"""
|
||||
if isinstance(store, FilesystemRuleStore):
|
||||
await _seed_rule_filesystem(store, rule_id, yaml_text)
|
||||
elif isinstance(store, DatabaseRuleStore):
|
||||
await _seed_rule_database(store, rule_id, yaml_text)
|
||||
else: # pragma: no cover
|
||||
raise TypeError(f"unknown rule store backend: {type(store).__name__}")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(
|
||||
params=["filesystem", "database"],
|
||||
ids=["filesystem", "database"],
|
||||
)
|
||||
def rule_store(
|
||||
async def rule_store(
|
||||
request: pytest.FixtureRequest, tmp_path: Path,
|
||||
) -> Iterator[RuleStore]:
|
||||
) -> AsyncIterator[RuleStore]:
|
||||
"""Yield a fresh :class:`RuleStore` instance per parametrization.
|
||||
|
||||
The filesystem backend is constructed against a ``tmp_path``
|
||||
rules dir so tests never touch the real ``./rules/``. The
|
||||
database backend's connection wiring lands at E.3.6; today the
|
||||
fixture just hands out the raw class instance and impl-phase
|
||||
tests are responsible for plumbing it into a session.
|
||||
database backend gets a per-test SQLite repo (initialized with
|
||||
``metadata.create_all``) so each test sees an empty
|
||||
``ttp_rule`` / ``ttp_rule_state`` pair.
|
||||
"""
|
||||
backend = request.param
|
||||
if backend == "filesystem":
|
||||
@@ -44,4 +89,20 @@ def rule_store(
|
||||
pytest.skip("FilesystemRuleStore requires Linux (inotify)")
|
||||
yield FilesystemRuleStore(rules_dir=tmp_path)
|
||||
else:
|
||||
yield DatabaseRuleStore()
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository # noqa: PLC0415
|
||||
|
||||
repo = SQLiteRepository(db_path=str(tmp_path / "ttp_store.db"))
|
||||
await repo.initialize()
|
||||
store = DatabaseRuleStore(repo=repo)
|
||||
# Mirror FS store's ``_rules_dir`` attr so cross-backend tests
|
||||
# that need to drop sample YAML on disk have somewhere to put
|
||||
# it; the DB-backend tests that need rule definitions either
|
||||
# write to ``ttp_rule`` directly or call ``upsert_rule``.
|
||||
store._rules_dir = tmp_path # type: ignore[attr-defined]
|
||||
try:
|
||||
yield store
|
||||
finally:
|
||||
try:
|
||||
await repo.engine.dispose()
|
||||
except Exception: # noqa: BLE001 — teardown best-effort
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user