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:
Elliot Chen
2026-06-05 22:35:51 +08:00
commit 518b8eca85
636 changed files with 160553 additions and 0 deletions

View 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

View 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()

View 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)

View 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)

View 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

View 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

View 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

View 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

View 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()

View 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"

View 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)

View 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)

View 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"]

View 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}"

View 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,
)

View 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()

View 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"

View 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

View 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

View 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

View 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