81 lines
2.7 KiB
Python
81 lines
2.7 KiB
Python
"""Incremental materialized view projection."""
|
|
|
|
from rune.orchestration.projection import ProjectionEngine, state_hash
|
|
from rune.orchestration.records import ProjectionCheckpoint
|
|
|
|
|
|
def _count_reducer(state: dict, event: dict) -> dict:
|
|
t = event["event_type"]
|
|
state[t] = state.get(t, 0) + 1
|
|
return state
|
|
|
|
|
|
def test_apply_accumulates_and_hash_changes():
|
|
engine = ProjectionEngine(_count_reducer)
|
|
engine.apply({"event_type": "a"})
|
|
first = engine.checkpoint()
|
|
engine.apply({"event_type": "a"})
|
|
second = engine.checkpoint()
|
|
assert engine.state() == {"a": 2}
|
|
assert second.offset == first.offset + 1
|
|
assert second.state_hash != first.state_hash
|
|
|
|
|
|
def test_rebuild_from_checkpoint_reproduces_exact_state():
|
|
engine = ProjectionEngine(_count_reducer)
|
|
events = [{"event_type": "a"}, {"event_type": "b"}, {"event_type": "a"}]
|
|
for ev in events:
|
|
engine.apply(ev)
|
|
cp = engine.checkpoint()
|
|
rebuilt = ProjectionEngine(_count_reducer).rebuild(events, cp)
|
|
assert rebuilt == engine.state()
|
|
assert state_hash(rebuilt) == cp.state_hash
|
|
|
|
|
|
def test_rebuild_from_scratch_without_checkpoint():
|
|
events = [{"event_type": "a"}, {"event_type": "b"}]
|
|
rebuilt = ProjectionEngine(_count_reducer).rebuild(events, None)
|
|
assert rebuilt == {"a": 1, "b": 1}
|
|
|
|
|
|
def test_hash_is_ordered_and_deterministic():
|
|
s1 = state_hash({"a": 1, "b": 2})
|
|
s2 = state_hash({"b": 2, "a": 1})
|
|
s3 = state_hash({"a": 2, "b": 1})
|
|
assert s1 == s2 # dict key order does not matter
|
|
assert s1 != s3
|
|
|
|
|
|
def test_rebuild_detects_truncated_log(tmp_path):
|
|
engine = ProjectionEngine(_count_reducer)
|
|
events = [{"event_type": "a"}, {"event_type": "b"}]
|
|
for ev in events:
|
|
engine.apply(ev)
|
|
cp = engine.checkpoint()
|
|
truncated = events[:1] # log lost the second event
|
|
try:
|
|
ProjectionEngine(_count_reducer).rebuild(truncated, cp)
|
|
raise AssertionError("expected ProjectionCorruptError")
|
|
except ProjectionEngine.ProjectionCorruptError:
|
|
pass
|
|
|
|
|
|
def test_rebuild_rejects_offset_beyond_log():
|
|
events = [{"event_type": "a"}]
|
|
cp = ProjectionCheckpoint(offset=5, state_hash="sha256:x")
|
|
try:
|
|
ProjectionEngine(_count_reducer).rebuild(events, cp)
|
|
raise AssertionError("expected ProjectionCorruptError")
|
|
except ProjectionEngine.ProjectionCorruptError:
|
|
pass
|
|
|
|
|
|
def test_rebuild_from_valid_checkpoint_still_passes():
|
|
engine = ProjectionEngine(_count_reducer)
|
|
events = [{"event_type": "a"}, {"event_type": "a"}]
|
|
for ev in events:
|
|
engine.apply(ev)
|
|
cp = engine.checkpoint()
|
|
rebuilt = ProjectionEngine(_count_reducer).rebuild(events, cp)
|
|
assert rebuilt == engine.state()
|