chore: initialize EverOS 1.0.0
md-first memory extraction framework for AI agents. Markdown is the single source of truth; SQLite holds state and LanceDB provides the rebuildable vector + BM25 + scalar index. The codebase follows a single-direction DDD layering (entrypoints -> service -> memory -> infra, with component / core / config cross-cutting) enforced by import-linter. Engineering surface: - Coding conventions in .claude/rules/ (path-scoped) and workflows in .claude/skills/ (/commit, /new-branch, /pr). - GitHub Actions CI runs make lint + test + integration; pre-commit mirrors the gates locally (ruff, hygiene hooks, gitlint commit-msg). - Commit messages follow Conventional Commits, enforced by gitlint. - make lint also enforces datetime two-zone discipline and OpenAPI drift.
This commit is contained in:
0
tests/unit/test_infra/test_ome/__init__.py
Normal file
0
tests/unit/test_infra/test_ome/__init__.py
Normal file
159
tests/unit/test_infra/test_ome/test_config.py
Normal file
159
tests/unit/test_infra/test_ome/test_config.py
Normal file
@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from everos.infra.ome.config import (
|
||||
CounterOverride,
|
||||
OMEConfig,
|
||||
StrategyOverride,
|
||||
TomlRoot,
|
||||
)
|
||||
|
||||
|
||||
def test_ome_config_defaults() -> None:
|
||||
from everos.core.persistence.memory_root import MemoryRoot
|
||||
|
||||
c = OMEConfig()
|
||||
assert c.jobstore_path == MemoryRoot.default().ome_db
|
||||
assert c.aps_jobstore_path == MemoryRoot.default().ome_aps_db
|
||||
assert c.max_concurrent_runs == 20
|
||||
assert c.max_retries == 1
|
||||
assert c.max_records_per_strategy == 1000
|
||||
assert c.crash_recovery_timeout_seconds == 1800
|
||||
assert c.config_path is None
|
||||
assert c.config_watch is True
|
||||
assert c.config_watch_debounce_ms == 1600
|
||||
|
||||
|
||||
def test_aps_jobstore_path_derives_sibling_of_jobstore_path(tmp_path: object) -> None:
|
||||
"""When only ``jobstore_path`` is set, APS db lands next to it as
|
||||
``<stem>.aps.db`` so callers using a custom path (e.g. tests with
|
||||
tmp_path) get an isolated APS file rather than the global default."""
|
||||
from pathlib import Path
|
||||
|
||||
custom = Path(str(tmp_path)) / "custom_dir" / "my_ome.db"
|
||||
c = OMEConfig(jobstore_path=custom)
|
||||
assert c.aps_jobstore_path == custom.with_name("my_ome.aps.db")
|
||||
|
||||
|
||||
def test_aps_jobstore_path_respects_explicit_value(tmp_path: object) -> None:
|
||||
"""An explicitly passed ``aps_jobstore_path`` is honored verbatim and
|
||||
the derivation validator does not overwrite it."""
|
||||
from pathlib import Path
|
||||
|
||||
ome = Path(str(tmp_path)) / "ome.db"
|
||||
aps = Path(str(tmp_path)) / "elsewhere" / "scheduler.db"
|
||||
c = OMEConfig(jobstore_path=ome, aps_jobstore_path=aps)
|
||||
assert c.aps_jobstore_path == aps
|
||||
|
||||
|
||||
def test_ome_config_rejects_unknown_field() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
OMEConfig(unknown_field=1) # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_ome_config_rejects_zero_concurrency() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
OMEConfig(max_concurrent_runs=0)
|
||||
|
||||
|
||||
def test_toml_root_parses_strategy_override() -> None:
|
||||
raw = """
|
||||
[strategies.cluster_memcells]
|
||||
enabled = true
|
||||
max_retries = 3
|
||||
|
||||
[strategies.cluster_memcells.gate]
|
||||
threshold = 10
|
||||
event_field = "user_id"
|
||||
"""
|
||||
import tomllib
|
||||
|
||||
parsed = tomllib.loads(raw)
|
||||
root = TomlRoot.model_validate(parsed)
|
||||
s = root.strategies["cluster_memcells"]
|
||||
assert isinstance(s, StrategyOverride)
|
||||
assert s.enabled is True
|
||||
assert s.max_retries == 3
|
||||
assert isinstance(s.gate, CounterOverride)
|
||||
assert s.gate.threshold == 10
|
||||
assert s.gate.event_field == "user_id"
|
||||
|
||||
|
||||
def test_toml_root_forbids_unknown_strategy_field() -> None:
|
||||
import tomllib
|
||||
|
||||
raw = """
|
||||
[strategies.x]
|
||||
unknown_key = 1
|
||||
"""
|
||||
parsed = tomllib.loads(raw)
|
||||
with pytest.raises(ValidationError):
|
||||
TomlRoot.model_validate(parsed)
|
||||
|
||||
|
||||
def test_strategy_override_accepts_cron_field() -> None:
|
||||
s = StrategyOverride(cron="0 3 * * *")
|
||||
assert s.cron == "0 3 * * *"
|
||||
|
||||
|
||||
def test_strategy_override_accepts_idle_seconds() -> None:
|
||||
s = StrategyOverride(idle_seconds=30)
|
||||
assert s.idle_seconds == 30
|
||||
|
||||
|
||||
def test_strategy_override_accepts_scan_interval_seconds() -> None:
|
||||
s = StrategyOverride(scan_interval_seconds=15)
|
||||
assert s.scan_interval_seconds == 15
|
||||
|
||||
|
||||
def test_strategy_override_rejects_zero_idle_seconds() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
StrategyOverride(idle_seconds=0)
|
||||
|
||||
|
||||
def test_strategy_override_rejects_zero_scan_interval() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
StrategyOverride(scan_interval_seconds=0)
|
||||
|
||||
|
||||
def test_strategy_override_defaults_are_none() -> None:
|
||||
s = StrategyOverride()
|
||||
assert s.cron is None
|
||||
assert s.idle_seconds is None
|
||||
assert s.scan_interval_seconds is None
|
||||
|
||||
|
||||
def test_counter_override_rejects_empty_event_field() -> None:
|
||||
with pytest.raises(ValidationError, match="event_field"):
|
||||
CounterOverride(event_field="")
|
||||
|
||||
|
||||
def test_strategy_override_rejects_invalid_cron_at_construction() -> None:
|
||||
"""cron is parsed by APS at construction time so TOML reload can't
|
||||
bring an invalid crontab into the system."""
|
||||
with pytest.raises(ValidationError, match="cron"):
|
||||
StrategyOverride(cron="not a cron")
|
||||
|
||||
|
||||
def test_strategy_override_rejects_inconsistent_idle_pair() -> None:
|
||||
"""When both idle_seconds and scan_interval_seconds are overridden in
|
||||
the same payload, scan_interval must be <= idle_seconds // 2 — mirror
|
||||
of the Idle trigger constraint."""
|
||||
with pytest.raises(ValidationError, match="scan_interval_seconds"):
|
||||
StrategyOverride(idle_seconds=30, scan_interval_seconds=20)
|
||||
|
||||
|
||||
def test_strategy_override_accepts_consistent_idle_pair() -> None:
|
||||
s = StrategyOverride(idle_seconds=60, scan_interval_seconds=30)
|
||||
assert s.idle_seconds == 60
|
||||
assert s.scan_interval_seconds == 30
|
||||
|
||||
|
||||
def test_strategy_override_accepts_single_idle_field() -> None:
|
||||
"""One-sided override is allowed; the cross-field check is deferred
|
||||
to post-merge time (in apply_overrides) when both are known."""
|
||||
s = StrategyOverride(scan_interval_seconds=999)
|
||||
assert s.scan_interval_seconds == 999
|
||||
assert s.idle_seconds is None
|
||||
407
tests/unit/test_infra/test_ome/test_config_reloader.py
Normal file
407
tests/unit/test_infra/test_ome/test_config_reloader.py
Normal file
@ -0,0 +1,407 @@
|
||||
"""Tests for ConfigReloader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.infra.ome._background.config_reloader import (
|
||||
ConfigReloader,
|
||||
apply_overrides,
|
||||
)
|
||||
from everos.infra.ome._dispatch.registry import StrategyRegistry
|
||||
from everos.infra.ome.config import CounterOverride, StrategyOverride, TomlRoot
|
||||
from everos.infra.ome.context import StrategyContext
|
||||
from everos.infra.ome.decorator import offline_strategy
|
||||
from everos.infra.ome.engine import OfflineEngine
|
||||
from everos.infra.ome.events import BaseEvent
|
||||
from everos.infra.ome.gates import Counter
|
||||
from everos.infra.ome.triggers import Cron, Idle, Immediate
|
||||
|
||||
|
||||
class _E(BaseEvent):
|
||||
pass
|
||||
|
||||
|
||||
class _EventUid(BaseEvent):
|
||||
user_id: str
|
||||
|
||||
|
||||
def _make(name: str, **kw: Any) -> Any:
|
||||
@offline_strategy(name=name, trigger=Immediate(on=[_E]), emits=[], **kw)
|
||||
async def f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
return f
|
||||
|
||||
|
||||
def _make_cron(name: str, expr: str = "0 3 * * *", **kw: Any) -> Any:
|
||||
@offline_strategy(name=name, trigger=Cron(expr=expr), emits=[], **kw)
|
||||
async def f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
return f
|
||||
|
||||
|
||||
def _make_idle(name: str, **kw: Any) -> Any:
|
||||
@offline_strategy(
|
||||
name=name,
|
||||
trigger=Idle(
|
||||
on=[_EventUid],
|
||||
event_field="user_id",
|
||||
idle_seconds=30,
|
||||
scan_interval_seconds=10,
|
||||
),
|
||||
emits=[],
|
||||
**kw,
|
||||
)
|
||||
async def f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_engine() -> MagicMock:
|
||||
"""Mock OfflineEngine; spec catches typos in mocked method names."""
|
||||
return MagicMock(spec=OfflineEngine)
|
||||
|
||||
|
||||
def test_apply_overrides_replaces_enabled(fake_engine: MagicMock) -> None:
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make("s", enabled=True))
|
||||
root = TomlRoot(strategies={"s": StrategyOverride(enabled=False)})
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
assert reg.get("s").enabled is False
|
||||
|
||||
|
||||
def test_apply_overrides_max_retries(fake_engine: MagicMock) -> None:
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make("s", max_retries=1))
|
||||
root = TomlRoot(strategies={"s": StrategyOverride(max_retries=5)})
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
assert reg.get("s").max_retries == 5
|
||||
|
||||
|
||||
def test_apply_overrides_counter_partial(fake_engine: MagicMock) -> None:
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make("s", gate=Counter(threshold=3, event_field="user_id")))
|
||||
root = TomlRoot(
|
||||
strategies={"s": StrategyOverride(gate=CounterOverride(threshold=10))}
|
||||
)
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
g = reg.get("s").gate
|
||||
assert g.threshold == 10
|
||||
assert g.event_field == "user_id" # untouched
|
||||
|
||||
|
||||
def test_apply_overrides_unknown_strategy_ignored(fake_engine: MagicMock) -> None:
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make("s"))
|
||||
root = TomlRoot(strategies={"unknown": StrategyOverride(enabled=False)})
|
||||
apply_overrides(reg, root, fake_engine) # must not raise
|
||||
|
||||
|
||||
def test_apply_overrides_updates_cron_expr(fake_engine: MagicMock) -> None:
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make_cron("s", "0 3 * * *"))
|
||||
root = TomlRoot(strategies={"s": StrategyOverride(cron="*/5 * * * *")})
|
||||
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
|
||||
assert isinstance(reg.get("s").trigger, Cron)
|
||||
assert reg.get("s").trigger.expr == "*/5 * * * *"
|
||||
fake_engine.reschedule_cron_job.assert_called_once_with("s", "*/5 * * * *")
|
||||
|
||||
|
||||
def test_apply_overrides_skips_atomic_group_on_reschedule_failure(
|
||||
fake_engine: MagicMock,
|
||||
) -> None:
|
||||
"""Even though StrategyOverride.cron is now syntactically validated at
|
||||
parse time, reschedule_cron_job can still fail at runtime (APS internal
|
||||
error, scheduler stopped, etc.). The atomic-group rollback must hold
|
||||
against those failures too.
|
||||
"""
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make_cron("s", "0 3 * * *", enabled=True, max_retries=1))
|
||||
fake_engine.reschedule_cron_job.side_effect = RuntimeError("APS error")
|
||||
root = TomlRoot(
|
||||
strategies={
|
||||
"s": StrategyOverride(enabled=False, cron="*/5 * * * *", max_retries=99)
|
||||
}
|
||||
)
|
||||
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
|
||||
# enabled applied independently
|
||||
assert reg.get("s").enabled is False
|
||||
# atomic group rolled back: cron unchanged, max_retries unchanged
|
||||
assert reg.get("s").trigger.expr == "0 3 * * *"
|
||||
assert reg.get("s").max_retries == 1
|
||||
fake_engine.reschedule_cron_job.assert_called_once_with("s", "*/5 * * * *")
|
||||
|
||||
|
||||
def test_apply_overrides_skips_atomic_group_on_cron_type_mismatch(
|
||||
fake_engine: MagicMock,
|
||||
) -> None:
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make("s", enabled=True)) # Immediate strategy
|
||||
root = TomlRoot(strategies={"s": StrategyOverride(enabled=False, cron="0 3 * * *")})
|
||||
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
|
||||
assert reg.get("s").enabled is False
|
||||
assert isinstance(reg.get("s").trigger, Immediate)
|
||||
fake_engine.reschedule_cron_job.assert_not_called()
|
||||
|
||||
|
||||
def test_apply_overrides_updates_idle_seconds_and_scan_interval(
|
||||
fake_engine: MagicMock,
|
||||
) -> None:
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make_idle("s"))
|
||||
root = TomlRoot(
|
||||
strategies={"s": StrategyOverride(idle_seconds=120, scan_interval_seconds=15)}
|
||||
)
|
||||
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
|
||||
t = reg.get("s").trigger
|
||||
assert t.idle_seconds == 120
|
||||
assert t.scan_interval_seconds == 15
|
||||
fake_engine.reschedule_idle_job.assert_called_once_with(
|
||||
"s", scan_interval_seconds=15
|
||||
)
|
||||
|
||||
|
||||
def test_apply_overrides_updates_only_idle_seconds_does_not_reschedule_aps(
|
||||
fake_engine: MagicMock,
|
||||
) -> None:
|
||||
"""idle_seconds is consumed by dispatcher / engine on each scan,
|
||||
not by APS IntervalTrigger, so changing only it must NOT trigger
|
||||
an APS reschedule (which would reset the pending tick).
|
||||
"""
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make_idle("s"))
|
||||
root = TomlRoot(strategies={"s": StrategyOverride(idle_seconds=120)})
|
||||
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
|
||||
assert reg.get("s").trigger.idle_seconds == 120
|
||||
fake_engine.reschedule_idle_job.assert_not_called()
|
||||
|
||||
|
||||
def test_apply_overrides_skips_atomic_group_on_idle_type_mismatch(
|
||||
fake_engine: MagicMock,
|
||||
) -> None:
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make_cron("s")) # Cron strategy
|
||||
root = TomlRoot(strategies={"s": StrategyOverride(idle_seconds=60)})
|
||||
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
|
||||
assert isinstance(reg.get("s").trigger, Cron)
|
||||
fake_engine.reschedule_cron_job.assert_not_called()
|
||||
fake_engine.reschedule_idle_job.assert_not_called()
|
||||
|
||||
|
||||
def test_apply_overrides_rollback_on_aps_reschedule_failure(
|
||||
fake_engine: MagicMock,
|
||||
) -> None:
|
||||
fake_engine.reschedule_cron_job.side_effect = RuntimeError("APS exploded")
|
||||
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make_cron("s", "0 3 * * *", enabled=True, max_retries=1))
|
||||
root = TomlRoot(
|
||||
strategies={
|
||||
"s": StrategyOverride(enabled=False, cron="*/5 * * * *", max_retries=99)
|
||||
}
|
||||
)
|
||||
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
|
||||
# enabled applied (Step 1, before atomic group)
|
||||
assert reg.get("s").enabled is False
|
||||
# atomic group rolled back: cron + max_retries unchanged
|
||||
assert reg.get("s").trigger.expr == "0 3 * * *"
|
||||
assert reg.get("s").max_retries == 1
|
||||
|
||||
|
||||
def test_apply_overrides_enabled_survives_reschedule_failure(
|
||||
fake_engine: MagicMock,
|
||||
) -> None:
|
||||
"""enabled=false is emergency-stop semantics; must apply even when the
|
||||
paired cron update fails at reschedule time.
|
||||
"""
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make_cron("s", "0 3 * * *", enabled=True))
|
||||
fake_engine.reschedule_cron_job.side_effect = RuntimeError("APS error")
|
||||
root = TomlRoot(
|
||||
strategies={"s": StrategyOverride(enabled=False, cron="*/5 * * * *")}
|
||||
)
|
||||
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
|
||||
assert reg.get("s").enabled is False
|
||||
assert reg.get("s").trigger.expr == "0 3 * * *"
|
||||
|
||||
|
||||
def test_apply_overrides_strategy_isolation(fake_engine: MagicMock) -> None:
|
||||
"""One strategy's atomic-group failure must not affect another."""
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make_cron("a", "0 3 * * *"))
|
||||
reg.register(_make_cron("b", "0 4 * * *"))
|
||||
|
||||
def _reschedule(name: str, expr: str) -> None:
|
||||
if name == "b":
|
||||
raise RuntimeError("simulated APS failure for b")
|
||||
|
||||
fake_engine.reschedule_cron_job.side_effect = _reschedule
|
||||
root = TomlRoot(
|
||||
strategies={
|
||||
"a": StrategyOverride(cron="*/5 * * * *"),
|
||||
"b": StrategyOverride(cron="*/7 * * * *"),
|
||||
}
|
||||
)
|
||||
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
|
||||
assert reg.get("a").trigger.expr == "*/5 * * * *"
|
||||
assert reg.get("b").trigger.expr == "0 4 * * *"
|
||||
|
||||
|
||||
def test_apply_overrides_atomic_group_no_partial_application(
|
||||
fake_engine: MagicMock,
|
||||
) -> None:
|
||||
"""A failure in the atomic group must roll back max_retries / gate too."""
|
||||
reg = StrategyRegistry()
|
||||
reg.register(
|
||||
_make_cron(
|
||||
"s",
|
||||
"0 3 * * *",
|
||||
max_retries=1,
|
||||
gate=Counter(threshold=3, event_field="user_id"),
|
||||
)
|
||||
)
|
||||
fake_engine.reschedule_cron_job.side_effect = RuntimeError("APS error")
|
||||
root = TomlRoot(
|
||||
strategies={
|
||||
"s": StrategyOverride(
|
||||
cron="*/5 * * * *",
|
||||
max_retries=99,
|
||||
gate=CounterOverride(threshold=100),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
|
||||
assert reg.get("s").trigger.expr == "0 3 * * *"
|
||||
assert reg.get("s").max_retries == 1
|
||||
assert reg.get("s").gate.threshold == 3
|
||||
|
||||
|
||||
def test_apply_overrides_succeeds_on_combined_enabled_and_trigger(
|
||||
fake_engine: MagicMock,
|
||||
) -> None:
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make_cron("s", "0 3 * * *", enabled=True))
|
||||
root = TomlRoot(
|
||||
strategies={"s": StrategyOverride(enabled=False, cron="*/5 * * * *")}
|
||||
)
|
||||
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
|
||||
assert reg.get("s").enabled is False
|
||||
assert reg.get("s").trigger.expr == "*/5 * * * *"
|
||||
fake_engine.reschedule_cron_job.assert_called_once_with("s", "*/5 * * * *")
|
||||
|
||||
|
||||
def test_atomic_group_skipped_when_introducing_gate_without_threshold(
|
||||
fake_engine: MagicMock,
|
||||
) -> None:
|
||||
"""N5: TOML that introduces a gate via cooldown alone (no threshold)
|
||||
must be rejected, not silently defaulted to threshold=1 ('fire every event').
|
||||
"""
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make("s")) # no gate
|
||||
assert reg.get("s").gate is None
|
||||
|
||||
root = TomlRoot(
|
||||
strategies={
|
||||
"s": StrategyOverride(gate=CounterOverride(cooldown_seconds=60)),
|
||||
}
|
||||
)
|
||||
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
|
||||
# Atomic group rolled back: still no gate.
|
||||
assert reg.get("s").gate is None
|
||||
|
||||
|
||||
def test_atomic_group_accepts_introducing_gate_with_explicit_threshold(
|
||||
fake_engine: MagicMock,
|
||||
) -> None:
|
||||
"""N5 happy path: explicit threshold on a previously-gateless strategy
|
||||
is the user opt-in we require.
|
||||
"""
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make("s"))
|
||||
assert reg.get("s").gate is None
|
||||
|
||||
root = TomlRoot(
|
||||
strategies={
|
||||
"s": StrategyOverride(
|
||||
gate=CounterOverride(threshold=5, cooldown_seconds=60)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
apply_overrides(reg, root, fake_engine)
|
||||
|
||||
g = reg.get("s").gate
|
||||
assert g is not None
|
||||
assert g.threshold == 5
|
||||
assert g.cooldown_seconds == 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_twice_raises(tmp_path: Path) -> None:
|
||||
"""N7: calling start() twice surfaces the caller bug instead of
|
||||
silently dropping the original task reference and racing two watchers.
|
||||
"""
|
||||
config_path = tmp_path / "ome.toml"
|
||||
config_path.write_text("")
|
||||
reloader = ConfigReloader(
|
||||
config_path=config_path,
|
||||
registry=StrategyRegistry(),
|
||||
engine=MagicMock(spec=OfflineEngine),
|
||||
)
|
||||
reloader.start()
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match=r"already started"):
|
||||
reloader.start()
|
||||
finally:
|
||||
await reloader.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_after_stop_is_allowed(tmp_path: Path) -> None:
|
||||
"""N7: idempotency check only fires while a task is live; once stopped,
|
||||
start() must work again so callers can restart the reloader.
|
||||
"""
|
||||
config_path = tmp_path / "ome.toml"
|
||||
config_path.write_text("")
|
||||
reloader = ConfigReloader(
|
||||
config_path=config_path,
|
||||
registry=StrategyRegistry(),
|
||||
engine=MagicMock(spec=OfflineEngine),
|
||||
)
|
||||
reloader.start()
|
||||
await reloader.stop()
|
||||
# Must not raise.
|
||||
reloader.start()
|
||||
await reloader.stop()
|
||||
24
tests/unit/test_infra/test_ome/test_context.py
Normal file
24
tests/unit/test_infra/test_ome/test_context.py
Normal file
@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
import structlog
|
||||
|
||||
from everos.infra.ome.context import StrategyContext
|
||||
|
||||
|
||||
def test_strategy_context_is_protocol() -> None:
|
||||
assert issubclass(StrategyContext, Protocol) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_strategy_context_runtime_attributes() -> None:
|
||||
class _Impl:
|
||||
run_id = "r1"
|
||||
logger = structlog.get_logger("test")
|
||||
|
||||
async def emit(self, event: object) -> None:
|
||||
return None
|
||||
|
||||
ctx: StrategyContext = _Impl()
|
||||
assert ctx.run_id == "r1"
|
||||
assert callable(ctx.emit)
|
||||
111
tests/unit/test_infra/test_ome/test_counter_store.py
Normal file
111
tests/unit/test_infra/test_ome/test_counter_store.py
Normal file
@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.infra.ome._stores.counter import CounterStore
|
||||
from everos.infra.ome._stores.storage import OMEStorage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def store(tmp_path: Path) -> CounterStore:
|
||||
storage = OMEStorage(db_path=tmp_path / "ome.db")
|
||||
await storage.init()
|
||||
return CounterStore(storage=storage)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_increments_until_threshold(store: CounterStore) -> None:
|
||||
for i in range(1, 5):
|
||||
passed, cur = await store.incr_and_check(
|
||||
"s",
|
||||
"u1",
|
||||
threshold=5,
|
||||
cooldown_seconds=0,
|
||||
)
|
||||
assert passed is False
|
||||
assert cur == i
|
||||
|
||||
passed, cur = await store.incr_and_check(
|
||||
"s",
|
||||
"u1",
|
||||
threshold=5,
|
||||
cooldown_seconds=0,
|
||||
)
|
||||
assert passed is True
|
||||
assert cur == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resets_after_pass(store: CounterStore) -> None:
|
||||
for _ in range(5):
|
||||
await store.incr_and_check("s", "u1", threshold=5, cooldown_seconds=0)
|
||||
passed, cur = await store.incr_and_check(
|
||||
"s",
|
||||
"u1",
|
||||
threshold=5,
|
||||
cooldown_seconds=0,
|
||||
)
|
||||
assert passed is False
|
||||
assert cur == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cooldown_blocks_pass(store: CounterStore) -> None:
|
||||
# First pass
|
||||
for _ in range(5):
|
||||
await store.incr_and_check("s", "u1", threshold=5, cooldown_seconds=10)
|
||||
# Threshold met again immediately, but cooldown blocks
|
||||
for _ in range(5):
|
||||
passed, _ = await store.incr_and_check(
|
||||
"s",
|
||||
"u1",
|
||||
threshold=5,
|
||||
cooldown_seconds=10,
|
||||
)
|
||||
assert passed is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_buckets_are_isolated(store: CounterStore) -> None:
|
||||
for _ in range(5):
|
||||
await store.incr_and_check("s", "u1", threshold=5, cooldown_seconds=0)
|
||||
passed, cur = await store.incr_and_check(
|
||||
"s",
|
||||
"u2",
|
||||
threshold=5,
|
||||
cooldown_seconds=0,
|
||||
)
|
||||
assert cur == 1
|
||||
assert passed is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_query(store: CounterStore) -> None:
|
||||
await store.incr_and_check("s", "u1", threshold=5, cooldown_seconds=0)
|
||||
await store.incr_and_check("s", "u1", threshold=5, cooldown_seconds=0)
|
||||
cur = await store.get_progress("s", "u1")
|
||||
assert cur == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returned_counter_reflects_actual_value_when_threshold_lowered(
|
||||
store: CounterStore,
|
||||
) -> None:
|
||||
"""When threshold drops via hot-reload after counter accumulation,
|
||||
the returned counter must reflect the *actual* count at trigger
|
||||
moment, not the (lower) threshold. Diagnostics rely on this.
|
||||
"""
|
||||
# Accumulate 7 hits under a high threshold; none pass.
|
||||
for _ in range(7):
|
||||
passed, _ = await store.incr_and_check(
|
||||
"s", "u1", threshold=10, cooldown_seconds=0
|
||||
)
|
||||
assert passed is False
|
||||
|
||||
# Threshold is "lowered" to 5 (config hot-reload semantics).
|
||||
# Counter goes 7 -> 8, which is past the new threshold.
|
||||
passed, cur = await store.incr_and_check("s", "u1", threshold=5, cooldown_seconds=0)
|
||||
assert passed is True
|
||||
assert cur == 8 # actual count, not threshold (=5)
|
||||
149
tests/unit/test_infra/test_ome/test_crash_recovery.py
Normal file
149
tests/unit/test_infra/test_ome/test_crash_recovery.py
Normal file
@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.component.utils.datetime import get_now_with_timezone, to_iso_format
|
||||
from everos.infra.ome._background.crash_recovery import scan_and_resume
|
||||
from everos.infra.ome._stores.run_record import RunRecordStore
|
||||
from everos.infra.ome._stores.storage import OMEStorage
|
||||
from everos.infra.ome.records import RunStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def rec_store(tmp_path: Path) -> RunRecordStore:
|
||||
storage = OMEStorage(db_path=tmp_path / "ome.db")
|
||||
await storage.init()
|
||||
return RunRecordStore(storage=storage, max_records_per_strategy=1000)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_marks_old_running_as_crashed(rec_store: RunRecordStore) -> None:
|
||||
await rec_store.mark_running(
|
||||
run_id="r_old",
|
||||
strategy_name="s",
|
||||
attempt=0,
|
||||
event_topic="x:E",
|
||||
event_payload="{}",
|
||||
max_retries_snapshot=1,
|
||||
)
|
||||
async with rec_store._storage.connect() as conn:
|
||||
rewind = to_iso_format(get_now_with_timezone() - timedelta(hours=2))
|
||||
await conn.execute(
|
||||
"UPDATE run_record SET started_at = ? WHERE run_id = ?",
|
||||
(rewind, "r_old"),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
resumed: list = []
|
||||
|
||||
async def add_job_hook(name, run_id, event_topic, event_payload, max_retries):
|
||||
resumed.append((name, run_id, event_topic, event_payload, max_retries))
|
||||
|
||||
await scan_and_resume(
|
||||
run_record_store=rec_store,
|
||||
timeout_seconds=1800,
|
||||
add_job=add_job_hook,
|
||||
)
|
||||
|
||||
rec = await rec_store.get("r_old")
|
||||
assert rec.status == RunStatus.CRASHED
|
||||
assert len(resumed) == 1
|
||||
new_name, new_run_id, ec, ep, mr = resumed[0]
|
||||
assert new_name == "s"
|
||||
assert new_run_id != "r_old"
|
||||
assert ec == "x:E"
|
||||
assert ep == "{}"
|
||||
assert mr == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_running_skipped(rec_store: RunRecordStore) -> None:
|
||||
await rec_store.mark_running(
|
||||
run_id="r_fresh",
|
||||
strategy_name="s",
|
||||
attempt=0,
|
||||
event_topic="x:E",
|
||||
event_payload="{}",
|
||||
max_retries_snapshot=1,
|
||||
)
|
||||
resumed: list = []
|
||||
|
||||
async def add_job_hook(*args, **kw):
|
||||
resumed.append(args)
|
||||
|
||||
await scan_and_resume(
|
||||
run_record_store=rec_store,
|
||||
timeout_seconds=1800,
|
||||
add_job=add_job_hook,
|
||||
)
|
||||
rec = await rec_store.get("r_fresh")
|
||||
assert rec.status == RunStatus.RUNNING
|
||||
assert resumed == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_timeout", [0, -1])
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_and_resume_non_positive_timeout_raises(
|
||||
rec_store: RunRecordStore, bad_timeout: int
|
||||
) -> None:
|
||||
"""N6: non-positive timeout must fail fast rather than silently no-op."""
|
||||
|
||||
async def _noop_add_job(*_args: object, **_kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(ValueError, match=r"timeout_seconds must be > 0"):
|
||||
await scan_and_resume(
|
||||
run_record_store=rec_store,
|
||||
timeout_seconds=bad_timeout,
|
||||
add_job=_noop_add_job,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_job_failure_does_not_abort_loop(
|
||||
rec_store: RunRecordStore,
|
||||
) -> None:
|
||||
"""add_job raising on one row must not block sibling stale rows.
|
||||
|
||||
mark_crashed runs before add_job, so both rows end up CRASHED even
|
||||
when add_job fails for one. This pins the at-most-once contract
|
||||
documented in the module docstring.
|
||||
"""
|
||||
for run_id in ("r_old_1", "r_old_2"):
|
||||
await rec_store.mark_running(
|
||||
run_id=run_id,
|
||||
strategy_name="s",
|
||||
attempt=0,
|
||||
event_topic="x:E",
|
||||
event_payload="{}",
|
||||
max_retries_snapshot=1,
|
||||
)
|
||||
async with rec_store._storage.connect() as conn:
|
||||
rewind = to_iso_format(get_now_with_timezone() - timedelta(hours=2))
|
||||
await conn.execute(
|
||||
"UPDATE run_record SET started_at = ? WHERE run_id IN (?, ?)",
|
||||
(rewind, "r_old_1", "r_old_2"),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
calls: list[tuple] = []
|
||||
|
||||
async def flaky_add_job(name, run_id, event_topic, event_payload, max_retries):
|
||||
calls.append((name, run_id, event_topic, event_payload, max_retries))
|
||||
if len(calls) == 1:
|
||||
raise RuntimeError("APS jobstore unavailable")
|
||||
|
||||
await scan_and_resume(
|
||||
run_record_store=rec_store,
|
||||
timeout_seconds=1800,
|
||||
add_job=flaky_add_job,
|
||||
)
|
||||
|
||||
rec1 = await rec_store.get("r_old_1")
|
||||
rec2 = await rec_store.get("r_old_2")
|
||||
assert rec1.status == RunStatus.CRASHED
|
||||
assert rec2.status == RunStatus.CRASHED
|
||||
assert len(calls) == 2
|
||||
81
tests/unit/test_infra/test_ome/test_decorator.py
Normal file
81
tests/unit/test_infra/test_ome/test_decorator.py
Normal file
@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.infra.ome.context import StrategyContext
|
||||
from everos.infra.ome.decorator import StrategyMeta, offline_strategy
|
||||
from everos.infra.ome.events import BaseEvent
|
||||
from everos.infra.ome.gates import Counter
|
||||
from everos.infra.ome.triggers import Immediate
|
||||
|
||||
|
||||
class _E(BaseEvent):
|
||||
user_id: str
|
||||
|
||||
|
||||
def test_decorator_attaches_metadata() -> None:
|
||||
@offline_strategy(name="x", trigger=Immediate(on=[_E]), emits=[_E])
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
meta: StrategyMeta = s._ome_strategy_meta # type: ignore[attr-defined]
|
||||
assert meta.name == "x"
|
||||
assert meta.emits == frozenset({_E})
|
||||
assert meta.gate is None
|
||||
assert meta.applies_to is None
|
||||
assert meta.max_retries is None
|
||||
assert meta.enabled is True
|
||||
assert meta.func is s
|
||||
|
||||
|
||||
def test_decorator_with_full_params() -> None:
|
||||
@offline_strategy(
|
||||
name="cluster",
|
||||
trigger=Immediate(on=[_E]),
|
||||
emits=[_E],
|
||||
applies_to="user_id",
|
||||
gate=Counter(threshold=5),
|
||||
max_retries=3,
|
||||
enabled=False,
|
||||
)
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
meta = s._ome_strategy_meta # type: ignore[attr-defined]
|
||||
assert meta.applies_to == "user_id"
|
||||
assert meta.gate.threshold == 5
|
||||
assert meta.max_retries == 3
|
||||
assert meta.enabled is False
|
||||
|
||||
|
||||
def test_decorator_callable_applies_to() -> None:
|
||||
def is_paid(e: _E) -> bool:
|
||||
return e.user_id.startswith("paid_")
|
||||
|
||||
@offline_strategy(
|
||||
name="paid_only",
|
||||
trigger=Immediate(on=[_E]),
|
||||
emits=[_E],
|
||||
applies_to=is_paid,
|
||||
)
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
meta = s._ome_strategy_meta # type: ignore[attr-defined]
|
||||
assert meta.applies_to is is_paid
|
||||
|
||||
|
||||
def test_decorator_rejects_blank_name() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
|
||||
@offline_strategy(name="", trigger=Immediate(on=[_E]), emits=[_E])
|
||||
async def _s(event: _E, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_decorator_rejects_non_async_function() -> None:
|
||||
with pytest.raises(TypeError):
|
||||
|
||||
@offline_strategy(name="x", trigger=Immediate(on=[_E]), emits=[_E])
|
||||
def _s(event: _E, ctx: StrategyContext) -> None: # not async
|
||||
return None
|
||||
215
tests/unit/test_infra/test_ome/test_dispatcher.py
Normal file
215
tests/unit/test_infra/test_ome/test_dispatcher.py
Normal file
@ -0,0 +1,215 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.infra.ome._dispatch.dispatcher import EventDispatcher
|
||||
from everos.infra.ome._dispatch.registry import StrategyRegistry
|
||||
from everos.infra.ome._stores.counter import CounterStore
|
||||
from everos.infra.ome._stores.storage import OMEStorage
|
||||
from everos.infra.ome.context import StrategyContext
|
||||
from everos.infra.ome.decorator import offline_strategy
|
||||
from everos.infra.ome.events import BaseEvent, CronTick
|
||||
from everos.infra.ome.gates import Counter
|
||||
from everos.infra.ome.triggers import Cron, Immediate
|
||||
|
||||
|
||||
class _E(BaseEvent):
|
||||
user_id: str
|
||||
|
||||
|
||||
def _make_strategy(name: str, **kw):
|
||||
@offline_strategy(name=name, trigger=Immediate(on=[_E]), emits=[], **kw)
|
||||
async def _f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
return _f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def dispatcher(tmp_path: Path) -> EventDispatcher:
|
||||
storage = OMEStorage(db_path=tmp_path / "ome.db")
|
||||
await storage.init()
|
||||
registry = StrategyRegistry()
|
||||
counter = CounterStore(storage=storage)
|
||||
return EventDispatcher(registry=registry, counter_store=counter)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_passes_when_no_gate(
|
||||
dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
dispatcher._registry.register(_make_strategy("s_pass"))
|
||||
routes = await dispatcher.dispatch(_E(user_id="u1"))
|
||||
assert [m.name for m, _ in routes] == ["s_pass"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_skips_disabled(dispatcher: EventDispatcher) -> None:
|
||||
dispatcher._registry.register(_make_strategy("s_off", enabled=False))
|
||||
routes = await dispatcher.dispatch(_E(user_id="u1"))
|
||||
assert routes == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_applies_to_string(
|
||||
dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
dispatcher._registry.register(
|
||||
_make_strategy("s", applies_to="user_id"),
|
||||
)
|
||||
routes_empty = await dispatcher.dispatch(_E(user_id=""))
|
||||
routes_set = await dispatcher.dispatch(_E(user_id="u1"))
|
||||
assert routes_empty == []
|
||||
assert len(routes_set) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_applies_to_callable(
|
||||
dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
def is_paid(e: _E) -> bool:
|
||||
return e.user_id.startswith("paid_")
|
||||
|
||||
dispatcher._registry.register(_make_strategy("s", applies_to=is_paid))
|
||||
assert await dispatcher.dispatch(_E(user_id="free_a")) == []
|
||||
assert len(await dispatcher.dispatch(_E(user_id="paid_a"))) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_counter_gate(dispatcher: EventDispatcher) -> None:
|
||||
dispatcher._registry.register(
|
||||
_make_strategy("s", gate=Counter(threshold=3, event_field="user_id"))
|
||||
)
|
||||
for _ in range(2):
|
||||
routes = await dispatcher.dispatch(_E(user_id="u1"))
|
||||
assert routes == []
|
||||
routes = await dispatcher.dispatch(_E(user_id="u1"))
|
||||
assert len(routes) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_returns_route_info(
|
||||
dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
dispatcher._registry.register(
|
||||
_make_strategy("s", gate=Counter(threshold=3, event_field="user_id"))
|
||||
)
|
||||
infos = await dispatcher.inspect(_E(user_id="u1"))
|
||||
assert len(infos) == 1
|
||||
assert infos[0].counter_progress == (1, 3)
|
||||
assert infos[0].will_run is False
|
||||
|
||||
|
||||
def _make_cron_strategy(name: str):
|
||||
@offline_strategy(name=name, trigger=Cron(expr="0 * * * *"), emits=[])
|
||||
async def _f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
return _f
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_routes_engine_tick_to_named_strategy_only(
|
||||
dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
dispatcher._registry.register(_make_cron_strategy("cron_a"))
|
||||
dispatcher._registry.register(_make_cron_strategy("cron_b"))
|
||||
routes = await dispatcher.dispatch(CronTick(strategy_name="cron_a"))
|
||||
assert [m.name for m, _ in routes] == ["cron_a"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_engine_tick_skips_non_target_strategy(
|
||||
dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
dispatcher._registry.register(_make_cron_strategy("cron_a"))
|
||||
dispatcher._registry.register(_make_cron_strategy("cron_b"))
|
||||
infos = await dispatcher.inspect(CronTick(strategy_name="cron_b"))
|
||||
assert [i.strategy_name for i in infos] == ["cron_b"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_force_enabled_bypasses_enabled_gate(
|
||||
dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
dispatcher._registry.register(_make_strategy("s_off", enabled=False))
|
||||
assert await dispatcher.dispatch(_E(user_id="u1")) == []
|
||||
routes = await dispatcher.dispatch(_E(user_id="u1"), force_enabled=True)
|
||||
assert [m.name for m, _ in routes] == ["s_off"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_force_enabled_still_applies_applies_to_and_counter(
|
||||
dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
dispatcher._registry.register(
|
||||
_make_strategy(
|
||||
"s",
|
||||
enabled=False,
|
||||
applies_to="user_id",
|
||||
gate=Counter(threshold=2, event_field="user_id"),
|
||||
),
|
||||
)
|
||||
assert await dispatcher.dispatch(_E(user_id=""), force_enabled=True) == []
|
||||
assert await dispatcher.dispatch(_E(user_id="u1"), force_enabled=True) == []
|
||||
routes = await dispatcher.dispatch(_E(user_id="u1"), force_enabled=True)
|
||||
assert len(routes) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_strategy_filter_scopes_to_single_strategy(
|
||||
dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
dispatcher._registry.register(_make_strategy("s_a"))
|
||||
dispatcher._registry.register(_make_strategy("s_b"))
|
||||
routes = await dispatcher.dispatch(_E(user_id="u1"), strategy_filter="s_a")
|
||||
assert [m.name for m, _ in routes] == ["s_a"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_strategy_filter_unknown_raises(
|
||||
dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
dispatcher._registry.register(_make_strategy("s_a"))
|
||||
with pytest.raises(KeyError):
|
||||
await dispatcher.dispatch(_E(user_id="u1"), strategy_filter="missing")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_isolates_faulty_applies_to_callable(
|
||||
dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
"""A single strategy's buggy ``applies_to`` callable must not tank
|
||||
the fan-out for siblings subscribed to the same event class.
|
||||
"""
|
||||
|
||||
def _boom(_e: _E) -> bool:
|
||||
raise RuntimeError("applies_to is buggy")
|
||||
|
||||
dispatcher._registry.register(_make_strategy("s_buggy", applies_to=_boom))
|
||||
dispatcher._registry.register(_make_strategy("s_healthy"))
|
||||
|
||||
routes = await dispatcher.dispatch(_E(user_id="u1"))
|
||||
|
||||
# s_buggy is treated as not-applies; s_healthy still routes.
|
||||
assert [m.name for m, _ in routes] == ["s_healthy"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_isolates_faulty_applies_to_callable(
|
||||
dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
def _boom(_e: _E) -> bool:
|
||||
raise RuntimeError("applies_to is buggy")
|
||||
|
||||
dispatcher._registry.register(_make_strategy("s_buggy", applies_to=_boom))
|
||||
dispatcher._registry.register(_make_strategy("s_healthy"))
|
||||
|
||||
infos = await dispatcher.inspect(_E(user_id="u1"))
|
||||
|
||||
by_name = {i.strategy_name: i for i in infos}
|
||||
assert by_name["s_buggy"].applies_to_pass is False
|
||||
assert by_name["s_healthy"].applies_to_pass is True
|
||||
186
tests/unit/test_infra/test_ome/test_e2e.py
Normal file
186
tests/unit/test_infra/test_ome/test_e2e.py
Normal file
@ -0,0 +1,186 @@
|
||||
"""End-to-end pipeline test exercising the chain emit semantics.
|
||||
|
||||
MemCellSaved -> atomic (leaf strategy)
|
||||
EpisodeSaved -> cluster -> ClusteringCompleted -> profile (Counter threshold=3)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.infra.ome import (
|
||||
BaseEvent,
|
||||
Counter,
|
||||
Cron,
|
||||
CronTick,
|
||||
Immediate,
|
||||
StrategyContext,
|
||||
offline_strategy,
|
||||
)
|
||||
from everos.infra.ome.engine import _cron_entry
|
||||
from everos.infra.ome.testing import StrategyTestHarness
|
||||
|
||||
|
||||
class MemCellSaved(BaseEvent):
|
||||
user_id: str
|
||||
cell_id: str
|
||||
|
||||
|
||||
class EpisodeSaved(BaseEvent):
|
||||
user_id: str
|
||||
episode_text: str
|
||||
|
||||
|
||||
class ClusteringCompleted(BaseEvent):
|
||||
user_id: str
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chain_emit_without_counter_gate() -> None:
|
||||
"""Variant of the full-chain test without a Counter gate.
|
||||
|
||||
Profile fires once per ClusteringCompleted instead of once per N.
|
||||
"""
|
||||
log: list[tuple[str, str]] = []
|
||||
|
||||
@offline_strategy(
|
||||
name="cluster_e2e",
|
||||
trigger=Immediate(on=[EpisodeSaved]),
|
||||
emits=[ClusteringCompleted],
|
||||
)
|
||||
async def cluster(event: EpisodeSaved, ctx: StrategyContext) -> None:
|
||||
log.append(("cluster", event.user_id))
|
||||
await ctx.emit(ClusteringCompleted(user_id=event.user_id))
|
||||
|
||||
@offline_strategy(
|
||||
name="profile_e2e",
|
||||
trigger=Immediate(on=[ClusteringCompleted]),
|
||||
emits=[],
|
||||
)
|
||||
async def profile(event: ClusteringCompleted, ctx: StrategyContext) -> None:
|
||||
log.append(("profile", event.user_id))
|
||||
|
||||
async with StrategyTestHarness() as h:
|
||||
h.register(cluster)
|
||||
h.register(profile)
|
||||
await h.start()
|
||||
# Emit 3 episodes -> cluster runs 3x -> emits ClusteringCompleted 3x ->
|
||||
# profile runs 3x (no counter gate).
|
||||
await h.emit(EpisodeSaved(user_id="u1", episode_text="t1"))
|
||||
await asyncio.sleep(0.15)
|
||||
await h.emit(EpisodeSaved(user_id="u1", episode_text="t2"))
|
||||
await asyncio.sleep(0.15)
|
||||
await h.emit(EpisodeSaved(user_id="u1", episode_text="t3"))
|
||||
await asyncio.sleep(0.2)
|
||||
await h.drain(timeout=15)
|
||||
|
||||
cluster_runs = await h.list_runs("cluster_e2e")
|
||||
profile_runs = await h.list_runs("profile_e2e")
|
||||
|
||||
cluster_calls = [c for c in log if c[0] == "cluster"]
|
||||
profile_calls = [c for c in log if c[0] == "profile"]
|
||||
assert len(cluster_calls) == 3, (
|
||||
f"Expected 3 cluster, got {len(cluster_calls)}: {log}"
|
||||
)
|
||||
assert len(profile_calls) == 3, (
|
||||
f"Expected 3 profile, got {len(profile_calls)}: {log}"
|
||||
)
|
||||
assert len(cluster_runs) == 3
|
||||
assert len(profile_runs) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chain_pipeline_runs_full_path() -> None:
|
||||
"""Full chain with atomic, cluster, and profile (Counter gated)."""
|
||||
log: list[tuple[str, str]] = []
|
||||
|
||||
@offline_strategy(name="atomic_e2e", trigger=Immediate(on=[MemCellSaved]), emits=[])
|
||||
async def atomic(event: MemCellSaved, ctx: StrategyContext) -> None:
|
||||
log.append(("atomic", event.cell_id))
|
||||
|
||||
@offline_strategy(
|
||||
name="cluster_e2e",
|
||||
trigger=Immediate(on=[EpisodeSaved]),
|
||||
emits=[ClusteringCompleted],
|
||||
)
|
||||
async def cluster(event: EpisodeSaved, ctx: StrategyContext) -> None:
|
||||
log.append(("cluster", event.user_id))
|
||||
await ctx.emit(ClusteringCompleted(user_id=event.user_id))
|
||||
|
||||
@offline_strategy(
|
||||
name="profile_e2e",
|
||||
trigger=Immediate(on=[ClusteringCompleted]),
|
||||
emits=[],
|
||||
gate=Counter(threshold=3, event_field="user_id"),
|
||||
)
|
||||
async def profile(event: ClusteringCompleted, ctx: StrategyContext) -> None:
|
||||
log.append(("profile", event.user_id))
|
||||
|
||||
async with StrategyTestHarness() as h:
|
||||
h.register(atomic)
|
||||
h.register(cluster)
|
||||
h.register(profile)
|
||||
await h.start()
|
||||
# Two memcells (each fires atomic).
|
||||
await h.emit(MemCellSaved(user_id="u1", cell_id="c1"))
|
||||
await asyncio.sleep(0.15)
|
||||
await h.emit(MemCellSaved(user_id="u1", cell_id="c2"))
|
||||
await asyncio.sleep(0.15)
|
||||
# Three episodes -> cluster runs 3x -> ClusteringCompleted 3x ->
|
||||
# profile Counter at threshold=3 fires once.
|
||||
await h.emit(EpisodeSaved(user_id="u1", episode_text="t1"))
|
||||
await asyncio.sleep(0.15)
|
||||
await h.emit(EpisodeSaved(user_id="u1", episode_text="t2"))
|
||||
await asyncio.sleep(0.15)
|
||||
await h.emit(EpisodeSaved(user_id="u1", episode_text="t3"))
|
||||
await asyncio.sleep(0.2)
|
||||
await h.drain(timeout=15)
|
||||
|
||||
# Validate using run records
|
||||
atomic_runs = await h.list_runs("atomic_e2e")
|
||||
cluster_runs = await h.list_runs("cluster_e2e")
|
||||
profile_runs = await h.list_runs("profile_e2e")
|
||||
|
||||
atomic_calls = [c for c in log if c[0] == "atomic"]
|
||||
cluster_calls = [c for c in log if c[0] == "cluster"]
|
||||
profile_calls = [c for c in log if c[0] == "profile"]
|
||||
assert len(atomic_calls) == 2, (
|
||||
f"Expected 2 atomic calls, got {len(atomic_calls)}: {log}"
|
||||
)
|
||||
assert len(cluster_calls) == 3, (
|
||||
f"Expected 3 cluster calls, got {len(cluster_calls)}: {log}"
|
||||
)
|
||||
assert len(profile_calls) == 1, (
|
||||
f"Expected 1 profile call, got {len(profile_calls)}: {log}"
|
||||
)
|
||||
assert len(atomic_runs) == 2
|
||||
assert len(cluster_runs) == 3
|
||||
assert len(profile_runs) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cron_strategy_executes_when_cron_entry_fires() -> None:
|
||||
"""Verify that the cron-trigger code path actually reaches the strategy.
|
||||
|
||||
APScheduler timing is mocked away — we directly call the module-level
|
||||
_cron_entry function that APS would invoke on schedule. This proves
|
||||
the registry/dispatcher/runner chain wires cron strategies correctly.
|
||||
"""
|
||||
seen: list[str] = []
|
||||
|
||||
@offline_strategy(name="cron_e2e", trigger=Cron(expr="0 * * * *"), emits=[])
|
||||
async def cron_job(event: CronTick, ctx: StrategyContext) -> None:
|
||||
seen.append(event.strategy_name)
|
||||
|
||||
async with StrategyTestHarness() as h:
|
||||
h.register(cron_job)
|
||||
await h.start()
|
||||
# Directly invoke what APS would call; bypass scheduler timing.
|
||||
await _cron_entry(h._engine._engine_id, "cron_e2e") # noqa: SLF001
|
||||
await h.drain(timeout=5)
|
||||
runs = await h.list_runs("cron_e2e")
|
||||
|
||||
assert seen == ["cron_e2e"]
|
||||
assert len(runs) == 1
|
||||
623
tests/unit/test_infra/test_ome/test_engine.py
Normal file
623
tests/unit/test_infra/test_ome/test_engine.py
Normal file
@ -0,0 +1,623 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.infra.ome.config import OMEConfig
|
||||
from everos.infra.ome.context import StrategyContext
|
||||
from everos.infra.ome.decorator import offline_strategy
|
||||
from everos.infra.ome.engine import OfflineEngine
|
||||
from everos.infra.ome.events import BaseEvent
|
||||
from everos.infra.ome.exceptions import (
|
||||
EngineLockHeldError,
|
||||
OMEError,
|
||||
StartupValidationError,
|
||||
)
|
||||
from everos.infra.ome.records import RunStatus
|
||||
from everos.infra.ome.triggers import Cron, Idle, Immediate
|
||||
|
||||
|
||||
class _E(BaseEvent):
|
||||
pass
|
||||
|
||||
|
||||
class _A(BaseEvent):
|
||||
pass
|
||||
|
||||
|
||||
class _B(BaseEvent):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg(tmp_path: Path) -> OMEConfig:
|
||||
return OMEConfig(jobstore_path=tmp_path / "ome.db", config_watch=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_register_and_start(cfg: OMEConfig) -> None:
|
||||
@offline_strategy(name="s", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
engine = OfflineEngine(config=cfg)
|
||||
engine.register(s)
|
||||
await engine.start()
|
||||
await engine.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_register_after_start_raises(cfg: OMEConfig) -> None:
|
||||
engine = OfflineEngine(config=cfg)
|
||||
await engine.start()
|
||||
try:
|
||||
|
||||
@offline_strategy(name="s", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
with pytest.raises(OMEError):
|
||||
engine.register(s)
|
||||
finally:
|
||||
await engine.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_lock_prevents_double_open(cfg: OMEConfig) -> None:
|
||||
engine1 = OfflineEngine(config=cfg)
|
||||
await engine1.start()
|
||||
try:
|
||||
engine2 = OfflineEngine(config=cfg)
|
||||
with pytest.raises(EngineLockHeldError):
|
||||
await engine2.start()
|
||||
finally:
|
||||
await engine1.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_validates_dag_at_start(tmp_path: Path) -> None:
|
||||
cfg = OMEConfig(jobstore_path=tmp_path / "ome.db", config_watch=False)
|
||||
|
||||
@offline_strategy(name="s1", trigger=Immediate(on=[_A]), emits=[_B])
|
||||
async def _s1(e: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
@offline_strategy(name="s2", trigger=Immediate(on=[_B]), emits=[_A])
|
||||
async def _s2(e: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
engine = OfflineEngine(config=cfg)
|
||||
engine.register(_s1)
|
||||
engine.register(_s2)
|
||||
with pytest.raises(StartupValidationError, match=r"(?i)cycle"):
|
||||
await engine.start()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_emit_drives_strategy(cfg: OMEConfig) -> None:
|
||||
seen: list[_E] = []
|
||||
|
||||
@offline_strategy(name="collector", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
seen.append(event)
|
||||
|
||||
engine = OfflineEngine(config=cfg)
|
||||
engine.register(s)
|
||||
await engine.start()
|
||||
try:
|
||||
await engine.emit(_E())
|
||||
# Poll because APScheduler offers no completion signal; retry up to ~2.5s.
|
||||
for _ in range(50):
|
||||
if seen:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
finally:
|
||||
await engine.stop()
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_chain_emit_through_ctx(cfg: OMEConfig) -> None:
|
||||
seen_b: list = []
|
||||
|
||||
@offline_strategy(name="a_to_b", trigger=Immediate(on=[_A]), emits=[_B])
|
||||
async def s_a(event: _A, ctx: StrategyContext) -> None:
|
||||
await ctx.emit(_B())
|
||||
|
||||
@offline_strategy(name="b_collector", trigger=Immediate(on=[_B]), emits=[])
|
||||
async def s_b(event: _B, ctx: StrategyContext) -> None:
|
||||
seen_b.append(event)
|
||||
|
||||
engine = OfflineEngine(config=cfg)
|
||||
engine.register(s_a)
|
||||
engine.register(s_b)
|
||||
await engine.start()
|
||||
try:
|
||||
await engine.emit(_A())
|
||||
for _ in range(50):
|
||||
if seen_b:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
finally:
|
||||
await engine.stop()
|
||||
assert len(seen_b) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strategy_calling_engine_emit_directly_is_rejected(
|
||||
cfg: OMEConfig,
|
||||
) -> None:
|
||||
"""Strategy code must emit follow-up events through ctx.emit.
|
||||
|
||||
Calling engine.emit from inside a strategy raises
|
||||
EngineCallFromStrategyError (a StrategyContractError) so Runner
|
||||
short-circuits the retry budget and dead-letters on the very first
|
||||
attempt — re-running the same buggy code can't fix a programming bug.
|
||||
"""
|
||||
engine = OfflineEngine(config=cfg)
|
||||
|
||||
@offline_strategy(name="bad", trigger=Immediate(on=[_A]), emits=[_B])
|
||||
async def bad_strategy(event: _A, ctx: StrategyContext) -> None:
|
||||
# Captured engine reference is the common, intended pattern for
|
||||
# external triggers; using it from INSIDE a strategy is the
|
||||
# convention violation we want to catch.
|
||||
await engine.emit(_B())
|
||||
|
||||
engine.register(bad_strategy)
|
||||
await engine.start()
|
||||
try:
|
||||
await engine.emit(_A())
|
||||
for _ in range(50):
|
||||
runs = await engine.list_runs("bad")
|
||||
if runs and runs[0].status == RunStatus.DEAD_LETTER:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
runs = await engine.list_runs("bad")
|
||||
finally:
|
||||
await engine.stop()
|
||||
|
||||
assert runs, "expected at least one run record"
|
||||
# Permanent error → exactly one attempt, no retry.
|
||||
assert len(runs) == 1
|
||||
final = runs[0]
|
||||
assert final.status == RunStatus.DEAD_LETTER
|
||||
assert "EngineCallFromStrategyError" in (final.error or "")
|
||||
assert "emit" in (final.error or "")
|
||||
|
||||
|
||||
# Module-level singleton — proxies the "strategy reads engine via
|
||||
# globals/DI/import" pattern. Guard is contextvars-based so it catches
|
||||
# this path identically to the closure case.
|
||||
_MODULE_ENGINE: OfflineEngine | None = None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strategy_reaching_engine_via_module_global_is_rejected(
|
||||
cfg: OMEConfig,
|
||||
) -> None:
|
||||
"""The guard is contextvars-based: it doesn't matter how the strategy
|
||||
got the engine reference (closure, module singleton, DI container).
|
||||
"""
|
||||
global _MODULE_ENGINE
|
||||
_MODULE_ENGINE = OfflineEngine(config=cfg)
|
||||
|
||||
@offline_strategy(name="bad_global", trigger=Immediate(on=[_A]), emits=[_B])
|
||||
async def bad_strategy(event: _A, ctx: StrategyContext) -> None:
|
||||
assert _MODULE_ENGINE is not None
|
||||
await _MODULE_ENGINE.emit(_B())
|
||||
|
||||
_MODULE_ENGINE.register(bad_strategy)
|
||||
await _MODULE_ENGINE.start()
|
||||
try:
|
||||
await _MODULE_ENGINE.emit(_A())
|
||||
for _ in range(50):
|
||||
runs = await _MODULE_ENGINE.list_runs("bad_global")
|
||||
if runs and runs[0].status == RunStatus.DEAD_LETTER:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
runs = await _MODULE_ENGINE.list_runs("bad_global")
|
||||
finally:
|
||||
await _MODULE_ENGINE.stop()
|
||||
_MODULE_ENGINE = None
|
||||
|
||||
assert len(runs) == 1
|
||||
assert runs[0].status == RunStatus.DEAD_LETTER
|
||||
assert "EngineCallFromStrategyError" in (runs[0].error or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strategy_calling_other_engine_methods_is_rejected(
|
||||
cfg: OMEConfig,
|
||||
) -> None:
|
||||
"""The guard covers every public engine method, not just emit —
|
||||
strategies must interact with the engine only via (event, ctx).
|
||||
"""
|
||||
engine = OfflineEngine(config=cfg)
|
||||
|
||||
@offline_strategy(name="bad_lookup", trigger=Immediate(on=[_A]), emits=[])
|
||||
async def bad_strategy(event: _A, ctx: StrategyContext) -> None:
|
||||
# trigger_manual is another public engine method that strategies
|
||||
# must not call directly.
|
||||
await engine.trigger_manual("bad_lookup")
|
||||
|
||||
engine.register(bad_strategy)
|
||||
await engine.start()
|
||||
try:
|
||||
await engine.emit(_A())
|
||||
for _ in range(50):
|
||||
runs = await engine.list_runs("bad_lookup")
|
||||
if runs and runs[0].status == RunStatus.DEAD_LETTER:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
runs = await engine.list_runs("bad_lookup")
|
||||
finally:
|
||||
await engine.stop()
|
||||
|
||||
assert len(runs) == 1
|
||||
assert runs[0].status == RunStatus.DEAD_LETTER
|
||||
assert "EngineCallFromStrategyError" in (runs[0].error or "")
|
||||
assert "trigger_manual" in (runs[0].error or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_manual_with_default_event_uses_manual_tick(
|
||||
cfg: OMEConfig,
|
||||
) -> None:
|
||||
seen: list = []
|
||||
|
||||
from everos.infra.ome.events import ManualTick
|
||||
|
||||
@offline_strategy(
|
||||
name="manual_only",
|
||||
trigger=Immediate(on=[ManualTick]),
|
||||
emits=[],
|
||||
)
|
||||
async def s(event: ManualTick, ctx: StrategyContext) -> None:
|
||||
seen.append(event)
|
||||
|
||||
engine = OfflineEngine(config=cfg)
|
||||
engine.register(s)
|
||||
await engine.start()
|
||||
try:
|
||||
await engine.trigger_manual("manual_only")
|
||||
for _ in range(50):
|
||||
if seen:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
finally:
|
||||
await engine.stop()
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_manual_force_bypasses_enabled(
|
||||
cfg: OMEConfig,
|
||||
) -> None:
|
||||
seen: list = []
|
||||
from everos.infra.ome.events import ManualTick
|
||||
|
||||
@offline_strategy(
|
||||
name="off",
|
||||
trigger=Immediate(on=[ManualTick]),
|
||||
emits=[],
|
||||
enabled=False,
|
||||
)
|
||||
async def s(event: ManualTick, ctx: StrategyContext) -> None:
|
||||
seen.append(event)
|
||||
|
||||
engine = OfflineEngine(config=cfg)
|
||||
engine.register(s)
|
||||
await engine.start()
|
||||
try:
|
||||
await engine.trigger_manual("off", force=True)
|
||||
for _ in range(50):
|
||||
if seen:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
finally:
|
||||
await engine.stop()
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_dead_letter_callback_invoked(cfg: OMEConfig) -> None:
|
||||
calls: list = []
|
||||
|
||||
@offline_strategy(
|
||||
name="bad_dl", trigger=Immediate(on=[_E]), emits=[], max_retries=0
|
||||
)
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
raise RuntimeError("always-fail")
|
||||
|
||||
engine = OfflineEngine(config=cfg)
|
||||
engine.register(s)
|
||||
engine.on_dead_letter(lambda rec: calls.append(rec.run_id))
|
||||
await engine.start()
|
||||
try:
|
||||
await engine.emit(_E())
|
||||
for _ in range(50):
|
||||
if calls:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
finally:
|
||||
await engine.stop()
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_dispatch_returns_routes(cfg: OMEConfig) -> None:
|
||||
@offline_strategy(name="s_t24a", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
engine = OfflineEngine(config=cfg)
|
||||
engine.register(s)
|
||||
await engine.start()
|
||||
try:
|
||||
infos = await engine.inspect_dispatch(_E())
|
||||
assert len(infos) == 1
|
||||
assert infos[0].will_run is True
|
||||
finally:
|
||||
await engine.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_run_status_and_list(cfg: OMEConfig) -> None:
|
||||
@offline_strategy(name="s_t24b", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
engine = OfflineEngine(config=cfg)
|
||||
engine.register(s)
|
||||
await engine.start()
|
||||
try:
|
||||
await engine.emit(_E())
|
||||
# Poll because APScheduler offers no completion signal; up to ~2.5s.
|
||||
for _ in range(50):
|
||||
runs = await engine.list_runs("s_t24b")
|
||||
if runs and runs[0].status.value == "success":
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
runs = await engine.list_runs("s_t24b")
|
||||
assert len(runs) == 1
|
||||
rec = await engine.get_run_status(runs[0].run_id)
|
||||
assert rec is not None
|
||||
assert rec.status.value == "success"
|
||||
finally:
|
||||
await engine.stop()
|
||||
|
||||
|
||||
class _EventWithUid(BaseEvent):
|
||||
user_id: str
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_reschedule_cron_job_updates_aps(cfg: OMEConfig) -> None:
|
||||
@offline_strategy(name="cron_s", trigger=Cron(expr="0 3 * * *"), emits=[])
|
||||
async def s(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
engine = OfflineEngine(config=cfg)
|
||||
engine.register(s)
|
||||
await engine.start()
|
||||
try:
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
engine.reschedule_cron_job("cron_s", "*/5 * * * *")
|
||||
|
||||
job = engine._scheduler.get_job("cron::cron_s")
|
||||
assert isinstance(job.trigger, CronTrigger)
|
||||
# CronTrigger stores parsed crontab fields; minute step=5 means "*/5".
|
||||
minute_field = next(f for f in job.trigger.fields if f.name == "minute")
|
||||
assert str(minute_field) == "*/5"
|
||||
finally:
|
||||
await engine.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_reschedule_idle_job_updates_interval(cfg: OMEConfig) -> None:
|
||||
@offline_strategy(
|
||||
name="idle_s",
|
||||
trigger=Idle(
|
||||
on=[_EventWithUid],
|
||||
event_field="user_id",
|
||||
idle_seconds=60,
|
||||
scan_interval_seconds=30,
|
||||
),
|
||||
emits=[],
|
||||
)
|
||||
async def s(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
engine = OfflineEngine(config=cfg)
|
||||
engine.register(s)
|
||||
await engine.start()
|
||||
try:
|
||||
engine.reschedule_idle_job("idle_s", scan_interval_seconds=10)
|
||||
job = engine._scheduler.get_job("idle::idle_s")
|
||||
# IntervalTrigger.interval is a timedelta.
|
||||
assert job.trigger.interval.total_seconds() == 10
|
||||
finally:
|
||||
await engine.stop()
|
||||
|
||||
|
||||
def test_reschedule_cron_job_before_start_raises(cfg: OMEConfig) -> None:
|
||||
engine = OfflineEngine(config=cfg)
|
||||
with pytest.raises(OMEError, match="engine not started"):
|
||||
engine.reschedule_cron_job("x", "* * * * *")
|
||||
|
||||
|
||||
def test_reschedule_idle_job_before_start_raises(cfg: OMEConfig) -> None:
|
||||
engine = OfflineEngine(config=cfg)
|
||||
with pytest.raises(OMEError, match="engine not started"):
|
||||
engine.reschedule_idle_job("x", scan_interval_seconds=30)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_failure_cleans_up_engines_and_scheduler(
|
||||
cfg: OMEConfig, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A failure between scheduler start and ``_started = True`` must roll
|
||||
back: pop from the module-level ``_ENGINES`` registry, shut the
|
||||
scheduler thread down, and release the lock so a fresh ``OfflineEngine``
|
||||
can start on the same jobstore.
|
||||
"""
|
||||
from everos.infra.ome import engine as engine_mod
|
||||
|
||||
async def _boom(*args: Any, **kwargs: Any) -> None:
|
||||
raise RuntimeError("crash recovery exploded")
|
||||
|
||||
monkeypatch.setattr(engine_mod, "scan_and_resume", _boom)
|
||||
|
||||
engine = OfflineEngine(config=cfg)
|
||||
with pytest.raises(RuntimeError, match="crash recovery exploded"):
|
||||
await engine.start()
|
||||
|
||||
assert engine._engine_id not in engine_mod._ENGINES
|
||||
assert engine._scheduler is None
|
||||
assert engine._started is False
|
||||
assert engine._lock_handle is None
|
||||
|
||||
monkeypatch.undo()
|
||||
engine2 = OfflineEngine(config=cfg)
|
||||
await engine2.start()
|
||||
await engine2.stop()
|
||||
|
||||
|
||||
# ── active_runs / wait_idle ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_idle_returns_true_when_no_runs(cfg: OMEConfig) -> None:
|
||||
"""Pre-emit idle: counter starts at 0, idle_event starts set."""
|
||||
engine = OfflineEngine(config=cfg)
|
||||
await engine.start()
|
||||
try:
|
||||
assert engine._active_runs == 0
|
||||
assert await engine.wait_idle(timeout=0.5) is True
|
||||
finally:
|
||||
await engine.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_idle_blocks_until_strategy_finishes(cfg: OMEConfig) -> None:
|
||||
"""A strategy mid-flight keeps active_runs > 0 and idle_event clear
|
||||
until it completes."""
|
||||
release = asyncio.Event()
|
||||
entered = asyncio.Event()
|
||||
|
||||
@offline_strategy(name="slow", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def slow(event: _E, ctx: StrategyContext) -> None:
|
||||
entered.set()
|
||||
await release.wait()
|
||||
|
||||
engine = OfflineEngine(config=cfg)
|
||||
engine.register(slow)
|
||||
await engine.start()
|
||||
try:
|
||||
await engine.emit(_E())
|
||||
await asyncio.wait_for(entered.wait(), timeout=2.0)
|
||||
# Strategy is now mid-flight.
|
||||
assert engine._active_runs >= 1
|
||||
assert await engine.wait_idle(timeout=0.2) is False
|
||||
# Release the strategy and verify wait_idle resolves.
|
||||
release.set()
|
||||
assert await engine.wait_idle(timeout=2.0) is True
|
||||
assert engine._active_runs == 0
|
||||
finally:
|
||||
release.set()
|
||||
await engine.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_waits_for_in_flight_run_to_complete(cfg: OMEConfig) -> None:
|
||||
"""stop() must not cancel in-flight strategies. Pre-fix this used
|
||||
scheduler.shutdown(wait=True) which APS 3.x AsyncIOExecutor cancels
|
||||
silently; post-fix stop() drains through wait_idle first.
|
||||
"""
|
||||
completed: list[str] = []
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
@offline_strategy(name="slow_to_finish", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def slow(event: _E, ctx: StrategyContext) -> None:
|
||||
started.set()
|
||||
await release.wait()
|
||||
completed.append("done")
|
||||
|
||||
engine = OfflineEngine(config=cfg)
|
||||
engine.register(slow)
|
||||
await engine.start()
|
||||
await engine.emit(_E())
|
||||
await asyncio.wait_for(started.wait(), timeout=2.0)
|
||||
|
||||
# Stop concurrently with the in-flight strategy; release it after a
|
||||
# tick so stop() has to actually wait.
|
||||
stop_task = asyncio.create_task(engine.stop())
|
||||
await asyncio.sleep(0.05)
|
||||
assert not stop_task.done()
|
||||
release.set()
|
||||
await asyncio.wait_for(stop_task, timeout=5.0)
|
||||
assert completed == ["done"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_active_runs_decrements_on_strategy_exception(cfg: OMEConfig) -> None:
|
||||
"""A strategy that raises (and exhausts retries → DEAD_LETTER) must
|
||||
still release its counter — dispatch_run's finally guarantees -1.
|
||||
"""
|
||||
|
||||
@offline_strategy(name="boom", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def boom(event: _E, ctx: StrategyContext) -> None:
|
||||
raise RuntimeError("strategy boom")
|
||||
|
||||
cfg2 = OMEConfig(
|
||||
jobstore_path=cfg.jobstore_path,
|
||||
config_watch=False,
|
||||
max_retries=0,
|
||||
)
|
||||
engine = OfflineEngine(config=cfg2)
|
||||
engine.register(boom)
|
||||
await engine.start()
|
||||
try:
|
||||
await engine.emit(_E())
|
||||
assert await engine.wait_idle(timeout=2.0) is True
|
||||
runs = await engine.list_runs("boom")
|
||||
assert len(runs) == 1
|
||||
assert runs[0].status == RunStatus.DEAD_LETTER
|
||||
assert engine._active_runs == 0
|
||||
finally:
|
||||
await engine.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_run_rolls_back_counter_on_add_job_failure(
|
||||
cfg: OMEConfig, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""If APScheduler ``add_job`` raises, the matching dispatch_run never
|
||||
runs — _enqueue_run must roll back the pre-emptive +1 itself.
|
||||
"""
|
||||
|
||||
@offline_strategy(name="s", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
engine = OfflineEngine(config=cfg)
|
||||
engine.register(s)
|
||||
await engine.start()
|
||||
try:
|
||||
|
||||
def _boom(*args: Any, **kwargs: Any) -> None:
|
||||
raise RuntimeError("add_job exploded")
|
||||
|
||||
monkeypatch.setattr(engine._scheduler, "add_job", _boom)
|
||||
with pytest.raises(RuntimeError, match="add_job exploded"):
|
||||
await engine.emit(_E())
|
||||
assert engine._active_runs == 0
|
||||
assert await engine.wait_idle(timeout=0.5) is True
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
await engine.stop()
|
||||
49
tests/unit/test_infra/test_ome/test_events.py
Normal file
49
tests/unit/test_infra/test_ome/test_events.py
Normal file
@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from everos.infra.ome.events import BaseEvent, CronTick, IdleTick, ManualTick
|
||||
|
||||
|
||||
class _DemoEvent(BaseEvent):
|
||||
payload: str
|
||||
|
||||
|
||||
def test_base_event_auto_generates_id_and_ts() -> None:
|
||||
e = _DemoEvent(payload="x")
|
||||
assert isinstance(e.event_id, str)
|
||||
assert len(e.event_id) >= 32
|
||||
assert isinstance(e.ts, datetime)
|
||||
assert e.ts.tzinfo is not None
|
||||
|
||||
|
||||
def test_base_event_is_frozen() -> None:
|
||||
e = _DemoEvent(payload="x")
|
||||
try:
|
||||
e.payload = "y" # type: ignore[misc]
|
||||
except Exception:
|
||||
return
|
||||
raise AssertionError("BaseEvent should be frozen")
|
||||
|
||||
|
||||
def test_base_event_round_trips_json() -> None:
|
||||
e = _DemoEvent(payload="hello")
|
||||
blob = e.model_dump_json()
|
||||
restored = _DemoEvent.model_validate_json(blob)
|
||||
assert restored == e
|
||||
|
||||
|
||||
def test_cron_tick_carries_strategy_name() -> None:
|
||||
e = CronTick(strategy_name="profile_extraction")
|
||||
assert e.strategy_name == "profile_extraction"
|
||||
|
||||
|
||||
def test_idle_tick_carries_bucket_and_seconds() -> None:
|
||||
e = IdleTick(strategy_name="agent_skill", bucket_key="user_42", idle_seconds=900)
|
||||
assert e.bucket_key == "user_42"
|
||||
assert e.idle_seconds == 900
|
||||
|
||||
|
||||
def test_manual_tick_carries_strategy_name() -> None:
|
||||
e = ManualTick(strategy_name="cluster_memcells")
|
||||
assert e.strategy_name == "cluster_memcells"
|
||||
38
tests/unit/test_infra/test_ome/test_exceptions.py
Normal file
38
tests/unit/test_infra/test_ome/test_exceptions.py
Normal file
@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from everos.infra.ome.events import BaseEvent
|
||||
from everos.infra.ome.exceptions import (
|
||||
EmitNotDeclaredError,
|
||||
OMEError,
|
||||
StartupValidationError,
|
||||
)
|
||||
|
||||
|
||||
class _UnknownEvent(BaseEvent):
|
||||
pass
|
||||
|
||||
|
||||
def test_ome_error_is_base_exception() -> None:
|
||||
assert issubclass(OMEError, Exception)
|
||||
|
||||
|
||||
def test_startup_validation_error_inherits_ome_error() -> None:
|
||||
assert issubclass(StartupValidationError, OMEError)
|
||||
|
||||
|
||||
def test_emit_not_declared_error_inherits_ome_error() -> None:
|
||||
assert issubclass(EmitNotDeclaredError, OMEError)
|
||||
|
||||
|
||||
def test_emit_not_declared_carries_strategy_and_event() -> None:
|
||||
ev = _UnknownEvent()
|
||||
err = EmitNotDeclaredError(strategy="cluster_memcells", event=ev)
|
||||
assert err.strategy == "cluster_memcells"
|
||||
assert err.event is ev
|
||||
assert "_UnknownEvent" in str(err)
|
||||
assert "cluster_memcells" in str(err)
|
||||
|
||||
|
||||
def test_startup_validation_carries_message() -> None:
|
||||
err = StartupValidationError("missing trigger.on")
|
||||
assert "missing trigger.on" in str(err)
|
||||
29
tests/unit/test_infra/test_ome/test_gates.py
Normal file
29
tests/unit/test_infra/test_ome/test_gates.py
Normal file
@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from everos.infra.ome.gates import Counter
|
||||
|
||||
|
||||
def test_counter_accepts_threshold() -> None:
|
||||
c = Counter(threshold=5)
|
||||
assert c.threshold == 5
|
||||
assert c.cooldown_seconds == 0
|
||||
assert c.event_field is None
|
||||
|
||||
|
||||
def test_counter_with_bucket_field() -> None:
|
||||
c = Counter(threshold=5, event_field="user_id", cooldown_seconds=120)
|
||||
assert c.event_field == "user_id"
|
||||
assert c.cooldown_seconds == 120
|
||||
|
||||
|
||||
def test_counter_rejects_zero_threshold() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Counter(threshold=0)
|
||||
|
||||
|
||||
def test_counter_rejects_negative_cooldown() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Counter(threshold=5, cooldown_seconds=-1)
|
||||
109
tests/unit/test_infra/test_ome/test_idle_scanner.py
Normal file
109
tests/unit/test_infra/test_ome/test_idle_scanner.py
Normal file
@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.infra.ome._background.idle_scanner import IdleScanner
|
||||
from everos.infra.ome._stores.idle import IdleStore
|
||||
from everos.infra.ome._stores.storage import OMEStorage
|
||||
from everos.infra.ome.events import BaseEvent, IdleTick
|
||||
from everos.infra.ome.triggers import Idle
|
||||
|
||||
|
||||
class _M(BaseEvent):
|
||||
user_id: str = "u1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_once_emits_idle_ticks(tmp_path: Path) -> None:
|
||||
storage = OMEStorage(db_path=tmp_path / "ome.db")
|
||||
await storage.init()
|
||||
idle_store = IdleStore(storage=storage)
|
||||
now = datetime.now(UTC)
|
||||
await idle_store.touch("s", "u_old", at=now - timedelta(seconds=2000))
|
||||
await idle_store.touch("s", "u_fresh", at=now)
|
||||
|
||||
emitted: list[IdleTick] = []
|
||||
|
||||
async def emit(e: BaseEvent) -> None:
|
||||
if isinstance(e, IdleTick):
|
||||
emitted.append(e)
|
||||
|
||||
trigger = Idle(on=[_M], event_field="user_id", idle_seconds=900)
|
||||
scanner = IdleScanner(
|
||||
strategy_name="s",
|
||||
trigger=trigger,
|
||||
idle_store=idle_store,
|
||||
emit=emit,
|
||||
)
|
||||
await scanner.scan_once(now=now)
|
||||
assert {e.bucket_key for e in emitted} == {"u_old"}
|
||||
assert all(e.strategy_name == "s" for e in emitted)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_once_with_now_none_uses_current_time(tmp_path: Path) -> None:
|
||||
storage = OMEStorage(db_path=tmp_path / "ome.db")
|
||||
await storage.init()
|
||||
idle_store = IdleStore(storage=storage)
|
||||
now = datetime.now(UTC)
|
||||
# Insert bucket with activity timestamp older than the threshold
|
||||
await idle_store.touch("s", "u_overdue", at=now - timedelta(seconds=2000))
|
||||
|
||||
emitted: list[IdleTick] = []
|
||||
|
||||
async def emit(e: BaseEvent) -> None:
|
||||
if isinstance(e, IdleTick):
|
||||
emitted.append(e)
|
||||
|
||||
trigger = Idle(on=[_M], event_field="user_id", idle_seconds=900)
|
||||
scanner = IdleScanner(
|
||||
strategy_name="s",
|
||||
trigger=trigger,
|
||||
idle_store=idle_store,
|
||||
emit=emit,
|
||||
)
|
||||
# Call scan_once with no now= argument; should use current time internally
|
||||
await scanner.scan_once()
|
||||
# Should emit idle tick for overdue bucket
|
||||
assert len(emitted) >= 1
|
||||
assert any(e.bucket_key == "u_overdue" for e in emitted)
|
||||
assert all(e.strategy_name == "s" for e in emitted)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_once_isolates_failing_emit(tmp_path: Path) -> None:
|
||||
"""A single bucket's emit failure must not abort the rest of the
|
||||
scan. Mirrors dispatcher's _safe_applies isolation: one transient
|
||||
downstream error shouldn't drop sibling IdleTicks for this round.
|
||||
"""
|
||||
storage = OMEStorage(db_path=tmp_path / "ome.db")
|
||||
await storage.init()
|
||||
idle_store = IdleStore(storage=storage)
|
||||
now = datetime.now(UTC)
|
||||
# Three overdue buckets — middle one's emit will raise.
|
||||
for bucket in ("u_a", "u_boom", "u_c"):
|
||||
await idle_store.touch("s", bucket, at=now - timedelta(seconds=2000))
|
||||
|
||||
emitted: list[str] = []
|
||||
|
||||
async def emit(e: BaseEvent) -> None:
|
||||
if isinstance(e, IdleTick):
|
||||
if e.bucket_key == "u_boom":
|
||||
raise RuntimeError("downstream dispatch transient error")
|
||||
emitted.append(e.bucket_key)
|
||||
|
||||
trigger = Idle(on=[_M], event_field="user_id", idle_seconds=900)
|
||||
scanner = IdleScanner(
|
||||
strategy_name="s",
|
||||
trigger=trigger,
|
||||
idle_store=idle_store,
|
||||
emit=emit,
|
||||
)
|
||||
# Must NOT raise; emit failure for u_boom is swallowed + logged.
|
||||
await scanner.scan_once(now=now)
|
||||
|
||||
# Both sibling buckets still received their IdleTick.
|
||||
assert sorted(emitted) == ["u_a", "u_c"]
|
||||
95
tests/unit/test_infra/test_ome/test_idle_store.py
Normal file
95
tests/unit/test_infra/test_ome/test_idle_store.py
Normal file
@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.component.utils.datetime import get_now_with_timezone
|
||||
from everos.infra.ome._stores.idle import IdleStore
|
||||
from everos.infra.ome._stores.storage import OMEStorage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def store(tmp_path: Path) -> IdleStore:
|
||||
storage = OMEStorage(db_path=tmp_path / "ome.db")
|
||||
await storage.init()
|
||||
return IdleStore(storage=storage)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_touch_records_activity(store: IdleStore) -> None:
|
||||
now = get_now_with_timezone()
|
||||
await store.touch("s", "u1", at=now)
|
||||
last = await store.get_last_activity("s", "u1")
|
||||
assert last == now
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_idle_returns_overdue(store: IdleStore) -> None:
|
||||
now = get_now_with_timezone()
|
||||
old = now - timedelta(seconds=1000)
|
||||
fresh = now - timedelta(seconds=100)
|
||||
|
||||
await store.touch("s", "u_old", at=old)
|
||||
await store.touch("s", "u_fresh", at=fresh)
|
||||
|
||||
overdue = await store.scan_idle("s", idle_seconds=900, now=now)
|
||||
assert overdue == ["u_old"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_idle_empty_when_none_overdue(store: IdleStore) -> None:
|
||||
now = get_now_with_timezone()
|
||||
await store.touch("s", "u1", at=now)
|
||||
overdue = await store.scan_idle("s", idle_seconds=900, now=now)
|
||||
assert overdue == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_touch_updates_existing_row(store: IdleStore) -> None:
|
||||
early = get_now_with_timezone() - timedelta(seconds=500)
|
||||
late = get_now_with_timezone()
|
||||
await store.touch("s", "u1", at=early)
|
||||
await store.touch("s", "u1", at=late)
|
||||
assert await store.get_last_activity("s", "u1") == late
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_idle_returns_buckets_oldest_first(store: IdleStore) -> None:
|
||||
"""``scan_idle`` must return buckets in ascending ``last_activity_ts``
|
||||
order so IdleTick emission order is reproducible across SQLite versions
|
||||
and query plans.
|
||||
"""
|
||||
now = get_now_with_timezone()
|
||||
await store.touch("s", "u_mid", at=now - timedelta(seconds=1500))
|
||||
await store.touch("s", "u_oldest", at=now - timedelta(seconds=2000))
|
||||
await store.touch("s", "u_newest", at=now - timedelta(seconds=1100))
|
||||
|
||||
overdue = await store.scan_idle("s", idle_seconds=900, now=now)
|
||||
assert overdue == ["u_oldest", "u_mid", "u_newest"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_idle_uses_composite_index(tmp_path: Path) -> None:
|
||||
"""``scan_idle``'s SQL must keep ``last_activity_ts`` un-wrapped so
|
||||
the ``(strategy_name, last_activity_ts)`` index is honoured. Verify
|
||||
via EXPLAIN QUERY PLAN — if a future refactor wraps the column in a
|
||||
function/CAST again, this test fails immediately instead of waiting
|
||||
for a perf regression in production.
|
||||
"""
|
||||
storage = OMEStorage(db_path=tmp_path / "ome.db")
|
||||
await storage.init()
|
||||
|
||||
cutoff_iso = "2026-05-13T00:00:00+00:00"
|
||||
async with storage.connect() as conn:
|
||||
cur = await conn.execute(
|
||||
"EXPLAIN QUERY PLAN "
|
||||
"SELECT bucket_key FROM idle_store "
|
||||
"WHERE strategy_name = ? AND last_activity_ts <= ?",
|
||||
("s", cutoff_iso),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
|
||||
plan = " ".join(str(r) for r in rows)
|
||||
assert "idx_idle_scan" in plan, f"expected idx_idle_scan in plan, got: {plan}"
|
||||
177
tests/unit/test_infra/test_ome/test_records.py
Normal file
177
tests/unit/test_infra/test_ome/test_records.py
Normal file
@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from everos.component.utils.datetime import get_now_with_timezone
|
||||
from everos.infra.ome.records import RunRecord, RunStatus, StrategyRouteInfo
|
||||
|
||||
|
||||
def _ok_kwargs(**overrides: Any) -> dict[str, Any]:
|
||||
"""Build a baseline-valid RunRecord kwargs dict."""
|
||||
base: dict[str, Any] = {
|
||||
"run_id": "r1",
|
||||
"strategy_name": "s",
|
||||
"status": RunStatus.RUNNING,
|
||||
"attempt": 0,
|
||||
"started_at": get_now_with_timezone(),
|
||||
"event_topic": "x:Y",
|
||||
"event_payload": "{}",
|
||||
"max_retries_snapshot": 1,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_run_status_values() -> None:
|
||||
assert RunStatus.RUNNING.value == "running"
|
||||
assert RunStatus.SUCCESS.value == "success"
|
||||
assert RunStatus.FAILED.value == "failed"
|
||||
assert RunStatus.DEAD_LETTER.value == "dead_letter"
|
||||
assert RunStatus.CRASHED.value == "crashed"
|
||||
|
||||
|
||||
def test_run_record_minimal() -> None:
|
||||
rec = RunRecord(
|
||||
run_id="r1",
|
||||
strategy_name="cluster",
|
||||
status=RunStatus.RUNNING,
|
||||
attempt=0,
|
||||
started_at=get_now_with_timezone(),
|
||||
event_topic="my_app.events:EpisodeSaved",
|
||||
event_payload="{}",
|
||||
max_retries_snapshot=1,
|
||||
)
|
||||
assert rec.finished_at is None
|
||||
assert rec.error is None
|
||||
|
||||
|
||||
def test_run_record_round_trips_json() -> None:
|
||||
rec = RunRecord(
|
||||
run_id="r1",
|
||||
strategy_name="cluster",
|
||||
status=RunStatus.SUCCESS,
|
||||
attempt=0,
|
||||
started_at=get_now_with_timezone(),
|
||||
finished_at=get_now_with_timezone(),
|
||||
event_topic="x:Y",
|
||||
event_payload='{"a":1}',
|
||||
max_retries_snapshot=1,
|
||||
)
|
||||
blob = rec.model_dump_json()
|
||||
restored = RunRecord.model_validate_json(blob)
|
||||
assert restored == rec
|
||||
|
||||
|
||||
def test_strategy_route_info() -> None:
|
||||
info = StrategyRouteInfo(
|
||||
strategy_name="profile_extraction",
|
||||
enabled_pass=True,
|
||||
applies_to_pass=True,
|
||||
counter_pass=False,
|
||||
counter_progress=(3, 5),
|
||||
)
|
||||
assert info.will_run is False
|
||||
assert info.counter_progress == (3, 5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constraint enforcement: every Field(...) / validator must actually reject
|
||||
# the bad input it claims to reject. Add a test per declared constraint so
|
||||
# accidental relaxation in the future fails CI.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field",
|
||||
["run_id", "strategy_name", "event_topic", "event_payload"],
|
||||
)
|
||||
def test_run_record_rejects_empty_identifier(field: str) -> None:
|
||||
with pytest.raises(ValidationError, match=field):
|
||||
RunRecord(**_ok_kwargs(**{field: ""}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["attempt", "max_retries_snapshot"])
|
||||
def test_run_record_rejects_negative_int(field: str) -> None:
|
||||
with pytest.raises(ValidationError, match=field):
|
||||
RunRecord(**_ok_kwargs(**{field: -1}))
|
||||
|
||||
|
||||
def test_run_record_rejects_naive_started_at() -> None:
|
||||
naive = datetime(2026, 5, 12, 12, 0, 0) # noqa: DTZ001 — deliberately naive
|
||||
with pytest.raises(ValidationError, match="started_at"):
|
||||
RunRecord(**_ok_kwargs(started_at=naive))
|
||||
|
||||
|
||||
def test_run_record_rejects_empty_error_when_set() -> None:
|
||||
"""error=None is allowed; error="" is not (min_length=1)."""
|
||||
with pytest.raises(ValidationError, match="error"):
|
||||
RunRecord(
|
||||
**_ok_kwargs(
|
||||
status=RunStatus.FAILED,
|
||||
finished_at=get_now_with_timezone(),
|
||||
error="",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Status-invariant violations: each must be rejected by _check_status_invariants.
|
||||
|
||||
|
||||
def test_running_must_have_no_finished_at() -> None:
|
||||
with pytest.raises(ValidationError, match=r"RUNNING.*finished_at"):
|
||||
RunRecord(
|
||||
**_ok_kwargs(status=RunStatus.RUNNING, finished_at=get_now_with_timezone())
|
||||
)
|
||||
|
||||
|
||||
def test_running_must_have_no_error() -> None:
|
||||
with pytest.raises(ValidationError, match=r"RUNNING.*error"):
|
||||
RunRecord(**_ok_kwargs(status=RunStatus.RUNNING, error="boom"))
|
||||
|
||||
|
||||
def test_success_must_have_finished_at() -> None:
|
||||
with pytest.raises(ValidationError, match=r"success.*finished_at"):
|
||||
RunRecord(**_ok_kwargs(status=RunStatus.SUCCESS))
|
||||
|
||||
|
||||
def test_success_must_have_no_error() -> None:
|
||||
with pytest.raises(ValidationError, match=r"SUCCESS.*error"):
|
||||
RunRecord(
|
||||
**_ok_kwargs(
|
||||
status=RunStatus.SUCCESS,
|
||||
finished_at=get_now_with_timezone(),
|
||||
error="should not be here",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[RunStatus.FAILED, RunStatus.DEAD_LETTER, RunStatus.CRASHED],
|
||||
)
|
||||
def test_terminal_failure_must_have_finished_at(status: RunStatus) -> None:
|
||||
with pytest.raises(ValidationError, match="finished_at"):
|
||||
RunRecord(**_ok_kwargs(status=status, error="boom"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[RunStatus.FAILED, RunStatus.DEAD_LETTER, RunStatus.CRASHED],
|
||||
)
|
||||
def test_terminal_failure_must_have_error(status: RunStatus) -> None:
|
||||
with pytest.raises(ValidationError, match="error"):
|
||||
RunRecord(**_ok_kwargs(status=status, finished_at=get_now_with_timezone()))
|
||||
|
||||
|
||||
def test_strategy_route_info_rejects_empty_strategy_name() -> None:
|
||||
with pytest.raises(ValidationError, match="strategy_name"):
|
||||
StrategyRouteInfo(
|
||||
strategy_name="",
|
||||
enabled_pass=True,
|
||||
applies_to_pass=True,
|
||||
counter_pass=True,
|
||||
)
|
||||
262
tests/unit/test_infra/test_ome/test_registry.py
Normal file
262
tests/unit/test_infra/test_ome/test_registry.py
Normal file
@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.infra.ome._dispatch.registry import StrategyRegistry
|
||||
from everos.infra.ome.context import StrategyContext
|
||||
from everos.infra.ome.decorator import offline_strategy
|
||||
from everos.infra.ome.events import BaseEvent, CronTick, IdleTick, ManualTick
|
||||
from everos.infra.ome.exceptions import StartupValidationError
|
||||
from everos.infra.ome.triggers import Cron, Idle, Immediate
|
||||
|
||||
|
||||
class _A(BaseEvent):
|
||||
pass
|
||||
|
||||
|
||||
class _B(BaseEvent):
|
||||
pass
|
||||
|
||||
|
||||
def _make(
|
||||
name: str,
|
||||
on: list[type[BaseEvent]],
|
||||
emits: list[type[BaseEvent]],
|
||||
):
|
||||
@offline_strategy(name=name, trigger=Immediate(on=on), emits=emits)
|
||||
async def _f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
return _f
|
||||
|
||||
|
||||
def test_register_extracts_meta() -> None:
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make("s1", [_A], [_B]))
|
||||
assert reg.get("s1").name == "s1"
|
||||
|
||||
|
||||
def test_register_duplicate_raises() -> None:
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make("s1", [_A], [_B]))
|
||||
with pytest.raises(StartupValidationError):
|
||||
reg.register(_make("s1", [_A], [_B]))
|
||||
|
||||
|
||||
def test_register_non_decorated_raises() -> None:
|
||||
async def f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
reg = StrategyRegistry()
|
||||
with pytest.raises(StartupValidationError):
|
||||
reg.register(f)
|
||||
|
||||
|
||||
def test_replace_swaps_meta_in_place() -> None:
|
||||
from dataclasses import replace
|
||||
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make("s1", [_A], [_B]))
|
||||
original = reg.get("s1")
|
||||
new_meta = replace(original, max_retries=99)
|
||||
|
||||
reg.replace("s1", new_meta)
|
||||
|
||||
assert reg.get("s1").max_retries == 99
|
||||
assert reg.get("s1") is new_meta
|
||||
|
||||
|
||||
def test_replace_unknown_strategy_raises() -> None:
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make("s1", [_A], [_B]))
|
||||
existing = reg.get("s1")
|
||||
with pytest.raises(KeyError):
|
||||
reg.replace("missing", existing)
|
||||
|
||||
|
||||
def test_lookup_by_event() -> None:
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make("s_a", [_A], []))
|
||||
reg.register(_make("s_b", [_B], []))
|
||||
metas = reg.lookup_by_event(_A)
|
||||
assert {m.name for m in metas} == {"s_a"}
|
||||
|
||||
|
||||
def test_validate_detects_cycle() -> None:
|
||||
# s1 emits _B, listens _A; s2 emits _A, listens _B -> cycle
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make("s1", [_A], [_B]))
|
||||
reg.register(_make("s2", [_B], [_A]))
|
||||
with pytest.raises(StartupValidationError, match=r"(?i)cycle"):
|
||||
reg.validate()
|
||||
|
||||
|
||||
def test_validate_passes_dag() -> None:
|
||||
reg = StrategyRegistry()
|
||||
reg.register(_make("s1", [_A], [_B]))
|
||||
reg.register(_make("s2", [_B], []))
|
||||
reg.validate() # must not raise
|
||||
|
||||
|
||||
def test_lookup_by_event_finds_cron_strategy_for_cron_tick() -> None:
|
||||
reg = StrategyRegistry()
|
||||
|
||||
@offline_strategy(name="cron_s", trigger=Cron(expr="0 * * * *"), emits=[])
|
||||
async def f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
reg.register(f)
|
||||
metas = reg.lookup_by_event(CronTick)
|
||||
assert [m.name for m in metas] == ["cron_s"]
|
||||
|
||||
|
||||
def test_lookup_by_event_finds_idle_strategy_for_idle_tick() -> None:
|
||||
reg = StrategyRegistry()
|
||||
|
||||
@offline_strategy(
|
||||
name="idle_s",
|
||||
trigger=Idle(on=[_A], event_field="event_id", idle_seconds=900),
|
||||
emits=[],
|
||||
)
|
||||
async def f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
reg.register(f)
|
||||
metas = reg.lookup_by_event(IdleTick)
|
||||
assert [m.name for m in metas] == ["idle_s"]
|
||||
|
||||
|
||||
def test_lookup_by_event_returns_empty_for_manual_tick() -> None:
|
||||
reg = StrategyRegistry()
|
||||
|
||||
@offline_strategy(name="manual_s", trigger=Immediate(on=[_A]), emits=[])
|
||||
async def f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
reg.register(f)
|
||||
metas = reg.lookup_by_event(ManualTick)
|
||||
assert metas == []
|
||||
|
||||
|
||||
class _EventWithUid(BaseEvent):
|
||||
user_id: str
|
||||
|
||||
|
||||
class _EventWithoutUid(BaseEvent):
|
||||
other: str
|
||||
|
||||
|
||||
def test_validate_passes_when_gate_event_field_present() -> None:
|
||||
from everos.infra.ome.gates import Counter
|
||||
|
||||
reg = StrategyRegistry()
|
||||
|
||||
@offline_strategy(
|
||||
name="s",
|
||||
trigger=Immediate(on=[_EventWithUid]),
|
||||
emits=[],
|
||||
gate=Counter(threshold=3, event_field="user_id"),
|
||||
)
|
||||
async def f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
reg.register(f)
|
||||
reg.validate() # must not raise
|
||||
|
||||
|
||||
def test_validate_raises_when_gate_event_field_missing_on_immediate() -> None:
|
||||
from everos.infra.ome.gates import Counter
|
||||
|
||||
reg = StrategyRegistry()
|
||||
|
||||
@offline_strategy(
|
||||
name="s",
|
||||
trigger=Immediate(on=[_EventWithoutUid]),
|
||||
emits=[],
|
||||
gate=Counter(threshold=3, event_field="user_id"),
|
||||
)
|
||||
async def f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
reg.register(f)
|
||||
with pytest.raises(StartupValidationError) as exc:
|
||||
reg.validate()
|
||||
msg = str(exc.value)
|
||||
assert "user_id" in msg
|
||||
assert "_EventWithoutUid" in msg
|
||||
assert "s" in msg
|
||||
|
||||
|
||||
def test_validate_raises_when_gate_event_field_missing_in_one_of_multiple() -> None:
|
||||
from everos.infra.ome.gates import Counter
|
||||
|
||||
reg = StrategyRegistry()
|
||||
|
||||
@offline_strategy(
|
||||
name="s",
|
||||
trigger=Immediate(on=[_EventWithUid, _EventWithoutUid]),
|
||||
emits=[],
|
||||
gate=Counter(threshold=3, event_field="user_id"),
|
||||
)
|
||||
async def f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
reg.register(f)
|
||||
with pytest.raises(StartupValidationError):
|
||||
reg.validate()
|
||||
|
||||
|
||||
def test_validate_passes_when_gate_event_field_is_none() -> None:
|
||||
"""gate.event_field=None means global bucket; no field-existence check."""
|
||||
from everos.infra.ome.gates import Counter
|
||||
|
||||
reg = StrategyRegistry()
|
||||
|
||||
@offline_strategy(
|
||||
name="s",
|
||||
trigger=Immediate(on=[_EventWithoutUid]),
|
||||
emits=[],
|
||||
gate=Counter(threshold=3), # event_field defaults to None
|
||||
)
|
||||
async def f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
reg.register(f)
|
||||
reg.validate() # must not raise
|
||||
|
||||
|
||||
def test_validate_passes_when_no_gate() -> None:
|
||||
reg = StrategyRegistry()
|
||||
|
||||
@offline_strategy(
|
||||
name="s",
|
||||
trigger=Immediate(on=[_EventWithoutUid]),
|
||||
emits=[],
|
||||
)
|
||||
async def f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
reg.register(f)
|
||||
reg.validate() # must not raise
|
||||
|
||||
|
||||
def test_validate_raises_when_gate_event_field_missing_on_cron_tick() -> None:
|
||||
"""Cron strategy: gate.event_field must exist on CronTick."""
|
||||
from everos.infra.ome.gates import Counter
|
||||
|
||||
reg = StrategyRegistry()
|
||||
|
||||
@offline_strategy(
|
||||
name="cron_s",
|
||||
trigger=Cron(expr="0 3 * * *"),
|
||||
emits=[],
|
||||
gate=Counter(threshold=3, event_field="user_id"), # not in CronTick
|
||||
)
|
||||
async def f(event: Any, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
reg.register(f)
|
||||
with pytest.raises(StartupValidationError):
|
||||
reg.validate()
|
||||
144
tests/unit/test_infra/test_ome/test_run_record_store.py
Normal file
144
tests/unit/test_infra/test_ome/test_run_record_store.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""Tests for RunRecordStore persistence layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.component.utils.datetime import get_now_with_timezone
|
||||
from everos.infra.ome._stores.run_record import RunRecordStore
|
||||
from everos.infra.ome._stores.storage import OMEStorage
|
||||
from everos.infra.ome.records import RunStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def store(tmp_path: Path) -> RunRecordStore:
|
||||
storage = OMEStorage(db_path=tmp_path / "ome.db")
|
||||
await storage.init()
|
||||
return RunRecordStore(storage=storage, max_records_per_strategy=3)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_running_inserts_row(store: RunRecordStore) -> None:
|
||||
await store.mark_running(
|
||||
run_id="r1",
|
||||
strategy_name="s",
|
||||
attempt=0,
|
||||
event_topic="x:Y",
|
||||
event_payload="{}",
|
||||
max_retries_snapshot=1,
|
||||
)
|
||||
rec = await store.get("r1")
|
||||
assert rec is not None
|
||||
assert rec.status == RunStatus.RUNNING
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_success_updates_row(store: RunRecordStore) -> None:
|
||||
await store.mark_running(
|
||||
run_id="r1",
|
||||
strategy_name="s",
|
||||
attempt=0,
|
||||
event_topic="x:Y",
|
||||
event_payload="{}",
|
||||
max_retries_snapshot=1,
|
||||
)
|
||||
await store.mark_success(run_id="r1", finished_at=get_now_with_timezone())
|
||||
rec = await store.get("r1")
|
||||
assert rec.status == RunStatus.SUCCESS
|
||||
assert rec.finished_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_failed_records_error(store: RunRecordStore) -> None:
|
||||
await store.mark_running(
|
||||
run_id="r1",
|
||||
strategy_name="s",
|
||||
attempt=0,
|
||||
event_topic="x:Y",
|
||||
event_payload="{}",
|
||||
max_retries_snapshot=1,
|
||||
)
|
||||
await store.mark_failed(
|
||||
run_id="r1", finished_at=get_now_with_timezone(), error="boom"
|
||||
)
|
||||
rec = await store.get("r1")
|
||||
assert rec.status == RunStatus.FAILED
|
||||
assert rec.error == "boom"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_dead_letter(store: RunRecordStore) -> None:
|
||||
await store.mark_running(
|
||||
run_id="r1",
|
||||
strategy_name="s",
|
||||
attempt=2,
|
||||
event_topic="x:Y",
|
||||
event_payload="{}",
|
||||
max_retries_snapshot=2,
|
||||
)
|
||||
await store.mark_dead_letter(
|
||||
run_id="r1", finished_at=get_now_with_timezone(), error="exhausted"
|
||||
)
|
||||
rec = await store.get("r1")
|
||||
assert rec.status == RunStatus.DEAD_LETTER
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ring_buffer_caps_strategy_records(store: RunRecordStore) -> None:
|
||||
"""Trim runs inside the same transaction as each ``mark_running``
|
||||
insert; the per-strategy row count never exceeds the cap.
|
||||
"""
|
||||
for i in range(5):
|
||||
await store.mark_running(
|
||||
run_id=f"r{i}",
|
||||
strategy_name="s",
|
||||
attempt=0,
|
||||
event_topic="x:Y",
|
||||
event_payload="{}",
|
||||
max_retries_snapshot=1,
|
||||
)
|
||||
listed = await store.list_runs(strategy_name="s")
|
||||
assert len(listed) <= 3 # never transiently above cap
|
||||
|
||||
listed = await store.list_runs(strategy_name="s")
|
||||
assert [r.run_id for r in listed] == ["r4", "r3", "r2"] # newest 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_runs_filters_by_status(store: RunRecordStore) -> None:
|
||||
await store.mark_running(
|
||||
run_id="r1",
|
||||
strategy_name="s",
|
||||
attempt=0,
|
||||
event_topic="x:Y",
|
||||
event_payload="{}",
|
||||
max_retries_snapshot=1,
|
||||
)
|
||||
await store.mark_success(run_id="r1", finished_at=get_now_with_timezone())
|
||||
await store.mark_running(
|
||||
run_id="r2",
|
||||
strategy_name="s",
|
||||
attempt=0,
|
||||
event_topic="x:Y",
|
||||
event_payload="{}",
|
||||
max_retries_snapshot=1,
|
||||
)
|
||||
success_runs = await store.list_runs(strategy_name="s", status=RunStatus.SUCCESS)
|
||||
assert [r.run_id for r in success_runs] == ["r1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_running_for_crash_recovery(store: RunRecordStore) -> None:
|
||||
await store.mark_running(
|
||||
run_id="r1",
|
||||
strategy_name="s",
|
||||
attempt=0,
|
||||
event_topic="x:Y",
|
||||
event_payload="{}",
|
||||
max_retries_snapshot=1,
|
||||
)
|
||||
running = await store.find_running()
|
||||
assert len(running) == 1
|
||||
assert running[0].run_id == "r1"
|
||||
223
tests/unit/test_infra/test_ome/test_runner.py
Normal file
223
tests/unit/test_infra/test_ome/test_runner.py
Normal file
@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.infra.ome._dispatch.runner import Runner
|
||||
from everos.infra.ome._stores.run_record import RunRecordStore
|
||||
from everos.infra.ome._stores.storage import OMEStorage
|
||||
from everos.infra.ome.context import StrategyContext
|
||||
from everos.infra.ome.decorator import offline_strategy
|
||||
from everos.infra.ome.events import BaseEvent
|
||||
from everos.infra.ome.records import RunStatus
|
||||
from everos.infra.ome.triggers import Immediate
|
||||
|
||||
|
||||
class _E(BaseEvent):
|
||||
user_id: str = "u1"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def setup(tmp_path: Path):
|
||||
storage = OMEStorage(db_path=tmp_path / "ome.db")
|
||||
await storage.init()
|
||||
rec_store = RunRecordStore(storage=storage, max_records_per_strategy=1000)
|
||||
sem = asyncio.Semaphore(20)
|
||||
return rec_store, sem
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_success_marks_record(setup) -> None:
|
||||
rec_store, sem = setup
|
||||
|
||||
@offline_strategy(name="ok", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
runner = Runner(
|
||||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
)
|
||||
await runner.run(
|
||||
s._ome_strategy_meta,
|
||||
_E(),
|
||||
run_id="r1",
|
||||
max_retries_snapshot=1,
|
||||
)
|
||||
|
||||
rec = await rec_store.get("r1")
|
||||
assert rec.status == RunStatus.SUCCESS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_retries_on_failure(setup) -> None:
|
||||
rec_store, sem = setup
|
||||
calls = {"n": 0}
|
||||
|
||||
@offline_strategy(
|
||||
name="flaky",
|
||||
trigger=Immediate(on=[_E]),
|
||||
emits=[],
|
||||
max_retries=2,
|
||||
)
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 3:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
runner = Runner(
|
||||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
)
|
||||
await runner.run(
|
||||
s._ome_strategy_meta,
|
||||
_E(),
|
||||
run_id="r1",
|
||||
max_retries_snapshot=2,
|
||||
)
|
||||
assert calls["n"] == 3
|
||||
# Final successful attempt 2 has a new run_id (not "r1");
|
||||
# find by status=SUCCESS, strategy_name=flaky
|
||||
success_runs = await rec_store.list_runs(
|
||||
strategy_name="flaky",
|
||||
status=RunStatus.SUCCESS,
|
||||
)
|
||||
assert len(success_runs) == 1
|
||||
assert success_runs[0].attempt == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_dead_letter_after_exhaust(setup) -> None:
|
||||
rec_store, sem = setup
|
||||
|
||||
@offline_strategy(
|
||||
name="bad",
|
||||
trigger=Immediate(on=[_E]),
|
||||
emits=[],
|
||||
max_retries=1,
|
||||
)
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
raise RuntimeError("always-fail")
|
||||
|
||||
dl_calls: list = []
|
||||
|
||||
runner = Runner(
|
||||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
on_dead_letter=lambda r: dl_calls.append(r),
|
||||
)
|
||||
await runner.run(
|
||||
s._ome_strategy_meta,
|
||||
_E(),
|
||||
run_id="r1",
|
||||
max_retries_snapshot=1,
|
||||
)
|
||||
dead_runs = await rec_store.list_runs(
|
||||
strategy_name="bad",
|
||||
status=RunStatus.DEAD_LETTER,
|
||||
)
|
||||
assert len(dead_runs) == 1
|
||||
assert len(dl_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_emit_must_be_declared(setup) -> None:
|
||||
rec_store, sem = setup
|
||||
|
||||
class _Other(BaseEvent):
|
||||
pass
|
||||
|
||||
@offline_strategy(
|
||||
name="emit_undeclared",
|
||||
trigger=Immediate(on=[_E]),
|
||||
emits=[],
|
||||
)
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
await ctx.emit(_Other()) # not declared
|
||||
|
||||
runner = Runner(
|
||||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
)
|
||||
await runner.run(
|
||||
s._ome_strategy_meta,
|
||||
_E(),
|
||||
run_id="r1",
|
||||
max_retries_snapshot=0,
|
||||
)
|
||||
rec = await rec_store.get("r1")
|
||||
assert rec.status == RunStatus.DEAD_LETTER
|
||||
assert "EmitNotDeclaredError" in (rec.error or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_negative_max_retries_raises(setup) -> None:
|
||||
"""``max_retries_snapshot < 0`` is an internal-bug condition (Pydantic
|
||||
constrains the user-supplied source to ``>= 0``), so the framework
|
||||
fails fast rather than silently no-op the run.
|
||||
"""
|
||||
rec_store, sem = setup
|
||||
|
||||
@offline_strategy(name="ok", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
runner = Runner(
|
||||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
)
|
||||
with pytest.raises(ValueError, match=r"max_retries_snapshot must be >= 0"):
|
||||
await runner.run(
|
||||
s._ome_strategy_meta,
|
||||
_E(),
|
||||
run_id="r1",
|
||||
max_retries_snapshot=-1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_aborts_silently_when_mark_running_fails(
|
||||
setup, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""When persistence itself fails before the strategy is invoked,
|
||||
the run must exit cleanly (no exception escaping the framework) and
|
||||
the strategy body must NOT execute — no RUNNING row exists for
|
||||
crash recovery to pick up, so re-execution via recovery is
|
||||
impossible. The emergency log is the only audit trail.
|
||||
"""
|
||||
rec_store, sem = setup
|
||||
called = {"n": 0}
|
||||
|
||||
@offline_strategy(name="ok", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
called["n"] += 1
|
||||
|
||||
async def _boom(**_: object) -> None:
|
||||
raise RuntimeError("disk_full")
|
||||
|
||||
monkeypatch.setattr(rec_store, "mark_running", _boom)
|
||||
|
||||
runner = Runner(
|
||||
run_record_store=rec_store,
|
||||
engine_sem=sem,
|
||||
emit_hook=_no_emit,
|
||||
)
|
||||
# Must NOT raise; the framework swallows + logs.
|
||||
await runner.run(
|
||||
s._ome_strategy_meta,
|
||||
_E(),
|
||||
run_id="r1",
|
||||
max_retries_snapshot=1,
|
||||
)
|
||||
assert called["n"] == 0
|
||||
|
||||
|
||||
async def _no_emit(event: BaseEvent) -> None:
|
||||
return None
|
||||
144
tests/unit/test_infra/test_ome/test_storage.py
Normal file
144
tests/unit/test_infra/test_ome/test_storage.py
Normal file
@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
|
||||
from everos.infra.ome._stores.storage import OMEStorage
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storage_creates_db_and_tables(tmp_path: Path) -> None:
|
||||
db = tmp_path / "ome.db"
|
||||
storage = OMEStorage(db_path=db)
|
||||
await storage.init()
|
||||
|
||||
assert db.exists()
|
||||
async with aiosqlite.connect(db) as conn:
|
||||
cur = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
)
|
||||
names = {row[0] for row in await cur.fetchall()}
|
||||
assert {"counter_store", "idle_store", "run_record"}.issubset(names)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storage_applies_pragmas(tmp_path: Path) -> None:
|
||||
db = tmp_path / "ome.db"
|
||||
storage = OMEStorage(db_path=db)
|
||||
await storage.init()
|
||||
|
||||
async with aiosqlite.connect(db) as conn:
|
||||
cur = await conn.execute("PRAGMA journal_mode")
|
||||
mode = (await cur.fetchone())[0]
|
||||
assert mode == "wal"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storage_init_is_idempotent(tmp_path: Path) -> None:
|
||||
db = tmp_path / "ome.db"
|
||||
storage = OMEStorage(db_path=db)
|
||||
await storage.init()
|
||||
await storage.init() # second call must not raise
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storage_creates_parent_dir(tmp_path: Path) -> None:
|
||||
db = tmp_path / "nested" / "dir" / "ome.db"
|
||||
storage = OMEStorage(db_path=db)
|
||||
await storage.init()
|
||||
assert db.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storage_connect_applies_per_connection_pragmas(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""``synchronous`` and ``cache_size`` are per-connection PRAGMAs:
|
||||
SQLite resets them to defaults on every new connection. The
|
||||
``OMEStorage.connect`` wrapper must re-apply them or the module
|
||||
docstring's promise is silently broken.
|
||||
"""
|
||||
db = tmp_path / "ome.db"
|
||||
storage = OMEStorage(db_path=db)
|
||||
await storage.init()
|
||||
|
||||
async with storage.connect() as conn:
|
||||
sync_row = await (await conn.execute("PRAGMA synchronous")).fetchone()
|
||||
cache_row = await (await conn.execute("PRAGMA cache_size")).fetchone()
|
||||
busy_row = await (await conn.execute("PRAGMA busy_timeout")).fetchone()
|
||||
|
||||
# synchronous: 0=OFF, 1=NORMAL, 2=FULL, 3=EXTRA
|
||||
assert sync_row[0] == 1
|
||||
# cache_size: negative value is "kibibytes of memory"
|
||||
assert cache_row[0] == -65536
|
||||
# busy_timeout: ms before SQLITE_BUSY is raised on contended writes
|
||||
assert busy_row[0] == 5000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storage_raw_aiosqlite_connect_does_not_carry_per_conn_pragmas(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Sanity check that documents why :meth:`OMEStorage.connect` exists:
|
||||
opening the same db with raw ``aiosqlite.connect`` yields a connection
|
||||
where ``synchronous`` is at SQLite's default (FULL=2), not NORMAL.
|
||||
"""
|
||||
db = tmp_path / "ome.db"
|
||||
storage = OMEStorage(db_path=db)
|
||||
await storage.init()
|
||||
|
||||
async with aiosqlite.connect(db) as conn:
|
||||
sync_row = await (await conn.execute("PRAGMA synchronous")).fetchone()
|
||||
|
||||
assert sync_row[0] == 2 # default FULL — confirms scope is per-connection
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storage_transaction_commits_on_success(tmp_path: Path) -> None:
|
||||
db = tmp_path / "ome.db"
|
||||
storage = OMEStorage(db_path=db)
|
||||
await storage.init()
|
||||
|
||||
async with storage.transaction() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO counter_store (strategy_name, bucket_key, counter) "
|
||||
"VALUES (?, ?, ?)",
|
||||
("s", "u1", 42),
|
||||
)
|
||||
|
||||
async with storage.connect() as conn:
|
||||
cur = await conn.execute(
|
||||
"SELECT counter FROM counter_store WHERE strategy_name=? AND bucket_key=?",
|
||||
("s", "u1"),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
assert row is not None and row[0] == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_storage_transaction_rolls_back_on_exception(tmp_path: Path) -> None:
|
||||
db = tmp_path / "ome.db"
|
||||
storage = OMEStorage(db_path=db)
|
||||
await storage.init()
|
||||
|
||||
class _BoomError(Exception):
|
||||
pass
|
||||
|
||||
with pytest.raises(_BoomError):
|
||||
async with storage.transaction() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO counter_store (strategy_name, bucket_key, counter) "
|
||||
"VALUES (?, ?, ?)",
|
||||
("s", "u1", 42),
|
||||
)
|
||||
raise _BoomError
|
||||
|
||||
async with storage.connect() as conn:
|
||||
cur = await conn.execute(
|
||||
"SELECT counter FROM counter_store WHERE strategy_name=? AND bucket_key=?",
|
||||
("s", "u1"),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
assert row is None
|
||||
44
tests/unit/test_infra/test_ome/test_testing_harness.py
Normal file
44
tests/unit/test_infra/test_ome/test_testing_harness.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""Tests for OME testing helpers (FakeStrategyContext + StrategyTestHarness)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.infra.ome.context import StrategyContext
|
||||
from everos.infra.ome.decorator import offline_strategy
|
||||
from everos.infra.ome.events import BaseEvent
|
||||
from everos.infra.ome.testing import FakeStrategyContext, StrategyTestHarness
|
||||
from everos.infra.ome.triggers import Immediate
|
||||
|
||||
|
||||
class _E(BaseEvent):
|
||||
"""Simple test event."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fake_strategy_context_collects_emits() -> None:
|
||||
"""FakeStrategyContext should collect emit() calls into a list."""
|
||||
ctx = FakeStrategyContext()
|
||||
await ctx.emit(_E())
|
||||
assert len(ctx.emitted) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_harness_runs_strategy_end_to_end() -> None:
|
||||
"""StrategyTestHarness should execute a strategy end-to-end."""
|
||||
seen: list[BaseEvent] = []
|
||||
|
||||
@offline_strategy(name="s_t23", trigger=Immediate(on=[_E]), emits=[])
|
||||
async def s(event: _E, ctx: StrategyContext) -> None:
|
||||
seen.append(event)
|
||||
|
||||
async with StrategyTestHarness() as h:
|
||||
h.register(s)
|
||||
await h.start()
|
||||
await h.emit(_E())
|
||||
await h.drain(timeout=5)
|
||||
runs = await h.list_runs("s_t23")
|
||||
assert len(runs) == 1
|
||||
assert len(seen) == 1
|
||||
150
tests/unit/test_infra/test_ome/test_triggers.py
Normal file
150
tests/unit/test_infra/test_ome/test_triggers.py
Normal file
@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from everos.infra.ome.events import BaseEvent
|
||||
from everos.infra.ome.triggers import Cron, Idle, Immediate
|
||||
|
||||
|
||||
class _A(BaseEvent):
|
||||
pass
|
||||
|
||||
|
||||
class _B(BaseEvent):
|
||||
pass
|
||||
|
||||
|
||||
class _EventWithUserId(BaseEvent):
|
||||
user_id: str
|
||||
|
||||
|
||||
def test_immediate_accepts_event_classes() -> None:
|
||||
t = Immediate(on=[_A, _B])
|
||||
assert t.on == [_A, _B]
|
||||
|
||||
|
||||
def test_immediate_rejects_empty_on() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Immediate(on=[])
|
||||
|
||||
|
||||
def test_cron_accepts_expression() -> None:
|
||||
t = Cron(expr="0 3 * * *")
|
||||
assert t.expr == "0 3 * * *"
|
||||
|
||||
|
||||
def test_cron_rejects_blank() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Cron(expr="")
|
||||
|
||||
|
||||
def test_idle_defaults_scan_interval() -> None:
|
||||
t = Idle(on=[_EventWithUserId], event_field="user_id", idle_seconds=900)
|
||||
assert t.scan_interval_seconds == 60
|
||||
|
||||
|
||||
def test_idle_rejects_negative_idle_seconds() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Idle(on=[_EventWithUserId], event_field="user_id", idle_seconds=-1)
|
||||
|
||||
|
||||
def test_cron_accepts_valid_crontab() -> None:
|
||||
t = Cron(expr="0 3 * * *")
|
||||
assert t.expr == "0 3 * * *"
|
||||
|
||||
|
||||
def test_cron_rejects_invalid_crontab() -> None:
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
Cron(expr="not a cron")
|
||||
assert "expr" in str(exc.value)
|
||||
|
||||
|
||||
def test_cron_rejects_out_of_range_field() -> None:
|
||||
# APS raises on out-of-range fields (e.g. minute=60)
|
||||
with pytest.raises(ValidationError):
|
||||
Cron(expr="60 0 * * *")
|
||||
|
||||
|
||||
def test_idle_accepts_existing_event_field() -> None:
|
||||
t = Idle(
|
||||
on=[_EventWithUserId],
|
||||
event_field="user_id",
|
||||
idle_seconds=30,
|
||||
scan_interval_seconds=10,
|
||||
)
|
||||
assert t.event_field == "user_id"
|
||||
|
||||
|
||||
def test_idle_rejects_missing_event_field() -> None:
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
Idle(on=[_EventWithUserId], event_field="bad_name", idle_seconds=30)
|
||||
msg = str(exc.value)
|
||||
assert "bad_name" in msg
|
||||
assert "user_id" in msg
|
||||
|
||||
|
||||
def test_idle_validator_runs_on_model_validate() -> None:
|
||||
base = Idle(
|
||||
on=[_EventWithUserId],
|
||||
event_field="user_id",
|
||||
idle_seconds=30,
|
||||
scan_interval_seconds=10,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
Idle.model_validate({**base.model_dump(), "event_field": "nope"})
|
||||
|
||||
|
||||
class _AnotherEventWithUserId(BaseEvent):
|
||||
user_id: str
|
||||
|
||||
|
||||
class _EventWithoutUserId(BaseEvent):
|
||||
other: str
|
||||
|
||||
|
||||
def test_idle_accepts_multiple_event_classes() -> None:
|
||||
t = Idle(
|
||||
on=[_EventWithUserId, _AnotherEventWithUserId],
|
||||
event_field="user_id",
|
||||
idle_seconds=30,
|
||||
scan_interval_seconds=10,
|
||||
)
|
||||
assert t.on == [_EventWithUserId, _AnotherEventWithUserId]
|
||||
|
||||
|
||||
def test_idle_rejects_event_field_missing_in_any_class() -> None:
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
Idle(
|
||||
on=[_EventWithUserId, _EventWithoutUserId],
|
||||
event_field="user_id",
|
||||
idle_seconds=30,
|
||||
scan_interval_seconds=10,
|
||||
)
|
||||
msg = str(exc.value)
|
||||
assert "user_id" in msg
|
||||
assert "_EventWithoutUserId" in msg
|
||||
|
||||
|
||||
def test_idle_rejects_scan_interval_exceeding_half_idle() -> None:
|
||||
"""The Idle docstring promises scan cadence <= idle_seconds // 2 so the
|
||||
scanner has at least two chances to observe an idle bucket before its
|
||||
silence window expires."""
|
||||
with pytest.raises(ValidationError, match="scan_interval_seconds"):
|
||||
Idle(
|
||||
on=[_EventWithUserId],
|
||||
event_field="user_id",
|
||||
idle_seconds=30,
|
||||
scan_interval_seconds=20,
|
||||
)
|
||||
|
||||
|
||||
def test_idle_accepts_scan_interval_at_half_idle() -> None:
|
||||
"""Boundary: scan_interval_seconds == idle_seconds // 2 is accepted."""
|
||||
t = Idle(
|
||||
on=[_EventWithUserId],
|
||||
event_field="user_id",
|
||||
idle_seconds=60,
|
||||
scan_interval_seconds=30,
|
||||
)
|
||||
assert t.scan_interval_seconds == 30
|
||||
Reference in New Issue
Block a user