Files
openswarm/backend/tests/test_workflows_storage.py
T

171 lines
6.3 KiB
Python

"""Storage durability: the crash-safe write path and the bounded caches.
These guard properties the rest of the suite assumes but never exercises: a
power-off mid-write must not corrupt or orphan files, a record that does end up
corrupt must be skipped rather than crash the loader, and the per-workflow run
log + missed-run list must stay bounded.
Run:
cd backend && .venv/bin/python -m pytest tests/test_workflows_storage.py -v
"""
from __future__ import annotations
import io
import json
import logging
import os
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from typing import Iterator
import pytest
@pytest.fixture(autouse=True)
def _wf_env(isolated_workflows_data):
yield
@contextmanager
def p_storage_warnings() -> Iterator[io.StringIO]:
"""Not caplog: backend/main.py pins propagate=False on the 'backend' logger, and
caplog listens at the root, so these records only exist if you sit on the logger itself."""
from backend.apps.workflows import storage
buf = io.StringIO()
handler = logging.StreamHandler(buf)
handler.setLevel(logging.WARNING)
storage.logger.addHandler(handler)
try:
yield buf
finally:
storage.logger.removeHandler(handler)
# --- atomic write ------------------------------------------------------------
def test_atomic_write_round_trips(tmp_path):
from backend.apps.workflows import storage
storage._ensure_dirs()
path = os.path.join(storage.DATA_DIR, "thing.json")
storage.p_atomic_write_json(path, {"a": 1, "b": [2, 3]})
with open(path) as f:
assert json.load(f) == {"a": 1, "b": [2, 3]}
def test_atomic_write_leaves_no_temp_and_preserves_old_on_failure(monkeypatch):
"""A failure mid-write must keep the previous complete file intact and drop
no .tmp sibling behind (the loader would never read a .tmp, but a litter of
them is its own bug)."""
from backend.apps.workflows import storage
storage._ensure_dirs()
path = os.path.join(storage.DATA_DIR, "keep.json")
storage.p_atomic_write_json(path, {"v": "original"})
def p_boom(*args, **kwargs):
raise RuntimeError("write died")
monkeypatch.setattr(storage.json, "dump", p_boom)
with pytest.raises(RuntimeError):
storage.p_atomic_write_json(path, {"v": "new"})
with open(path) as f:
assert json.load(f) == {"v": "original"}
leftovers = [n for n in os.listdir(storage.DATA_DIR) if n.endswith(".tmp")]
assert leftovers == []
# --- corrupt-record resilience -----------------------------------------------
def test_corrupt_workflow_record_is_skipped_not_fatal(make_wf):
"""A truncated <id>.json must not take down the whole load; the bad record
drops out and the good ones still come back."""
from backend.apps.workflows import storage
good = make_wf(title="good")
storage.save_workflow(good)
storage._ensure_dirs()
with open(os.path.join(storage.DATA_DIR, "broken.json"), "w") as f:
f.write('{"id": "broken", "title": "trunc') # deliberately unterminated
storage._cache_loaded = False
ids = {w.id for w in storage.list_workflows()}
assert good.id in ids
assert "broken" not in ids
def test_dropped_workflow_record_names_the_file_it_dropped(make_wf):
"""The drop is what makes a workflow vanish from the UI with its file still
on disk. If it happens without naming the file, nobody can ever diagnose it."""
from backend.apps.workflows import storage
storage.save_workflow(make_wf(title="good"))
storage._ensure_dirs()
with open(os.path.join(storage.DATA_DIR, "broken.json"), "w") as f:
f.write('{"id": "broken", "title": "trunc')
storage._cache_loaded = False
with p_storage_warnings() as logged:
storage.list_workflows()
assert "broken.json" in logged.getvalue()
def test_record_a_newer_build_wrote_is_reported_when_it_fails_to_load():
"""Every Workflow field has a default, so unknown keys survive a downgrade
fine. A field whose VALUE the running build's schema rejects does not: the
record takes the same exit as a truncated file, and must say so."""
from backend.apps.workflows import storage
storage._ensure_dirs()
with open(os.path.join(storage.DATA_DIR, "fromfuture.json"), "w") as f:
json.dump({"id": "fromfuture", "execution_target": "orbital-relay"}, f)
storage._cache_loaded = False
with p_storage_warnings() as logged:
ids = {w.id for w in storage.list_workflows()}
assert "fromfuture" not in ids
assert "fromfuture.json" in logged.getvalue()
def test_corrupt_runs_file_yields_empty_history(make_wf):
from backend.apps.workflows import storage
wf = make_wf()
storage.save_workflow(wf)
storage._ensure_dirs()
with open(os.path.join(storage.RUNS_DIR, f"{wf.id}.json"), "w") as f:
f.write("not json at all")
storage._cache_loaded = False
assert storage.list_runs(wf.id) == []
# --- bounded caches ----------------------------------------------------------
def test_missed_cache_capped_to_newest(make_wf):
"""add_missed past MAX_MISSED keeps the newest by scheduled_for so a card
the user never acts on can't grow the file without bound."""
from backend.apps.workflows import storage
from backend.apps.workflows.models import MissedRun
wf = make_wf()
storage.save_workflow(wf)
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
total = storage.MAX_MISSED + 25
for i in range(total):
storage.add_missed(MissedRun(workflow_id=wf.id, scheduled_for=base + timedelta(minutes=i)))
kept = storage.list_missed()
assert len(kept) == storage.MAX_MISSED
# The oldest 25 fell off; the kept set starts at minute 25.
earliest = min(m.scheduled_for for m in kept)
assert earliest == base + timedelta(minutes=25)
def test_run_history_bounded_per_workflow(make_wf):
from backend.apps.workflows import storage
from backend.apps.workflows.models import WorkflowRun
wf = make_wf()
storage.save_workflow(wf)
over = storage.RUNS_PER_WORKFLOW + 10
for i in range(over):
storage.record_run(WorkflowRun(
workflow_id=wf.id, status="success",
started_at=datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(minutes=i),
))
assert len(storage._runs_cache[wf.id]) == storage.RUNS_PER_WORKFLOW