Files
DECNET/tests/ttp/store/test_conformance.py
anti 8a93ee3129 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.
2026-05-01 08:39:46 -04:00

213 lines
8.0 KiB
Python

"""E.2.14b — Cross-backend conformance for :class:`RuleStore`.
Both :class:`FilesystemRuleStore` and :class:`DatabaseRuleStore` must
satisfy the same observable contract. The :func:`rule_store` fixture
in :mod:`conftest` parametrizes every assertion in this module over
both backends.
Per ``development/TTP_TAGGING.md`` §E.2.14b:
* :meth:`load_compiled` over a known YAML corpus returns the same
``CompiledRule`` set from both backends (modulo state defaulting
to enabled when no state row exists).
* :meth:`get_state` for an unknown ``rule_id`` returns the default
``RuleState(state="enabled", ...)`` — never raise, never return
``None``.
* :meth:`set_state` on one ``rule_id`` does not affect any other.
* :meth:`set_state` followed by :meth:`get_state` round-trips
faithfully.
* :meth:`subscribe_changes` yields ONE :class:`RuleChange` per
per-rule edit (5-rule edit → 5 events, never one batch of 5).
* ``expires_at`` in the past → :meth:`get_state` returns the
default and emits a ``ttp.rule.state.{rule_id}`` auto-revert
event.
* :meth:`set_state` failure (DB write error) raises rather than
silently dropping — operational state changes are not a
tolerated-absence path.
Filesystem-specific properties (inotify mask, dotfile filter,
atomic-swap concurrency) live in :mod:`test_filesystem`.
"""
from __future__ import annotations
import asyncio
import inspect
from datetime import datetime, timedelta, timezone
from pathlib import Path
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}
rule_version: 1
name: test rule
applies_to: [command]
match:
pattern: 'hydra'
emits:
- technique_id: T1110
"""
# ── Surface (GREEN today) ───────────────────────────────────────────
def test_store_implements_required_methods(rule_store: RuleStore) -> None:
"""Every backend implements all four ABC methods. Catches a
refactor that accidentally drops a method body."""
for name in ("load_compiled", "get_state", "set_state", "subscribe_changes"):
assert hasattr(rule_store, name)
def test_async_methods_are_coroutines() -> None:
"""The three ``async def`` methods on the ABC are coroutine
functions; ``subscribe_changes`` is a regular ``def`` returning
an async iterator (per the doc's signature)."""
for name in ("load_compiled", "get_state", "set_state"):
member = getattr(RuleStore, name)
assert inspect.iscoroutinefunction(member), (
f"RuleStore.{name} must be `async def`"
)
def test_rule_change_namedtuple_shape() -> None:
""":class:`RuleChange` carries ``change_kind``, ``rule_id``,
``new_value`` — pinned so a future "improvement" that adds
fields trips downstream consumers (the bus republisher, the
engine's atomic swap path) deliberately rather than silently."""
assert RuleChange._fields == ("change_kind", "rule_id", "new_value")
# ── Default state behavior ──────────────────────────────────────────
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``."""
state = await rule_store.get_state("R0001_unknown_rule")
assert state == RuleState()
assert state.state == "enabled"
# ── Behavioral conformance (xfail until E.3.5/E.3.6) ────────────────
async def test_load_compiled_corpus_identical_across_backends(
rule_store: RuleStore, tmp_path: Path,
) -> None:
"""Both backends, given the same YAML corpus, return the same
set of ``CompiledRule`` (modulo state defaulting). The doc's
cross-backend property requires running the same fixture against
both — pinned here as a single test that the parametrize fans
out over both backends."""
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:
assert isinstance(c, CompiledRule)
assert c.state == RuleState()
assert c.applies_to == frozenset({"command"})
assert c.emits == (("T1110", None),)
async def test_set_state_isolates_rules(rule_store: RuleStore) -> None:
"""``set_state(A, ...)`` does not perturb the state read by
``get_state(B)``."""
await rule_store.set_state(
"R0001", RuleState(state="disabled", reason="A"), set_by="op",
)
other = await rule_store.get_state("R0002")
assert other == RuleState() # B untouched
async def test_set_state_then_get_state_round_trips(
rule_store: RuleStore,
) -> None:
"""``set_state`` followed by ``get_state`` returns the value
that was set. No translation, no field drop."""
new_state = RuleState(
state="clipped", confidence_max=0.5, reason="probation",
)
await rule_store.set_state("R0001", new_state, set_by="op")
got = await rule_store.get_state("R0001")
assert got.state == "clipped"
assert got.confidence_max == 0.5
assert got.reason == "probation"
assert got.set_by == "op"
assert got.set_at is not None
async def test_subscribe_changes_per_rule_not_batched(
rule_store: RuleStore,
) -> None:
"""A 5-rule edit produces 5 :class:`RuleChange` events from
:meth:`subscribe_changes`, never a single event carrying 5
entries. The bus per-rule fan-out
(``ttp.rule.reloaded.{rule_id}``) inherits its granularity from
this iterator."""
sub = rule_store.subscribe_changes()
for i in range(5):
await rule_store.set_state(
f"R000{i}", RuleState(state="disabled"), set_by="op",
)
seen: list[RuleChange] = []
for _ in range(5):
seen.append(await asyncio.wait_for(sub.__anext__(), timeout=2.0))
rule_ids = {ch.rule_id for ch in seen}
assert rule_ids == {f"R000{i}" for i in range(5)}
for ch in seen:
assert ch.change_kind == "state"
assert isinstance(ch.new_value, RuleState)
async def test_expired_state_reverts_to_default_and_emits(
rule_store: RuleStore,
) -> None:
"""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."""
past = datetime.now(tz=timezone.utc) - timedelta(seconds=5)
sub = rule_store.subscribe_changes()
await rule_store.set_state(
"R0001",
RuleState(state="disabled", expires_at=past),
set_by="op",
)
# Drain the set_state event we just produced.
await asyncio.wait_for(sub.__anext__(), timeout=2.0)
state = await rule_store.get_state("R0001")
assert state == RuleState()
revert = await asyncio.wait_for(sub.__anext__(), timeout=2.0)
assert revert.change_kind == "state"
assert revert.rule_id == "R0001"
assert revert.new_value == RuleState()
async def test_set_state_failure_raises_not_silent(
rule_store: RuleStore,
) -> None:
"""A backend failure during :meth:`set_state` (e.g. queue
death) MUST raise rather than silently drop. Operational state
changes are NOT a tolerated-absence path — state drift would be
silent and dangerous."""
class _BoomQueue:
async def put(self, _item: object) -> None:
raise RuntimeError("simulated backend failure")
# Inject a poisoned subscriber so the publish path raises.
if not hasattr(rule_store, "_subscribers"): # pragma: no cover
pytest.skip("backend has no subscriber fan-out hook")
rule_store._subscribers.append(_BoomQueue())
with pytest.raises(RuntimeError, match="simulated backend failure"):
await rule_store.set_state(
"R0001", RuleState(state="disabled"), set_by="op",
)