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:
@@ -40,6 +40,8 @@ import pytest
|
||||
from decnet.ttp.impl.rule_engine import CompiledRule
|
||||
from decnet.ttp.store.base import RuleChange, RuleState, RuleStore
|
||||
|
||||
from .conftest import seed_rule
|
||||
|
||||
|
||||
_RULE_YAML = """\
|
||||
rule_id: {rule_id}
|
||||
@@ -53,18 +55,6 @@ emits:
|
||||
"""
|
||||
|
||||
|
||||
def _xfail_db_until_e36(rule_store: RuleStore) -> None:
|
||||
"""Skip a parametrized run for the database backend.
|
||||
|
||||
The conformance contract is identical across backends, but the
|
||||
DB backend's persistence path lands at E.3.6. Per-test xfail
|
||||
rather than a module-level skip so the FS-backend run still
|
||||
exercises the assertion today.
|
||||
"""
|
||||
if type(rule_store).__name__ == "DatabaseRuleStore":
|
||||
pytest.xfail("impl phase E.3.6 — DatabaseRuleStore not implemented")
|
||||
|
||||
|
||||
# ── Surface (GREEN today) ───────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -99,14 +89,7 @@ def test_rule_change_namedtuple_shape() -> None:
|
||||
|
||||
async def test_get_state_unknown_returns_default(rule_store: RuleStore) -> None:
|
||||
"""``get_state`` for a never-set ``rule_id`` returns the default
|
||||
``RuleState`` — never raises, never returns ``None``.
|
||||
|
||||
GREEN for :class:`FilesystemRuleStore` (the impl already returns
|
||||
``RuleState()`` for an empty cache; covered in the contract
|
||||
file). xfail for :class:`DatabaseRuleStore` until E.3.6 lands.
|
||||
"""
|
||||
if type(rule_store).__name__ == "DatabaseRuleStore":
|
||||
pytest.xfail("impl phase E.3.6 — DatabaseRuleStore.get_state")
|
||||
``RuleState`` — never raises, never returns ``None``."""
|
||||
state = await rule_store.get_state("R0001_unknown_rule")
|
||||
assert state == RuleState()
|
||||
assert state.state == "enabled"
|
||||
@@ -123,14 +106,8 @@ async def test_load_compiled_corpus_identical_across_backends(
|
||||
cross-backend property requires running the same fixture against
|
||||
both — pinned here as a single test that the parametrize fans
|
||||
out over both backends."""
|
||||
_xfail_db_until_e36(rule_store)
|
||||
rules_dir: Path = rule_store._rules_dir # type: ignore[attr-defined]
|
||||
(rules_dir / "R0001.yaml").write_text(
|
||||
_RULE_YAML.format(rule_id="R0001"), encoding="utf-8",
|
||||
)
|
||||
(rules_dir / "R0002.yaml").write_text(
|
||||
_RULE_YAML.format(rule_id="R0002"), encoding="utf-8",
|
||||
)
|
||||
await seed_rule(rule_store, "R0001", _RULE_YAML.format(rule_id="R0001"))
|
||||
await seed_rule(rule_store, "R0002", _RULE_YAML.format(rule_id="R0002"))
|
||||
compiled = await rule_store.load_compiled()
|
||||
assert {c.rule_id for c in compiled} == {"R0001", "R0002"}
|
||||
for c in compiled:
|
||||
@@ -143,7 +120,6 @@ async def test_load_compiled_corpus_identical_across_backends(
|
||||
async def test_set_state_isolates_rules(rule_store: RuleStore) -> None:
|
||||
"""``set_state(A, ...)`` does not perturb the state read by
|
||||
``get_state(B)``."""
|
||||
_xfail_db_until_e36(rule_store)
|
||||
await rule_store.set_state(
|
||||
"R0001", RuleState(state="disabled", reason="A"), set_by="op",
|
||||
)
|
||||
@@ -156,7 +132,6 @@ async def test_set_state_then_get_state_round_trips(
|
||||
) -> None:
|
||||
"""``set_state`` followed by ``get_state`` returns the value
|
||||
that was set. No translation, no field drop."""
|
||||
_xfail_db_until_e36(rule_store)
|
||||
new_state = RuleState(
|
||||
state="clipped", confidence_max=0.5, reason="probation",
|
||||
)
|
||||
@@ -177,7 +152,6 @@ async def test_subscribe_changes_per_rule_not_batched(
|
||||
entries. The bus per-rule fan-out
|
||||
(``ttp.rule.reloaded.{rule_id}``) inherits its granularity from
|
||||
this iterator."""
|
||||
_xfail_db_until_e36(rule_store)
|
||||
sub = rule_store.subscribe_changes()
|
||||
for i in range(5):
|
||||
await rule_store.set_state(
|
||||
@@ -199,7 +173,6 @@ async def test_expired_state_reverts_to_default_and_emits(
|
||||
"""A ``RuleState`` with ``expires_at`` in the past returns the
|
||||
default from :meth:`get_state` AND emits a
|
||||
``ttp.rule.state.{rule_id}`` auto-revert event."""
|
||||
_xfail_db_until_e36(rule_store)
|
||||
past = datetime.now(tz=timezone.utc) - timedelta(seconds=5)
|
||||
sub = rule_store.subscribe_changes()
|
||||
await rule_store.set_state(
|
||||
@@ -224,7 +197,6 @@ async def test_set_state_failure_raises_not_silent(
|
||||
death) MUST raise rather than silently drop. Operational state
|
||||
changes are NOT a tolerated-absence path — state drift would be
|
||||
silent and dangerous."""
|
||||
_xfail_db_until_e36(rule_store)
|
||||
|
||||
class _BoomQueue:
|
||||
async def put(self, _item: object) -> None:
|
||||
|
||||
Reference in New Issue
Block a user