Files
app/backend/tests/test_rename_journal.py

601 lines
22 KiB
Python

"""A key rename's journal, its steps and what the next start does with one a
crash interrupted (section 4 of docs/private/id_refac_rename.md). Memory
always, Redis with FLUKSIO_TEST_REDIS_HOST (and optional PORT)."""
import os
import shutil
import subprocess
import uuid
from collections import Counter
from collections.abc import Iterator
from datetime import UTC, datetime
from pathlib import Path
from types import SimpleNamespace
import pytest
from sqlmodel import Session, col, delete, select
from fluksio.core.config import settings
from fluksio.core.db import engine
from fluksio.flow import identity
from fluksio.flow import rename as renaming
from fluksio.flow import state as state_module
from fluksio.flow.alerts import AlertsConfig, Channel
from fluksio.flow.controller import FlowController
from fluksio.flow.dashboards import DashboardDef, DashboardStore, WidgetDef
from fluksio.flow.executor import ExecutionService
from fluksio.flow.identity import plan_ids
from fluksio.flow.messages import MessageSpec
from fluksio.flow.namespace import Rename
from fluksio.flow.queue import MemoryWorkQueue, RedisWorkQueue, WorkItem
from fluksio.flow.rename import (
RenameConflict,
RenameResult,
apply_documents,
begin,
finish,
plan_rename,
read_journal,
recover_documents,
recover_runtime,
repeated,
rewrite_documents,
roll_back,
run_steps,
)
from fluksio.flow.schemas import FlowDef, FlowInput, NodeDef
from fluksio.flow.state import MemoryState, RedisState
from fluksio.flow.store import FlowStore
from fluksio.models import AlertSent, EngineEvent, Run, RunArtifact, RunMetric, RunNode
from tests.utils.rename import leftovers, stability
from tests.utils.stores import created
HEAT = Rename("heating", "warmth")
BACK = Rename("warmth", "heating")
#: The flows whose documents name `heating`: itself, `lights` and `notes`.
AFFECTED = ["heating", "lights", "notes"]
#: Document variants the rename rewrites: two of `heating`, one each of
#: `lights` and `notes`, two of the dashboard `home`.
DOCUMENTS = 6
#: Outputs a node returned, as the stage cache keeps them.
CACHED = '{"heating.temp":20.5,"lights.level":2}'
NOW = datetime.now(UTC)
class Crash(Exception):
"""The process stopping where it stands."""
def git(root: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(root), *args], capture_output=True, text=True, check=True
).stdout
def _files(root: Path) -> dict[str, bytes]:
return {
str(path.relative_to(root)): path.read_bytes()
for path in sorted(root.rglob("*"))
if path.is_file() and ".git" not in path.parts
}
def _reads(node_id: str, *messages: str) -> NodeDef:
return NodeDef(id=node_id, requires=[MessageSpec(name=m) for m in messages])
@pytest.fixture(scope="module")
def template(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The store each test copies: `heating`, published with a draft over it;
`lights` reading it; `notes`, a draft reading it; `heating_extra`, which
only looks alike; and `home`, a dashboard bound to it with a draft over it.
Every flow has its key as its id, as one from before ids does."""
root = tmp_path_factory.mktemp("template") / "flows"
store = FlowStore(root)
dashboards = DashboardStore(store)
store.write_flow(
FlowDef(
name="heating",
nodes=[
NodeDef(
id="sensor",
provides=[
MessageSpec(name="temp"),
MessageSpec(name="heating.set"),
],
)
],
inputs=[FlowInput(spec=MessageSpec(name="heating.target"))],
)
)
draft = store.read_flow("heating", draft=True)
draft.nodes.append(_reads("valve", "heating.temp"))
store.write_draft(draft, draft.version)
store.write_flow(FlowDef(name="lights", nodes=[_reads("lamp", "heating.temp")]))
created(store, FlowDef(name="notes", nodes=[_reads("note", "heating.mode")]))
store.write_flow(FlowDef(name="heating_extra", nodes=[_reads("x", "lights.x")]))
dashboards.write(
DashboardDef(
name="home",
widgets=[
WidgetDef(id="t", type="stat", config={"message": "heating.temp"})
],
)
)
home = dashboards.read("home", draft=True)
dashboards.write_draft(home.model_copy(update={"title": "Home"}), home.version)
return root
@pytest.fixture
def world(
template: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> Iterator[SimpleNamespace]:
"""The template's store, alert channels and history: runs of `heating`,
of `lights` reading it, of `heating_extra` and of a deleted flow that
once had the key `heating`."""
root = tmp_path / "flows"
shutil.copytree(template, root)
monkeypatch.setattr(settings, "RENAME_JOURNAL_FILE", tmp_path / "rename.json")
monkeypatch.setattr(settings, "ALERTS_FILE", tmp_path / "alerts.json")
channels = [
Channel(name="board", kind="dashboard", config={"message": "heating.alert"}),
Channel(name="phone", kind="ntfy", config={"topic": "heating.alert"}),
]
settings.ALERTS_FILE.write_text(
AlertsConfig(channels=channels).model_dump_json(indent=2)
)
with Session(engine) as session:
for run_id, flow_id in (
("r1", "heating"),
("r2", "heating"),
("r3", "lights"),
("r4", "heating_extra"),
("r9", "f_gone2xa"),
):
session.add(
Run(
id=run_id,
flow_id=flow_id,
created_at=NOW,
status="ok",
params={"note": "heating.temp"},
)
)
session.commit()
session.add_all(
[
RunMetric(run_id="r1", name="heating.loss", step=0, node="heating.s"),
RunMetric(run_id="r1", name="heating.loss", step=1, node="heating.s"),
RunMetric(run_id="r1", name="heating_extra.x", step=0),
RunMetric(run_id="r3", name="heating.temp", step=0, node="lights.lamp"),
RunMetric(run_id="r4", name="heating_extra.x", step=0),
RunMetric(run_id="r9", name="heating.loss", step=0),
RunArtifact(run_id="r2", name="heating.img", node="heating.s"),
RunArtifact(run_id="r9", name="heating.img"),
RunNode(run_id="r1", node="heating.s", cache_key="k1", outputs=CACHED),
RunNode(
run_id="r2",
node="heating.s",
cache_key="k2",
cached_from="r1",
outputs=CACHED,
),
RunNode(
run_id="r3",
node="lights.lamp",
cache_key="k3",
outputs='{"lights.level":2}',
),
RunNode(
run_id="r9", node="f_gone2xa.s", cache_key="k9", outputs=CACHED
),
AlertSent(key="node_error:heating.s", ts=1.0),
AlertSent(key="flow_quarantined:heating", ts=1.0),
AlertSent(key="node_error:heating_extra.x", ts=1.0),
EngineEvent(
ts=NOW,
type="node_error",
flow_id="heating",
node="heating.s",
detail="heating.temp went missing",
),
]
)
session.commit()
store = FlowStore(root)
yield SimpleNamespace(root=root, store=store, dashboards=DashboardStore(store))
with Session(engine) as session:
for model in (RunMetric, RunArtifact, RunNode, Run, AlertSent, EngineEvent):
session.execute(delete(model))
session.commit()
def _redis_server() -> tuple[str, int]:
host = os.environ.get("FLUKSIO_TEST_REDIS_HOST")
if not host:
pytest.skip("Set FLUKSIO_TEST_REDIS_HOST to run Redis integration checks")
return host, int(os.environ.get("FLUKSIO_TEST_REDIS_PORT", "6379"))
@pytest.fixture
def backends(request: pytest.FixtureRequest) -> Iterator[SimpleNamespace]:
"""State and queue, and how the next start gets them. Memory ones stand
in for durable backends here, so a restart hands back the same objects."""
if request.param == "memory":
state, queue = MemoryState(), MemoryWorkQueue()
yield SimpleNamespace(open=lambda: (state, queue))
return
host, port = _redis_server()
namespace = f"test-journal-{uuid.uuid4().hex}"
opened: list = []
def open_() -> tuple[RedisState, RedisWorkQueue]:
state = RedisState(host, port, namespace=namespace)
queue = RedisWorkQueue(
host, port, namespace=f"{namespace}-queue", consumer="engine-a"
)
opened.extend((state, queue))
return state, queue
try:
yield SimpleNamespace(open=open_)
finally:
client = opened[0]._client
stale = list(client.scan_iter(match=f"{namespace}*"))
if stale:
client.delete(*stale)
for one in opened:
one.close()
def _item(node: str, kind: str = "cascade", **fields) -> WorkItem:
return WorkItem(kind=kind, node=node, flow=node.split(".")[0], **fields)
def _fill(state, queue) -> None:
"""Live state and queued work, naming `heating` or not."""
for key, value in (
("heating.temp", 20.5),
("__version__:heating.temp", 3),
("__ts__:heating.temp", 1.0),
("__last_seen__:lights.lamp:heating.temp", 3),
("__node__:heating.valve:last:x", 1),
("heating_extra.x", 1),
("lights.level", 2),
):
state.set(key, value)
queue.add(_item("heating.acked"))
queue.ack(*queue.claim(1, 10))
queue.add(_item("heating.valve", outputs={"heating.temp": 21}))
queue.add(_item("lights.lamp", outputs={"heating.temp": 21}))
queue.add(_item("heating_extra.x"))
queue.add_delayed(_item("heating.pump", kind="flush"), 100.0)
queue.move_due(150.0)
(claimed,) = queue.claim(1, 10, due_only=True)
queue.mark_done(claimed.entry_id, "heating.pump")
queue.add_delayed(_item("heating.limit"), 9e9)
queue.park("heating", _item("heating.p"))
def _rename(
world, state, queue, op: str = "op-1", durable: bool = True
) -> RenameResult:
"""What the rename route does once the engine is held (S8): the journal,
the documents, the steps and the end."""
rewrites = rewrite_documents(world.store, world.dashboards, "flow", "heating", HEAT)
journal = begin(
op,
"flow",
"heating",
HEAT,
rewrites,
actor="admin@example.com",
durable=durable,
)
apply_documents(journal, world.store)
run_steps(journal, state, queue)
return finish(journal)
def _recover(world, state, queue) -> None:
"""What the next start does before it serves (`main.lifespan`)."""
recover_documents(world.root)
# What assigning ids checks first, and refuses to start on.
assert plan_ids(world.root).problems == []
recover_runtime(state, queue)
queue.release_held()
def _crash_at(patch: pytest.MonkeyPatch, point: str, nth: int) -> None:
seen: Counter[str] = Counter()
def reached(name: str) -> None:
seen[name] += 1
if seen[name] == nth:
raise Crash(f"{name} #{nth}")
if point == "document":
write = identity._replace
def replace(path: Path, text: str) -> None:
write(path, text)
reached("document")
patch.setattr(identity, "_replace", replace)
return
def fault(name: str) -> None:
if name == point:
reached(name)
patch.setattr(renaming, "fault", fault)
def _audits() -> list[tuple[str, str, str]]:
with Session(engine) as session:
return [
(row.flow_id, row.actor, row.detail)
for row in session.exec(
select(EngineEvent).where(col(EngineEvent.type) == "audit")
)
]
#: Every point a crash can stop a rename at, by backend: after each document,
#: the commit, each run's history, each state batch and queue structure, each
#: step, and after the journal is done but before anyone was told.
_EVERYWHERE = [
*(("document", n) for n in range(1, DOCUMENTS + 1)),
("commit", 1),
*(("sql:run", n) for n in (1, 2, 3)),
("alert_ledger", 1),
("alerts_file", 1),
("state:keys", 1),
("state", 1),
("queue", 1),
("audit", 1),
("done", 1),
]
_QUEUE_STRUCTURES = ("due", "backlog", "delayed", "lists", "done markers")
CRASHES = [
*(("memory", point, n) for point, n in [*_EVERYWHERE, ("queue:queue", 1)]),
*(
("redis", point, n)
for point, n in [
*_EVERYWHERE,
("state:keys", 2),
*((f"queue:{one}", 1) for one in _QUEUE_STRUCTURES),
]
),
]
@pytest.mark.parametrize(
("backends", "point", "nth"),
CRASHES,
indirect=["backends"],
ids=[f"{kind}-{point}-{n}" for kind, point, n in CRASHES],
)
def test_a_crash_anywhere_is_undone_or_finished_by_the_next_start(
world, backends, point: str, nth: int, monkeypatch: pytest.MonkeyPatch
):
# Small batches, so a crash can fall between two of them.
monkeypatch.setattr(state_module, "RENAME_BATCH", 2)
state, queue = backends.open()
_fill(state, queue)
counts = state.count(HEAT), queue.count(HEAT)
files = _files(world.root)
stable = stability(world.root)
found = leftovers("heating", world.root, AFFECTED, state, queue)
assert found
with monkeypatch.context() as patch:
_crash_at(patch, point, nth)
with pytest.raises(Crash):
_rename(world, state, queue)
if (point, nth) == ("document", 1):
# A published document and its draft with different keys, which
# assigning ids would refuse to start on.
assert plan_ids(world.root).problems
state, queue = backends.open()
_recover(world, state, queue)
journal = read_journal()
assert journal is not None
if point in ("document", "commit"):
assert journal.phase == "rolled_back"
assert _files(world.root) == files
assert leftovers("heating", world.root, AFFECTED, state, queue) == found
assert (state.count(HEAT), queue.count(HEAT)) == counts
assert _audits() == []
else:
assert journal.phase == "done"
assert journal.result is not None
assert leftovers("heating", world.root, AFFECTED, state, queue) == []
# Moved, none lost and none twice.
assert (state.count(BACK), queue.count(BACK)) == counts
assert stability(world.root) == stable
assert len(_audits()) == 1
assert git(world.root, "status", "--porcelain") == ""
# Settled is settled: another start changes nothing.
settled = _files(world.root), journal
_recover(world, state, queue)
assert (_files(world.root), read_journal()) == settled
def test_history_moves_for_the_affected_flows_only(world):
"""By flow id, so a deleted flow that had the key keeps its history, and
payloads and log text keep whatever they say."""
_rename(world, MemoryState(), MemoryWorkQueue())
with Session(engine) as session:
metrics = session.exec(select(RunMetric.run_id, RunMetric.name)).all()
artifacts = session.exec(select(RunArtifact.run_id, RunArtifact.name)).all()
nodes = {
(row.run_id, row.node): (row.cache_key, row.cached_from, row.outputs)
for row in session.exec(select(RunNode))
}
ledger = session.exec(select(AlertSent.key)).all()
params = session.exec(select(Run.params)).all()
details = session.exec(
select(EngineEvent.detail).where(col(EngineEvent.type) != "audit")
).all()
assert sorted(metrics) == [
("r1", "heating_extra.x"),
("r1", "warmth.loss"),
("r1", "warmth.loss"),
("r3", "warmth.temp"),
("r4", "heating_extra.x"),
("r9", "heating.loss"),
]
assert sorted(artifacts) == [("r2", "warmth.img"), ("r9", "heating.img")]
moved = '{"lights.level":2,"warmth.temp":20.5}'
# A renamed output is no longer what its cache key promised, and where
# it was restored from still holds.
assert nodes == {
("r1", "heating.s"): ("", "", moved),
("r2", "heating.s"): ("", "r1", moved),
("r3", "lights.lamp"): ("k3", "", '{"lights.level":2}'),
("r9", "f_gone2xa.s"): ("k9", "", CACHED),
}
assert sorted(ledger) == [
"flow_quarantined:warmth",
"node_error:heating_extra.x",
"node_error:warmth.s",
]
assert params == [{"note": "heating.temp"}] * 5
assert details == ["heating.temp went missing"]
assert _audits() == [
("heating", "admin@example.com", "renamed 'heating' to 'warmth' [op op-1]")
]
alerts = AlertsConfig.model_validate_json(settings.ALERTS_FILE.read_text())
assert [c.config for c in alerts.channels] == [
{"message": "warmth.alert"},
{"topic": "heating.alert"},
]
def test_a_rollback_restores_its_own_files_and_leaves_other_changes(world):
"""Whatever else in the store is uncommitted stays so, and out of the
revert."""
extra = world.root / "heating_extra" / "flow.json"
extra.write_text(extra.read_text().replace('"lights.x"', '"lights.y"'))
(world.root / "scratch.txt").write_text("mine")
status = git(world.root, "status", "--porcelain")
files = _files(world.root)
state, queue = MemoryState(), MemoryWorkQueue()
with pytest.MonkeyPatch.context() as patch:
_crash_at(patch, "commit", 1)
with pytest.raises(Crash):
_rename(world, state, queue)
recover_documents(world.root)
journal = read_journal()
assert journal is not None and journal.phase == "rolled_back"
assert _files(world.root) == files
assert git(world.root, "status", "--porcelain") == status
assert git(world.root, "log", "-2", "--format=%s").splitlines() == [
"Revert rename of flow 'heating' to 'warmth'",
"Rename flow 'heating' to 'warmth' (heating)",
]
assert sorted(
git(world.root, "show", "--name-only", "--format=", "HEAD").split()
) == (sorted(doc.path for doc in journal.files))
def test_a_commit_that_fails_is_undone_in_process(world, monkeypatch):
files, head = _files(world.root), git(world.root, "rev-parse", "HEAD")
rewrites = rewrite_documents(world.store, world.dashboards, "flow", "heating", HEAT)
journal = begin("op-1", "flow", "heating", HEAT, rewrites)
with monkeypatch.context() as patch:
patch.setattr(identity, "_commit", lambda *_: False)
with pytest.raises(RuntimeError):
apply_documents(journal, world.store)
assert world.store.read_flow("heating").name == "warmth"
roll_back(journal, world.store)
assert read_journal().phase == "rolled_back"
assert _files(world.root) == files
assert git(world.root, "rev-parse", "HEAD") == head
# The caches read the files again.
assert world.store.read_flow("heating").name == "heating"
assert world.store.key_index()["heating"] == "heating"
def test_a_store_without_git_renames_and_recovers(world):
shutil.rmtree(world.root / ".git")
files = _files(world.root)
state, queue = MemoryState(), MemoryWorkQueue()
with pytest.MonkeyPatch.context() as patch:
_crash_at(patch, "commit", 1)
with pytest.raises(Crash):
_rename(world, state, queue)
_recover(world, state, queue)
assert _files(world.root) == files
_rename(world, state, queue, op="op-2")
assert leftovers("heating", world.root, AFFECTED, state, queue) == []
def test_a_restart_on_memory_backends_finishes_the_rest(world):
"""What was in memory went with the process, so there is nothing of it
left to move, and the rest is finished as on Redis."""
state, queue = MemoryState(), MemoryWorkQueue()
_fill(state, queue)
with pytest.MonkeyPatch.context() as patch:
_crash_at(patch, "sql:run", 2)
with pytest.raises(Crash):
_rename(world, state, queue, durable=False)
fresh = MemoryState(), MemoryWorkQueue()
_recover(world, *fresh)
assert read_journal().phase == "done"
assert leftovers("heating", world.root, AFFECTED, *fresh) == []
assert fresh[0].keys() == []
def test_a_repeated_request_gets_the_stored_result_and_a_stale_one_409(world):
version = world.store.read_flow("heating", draft=True).version
assert repeated("op-1", "heating", version, "heating", version) is None
result = _rename(world, MemoryState(), MemoryWorkQueue())
assert [(d.kind, d.id, d.version, d.draft_version) for d in result.documents] == [
("flow", "heating", version + 1, version + 1),
("flow", "lights", 2, None),
("flow", "notes", 2, 2),
("dashboard", "home", 3, 3),
]
now = world.store.read_flow("heating", draft=True)
assert (now.name, now.version) == ("warmth", version + 1)
assert repeated("op-1", now.name, now.version, "heating", version) == result
# The same rename asked for again under another id: its key matches,
# which proves nothing.
with pytest.raises(RenameConflict) as stale:
repeated("op-2", now.name, now.version, "heating", version)
assert (stale.value.status, stale.value.code) == (409, "stale")
# One still finishing turns every other away, and the planner says so.
journal = read_journal()
journal.phase = "forward"
journal.save()
for request, code in (("op-1", "completing"), ("op-3", "rename_in_progress")):
with pytest.raises(RenameConflict) as busy:
repeated(request, now.name, now.version, now.name, now.version)
assert (busy.value.status, busy.value.code) == (503, code)
controller = FlowController(
world.store,
state=MemoryState(),
execution=ExecutionService(MemoryWorkQueue()),
)
plan = plan_rename(
controller, world.dashboards, "flow", "lights", "lamps", AlertsConfig()
)
assert [r.code for r in plan.refusals] == ["rename_in_progress"]