Third and fourth TTP-tagging contract commits, plus a scoped subset
of the E.2.4 conformance tests covering the contract surface shipped
here (full hypothesis-fuzz suite still lands with E.2.4).
E.1.3 — decnet/ttp/base.py
- TaggerEvent NamedTuple: source_kind, source_id, attacker_uuid,
identity_uuid, session_id, decky_id, opaque payload.
- Tagger(ABC) with abstract async tag(); class-level name and
HANDLES: frozenset[str] (default empty so a misconfigured subclass
is loudly idle, not loudly noisy).
- TolerantTagger(Tagger): concrete tag() wraps abstract _tag_impl()
in try/except Exception (deliberately not BaseException — so
KeyboardInterrupt / SystemExit / asyncio.CancelledError propagate
and the worker can shut down cleanly). Swallowed exceptions log
at WARNING with exc_info, never ERROR — absence is the steady
state, not a bug. Subclasses override _tag_impl, never tag — the
tolerance contract is enforced in the base class, not on trust.
- KNOWN_SOURCE_KINDS: Final[frozenset[str]] enumerating every
source_kind a producer is allowed to emit. Closed-by-enumeration
at the runtime layer; the composite tagger keys its WARNING/INFO
bridge off this constant to surface the silent-drop trap from
the design doc (lines 160–195).
E.1.4 — decnet/ttp/factory.py
- get_tagger() reads DECNET_TTP_TAGGER_TYPE (default 'composite');
unknown values raise ValueError with the known-list. Mirrors
decnet.intel.factory and decnet.clustering.factory.
- _KNOWN = ('composite',). Per-lifter classes (E.1.6) are children
of the composite, not standalone tagger types.
- CompositeTagger(Tagger): pre-computes a dict[str, list[Tagger]]
dispatch index from each lifter's HANDLES; fans events out
concurrently with asyncio.gather and concatenates results.
Empty lifters=[] is the legal contract-phase state — E.1.6
wires the real lifters in.
- Unhandled-event observability: source_kind in KNOWN_SOURCE_KINDS
but no lifter claims it -> WARNING once per kind per process
(missed E.1.6 update). Unknown kind -> INFO once per kind per
process (future-feature telemetry, by design). Per-process dedup
via plain set; E.1.6 may swap in a proper rate-limiter once
production traffic shapes are known.
Tests — tests/ttp/test_base.py, tests/ttp/test_factory.py
- Tagger / TolerantTagger abstractness, missing-tag-impl rejection,
WARNING-not-ERROR log level, propagation of KeyboardInterrupt /
SystemExit / asyncio.CancelledError.
- Factory env-var routing, unknown-name ValueError, dispatch-index
correctness, only-claiming-lifter invocation, WARNING-once for
known-but-unclaimed kinds, INFO-once for unknown kinds, result
concatenation across lifters.
Mypy clean under .311/bin/mypy --ignore-missing-imports.
122 lines
4.6 KiB
Python
122 lines
4.6 KiB
Python
"""Tagger factory + composite tagger.
|
||
|
||
Contract step E.1.4 of ``development/TTP_TAGGING.md``. Mirrors the
|
||
provider-subpackage convention used by :mod:`decnet.intel.factory` and
|
||
:mod:`decnet.clustering.factory`: callers obtain the active tagger via
|
||
:func:`get_tagger` rather than instantiating a concrete class directly.
|
||
|
||
The composite tagger is the only shippable tagger type — per-lifter
|
||
classes (E.1.6) are children of the composite, not standalone tagger
|
||
``DECNET_TTP_TAGGER_TYPE`` values.
|
||
|
||
Configuration:
|
||
|
||
* ``DECNET_TTP_TAGGER_TYPE`` — which tagger to instantiate. Default
|
||
``"composite"``. Unknown values raise :class:`ValueError` so a typo
|
||
in ``decnet.ini`` surfaces immediately rather than silently falling
|
||
back.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
from typing import Final
|
||
|
||
from decnet.ttp.base import KNOWN_SOURCE_KINDS, Tagger, TaggerEvent
|
||
from decnet.web.db.models.ttp import TTPTag
|
||
|
||
_log = logging.getLogger(__name__)
|
||
|
||
_KNOWN: Final[tuple[str, ...]] = ("composite",)
|
||
_DEFAULT: Final[str] = "composite"
|
||
|
||
|
||
class CompositeTagger(Tagger):
|
||
"""Fans an event out to every lifter that claims its ``source_kind``.
|
||
|
||
The composite is the runtime end of the closed-by-enumeration
|
||
bridge described in :mod:`decnet.ttp.base`: when an event arrives
|
||
with a ``source_kind`` no lifter claims, the composite emits a
|
||
structured log line so the silent-drop trap from the design doc
|
||
becomes observable.
|
||
|
||
During the contract phase (this commit) ``lifters=[]`` is the
|
||
legal state — E.1.6 wires the real per-source lifters in.
|
||
"""
|
||
|
||
name = "composite"
|
||
# The composite itself accepts every event; per-kind dispatch is
|
||
# delegated to children. Empty here is "n/a, computed from
|
||
# children" — the dispatch index below is what actually drives
|
||
# the fan-out.
|
||
HANDLES: frozenset[str] = frozenset()
|
||
|
||
def __init__(self, lifters: list[Tagger]) -> None:
|
||
self._lifters: list[Tagger] = list(lifters)
|
||
index: dict[str, list[Tagger]] = {}
|
||
for lifter in self._lifters:
|
||
for kind in lifter.HANDLES:
|
||
index.setdefault(kind, []).append(lifter)
|
||
self._by_kind: dict[str, list[Tagger]] = index
|
||
# Per-process dedup state so a flood of one unknown kind
|
||
# produces one log line, not one per event. A simple set
|
||
# is fine for the contract; E.1.6 may swap in a proper
|
||
# rate-limiter once production traffic shapes are known.
|
||
self._warned_known: set[str] = set()
|
||
self._informed_unknown: set[str] = set()
|
||
|
||
async def tag(self, event: TaggerEvent) -> list[TTPTag]:
|
||
lifters = self._by_kind.get(event.source_kind, [])
|
||
if not lifters:
|
||
self._log_unhandled(event.source_kind)
|
||
return []
|
||
results = await asyncio.gather(*(t.tag(event) for t in lifters))
|
||
out: list[TTPTag] = []
|
||
for tags in results:
|
||
out.extend(tags)
|
||
return out
|
||
|
||
def _log_unhandled(self, source_kind: str) -> None:
|
||
if source_kind in KNOWN_SOURCE_KINDS:
|
||
if source_kind not in self._warned_known:
|
||
self._warned_known.add(source_kind)
|
||
# Producer ships a kind that *should* be handled but
|
||
# no lifter claims it — almost certainly a missed
|
||
# E.1.6 update. Loud once per kind per process.
|
||
_log.warning(
|
||
"composite tagger: no lifter claims known "
|
||
"source_kind=%r; events will be dropped until a "
|
||
"lifter is registered",
|
||
source_kind,
|
||
)
|
||
else:
|
||
if source_kind not in self._informed_unknown:
|
||
self._informed_unknown.add(source_kind)
|
||
# Telemetry from a future feature, no lifter yet, by
|
||
# design (lines 160–195 of the design doc). INFO once
|
||
# per process; never an error.
|
||
_log.info(
|
||
"composite tagger: unknown source_kind=%r "
|
||
"(not in KNOWN_SOURCE_KINDS); ignoring",
|
||
source_kind,
|
||
)
|
||
|
||
|
||
def get_tagger() -> Tagger:
|
||
"""Return the configured tagger instance.
|
||
|
||
Lazy package layout: the composite is constructed with an empty
|
||
lifter list during the contract phase. E.1.6 will replace this
|
||
with explicit lifter wiring; callers don't change.
|
||
"""
|
||
name = os.environ.get("DECNET_TTP_TAGGER_TYPE", _DEFAULT).strip().lower()
|
||
if name == "composite":
|
||
return CompositeTagger(lifters=[])
|
||
raise ValueError(
|
||
f"Unknown tagger: {name!r}. Known: {_KNOWN}"
|
||
)
|
||
|
||
|
||
__all__ = ["get_tagger", "CompositeTagger"]
|