ReMe/tests/unit/test_auto_resource_batch_lookup.py
WQS 6ed97f033b
feat(auto_resource): reuse parsed resource ownership per batch (#529)
* perf(auto_resource): reuse historical ownership lookup per batch

Share a lazy ownership index across routed processors and expire it at the end of each resource batch. Refresh changed days and their safe aliases after writes, deletes, and partial failures.

Add regression coverage for scan counts, ownership, lookup scope isolation, and retry behavior.

* fix(auto_resource): reconcile shared lookup across active batches

* fix(auto_resource): refresh batch ownership from filesystem metadata

* refactor(auto_resource): simplify batch ownership lookup

Keep per-day ownership caches paired with pre-read metadata and refresh changed days before subsequent resources. Remove reverse-index bookkeeping, post-read stabilization retries, and recursive scope fallback.

Handle late ELOOP errors on Python 3.13 while preserving other OS errors. Update regression tests for cross-resource freshness and retain safety and lifecycle coverage.

* refactor(auto_resource): streamline batch lookup refresh

* refactor(auto_resource): scope ownership cache to batch mutations

Replace filesystem snapshots with one lazy historical lookup shared by resource processors. Reload only days changed by this invocation and discard the cache when it ends.

Reuse canonical daily-note scans for symlink safety and Python 3.13 compatibility. Consolidate regression tests around the narrowed batch contract.

Validation: Python 3.11, 3.12 and 3.13 core/plugin suites each passed 1407 tests; pre-commit --all-files and offline lookup/deletion comparison passed.

* refactor(auto_resource): reuse initial ownership without refreshes

* test(auto_resource): streamline ownership lookup regression coverage
2026-09-14 16:55:42 +08:00

555 lines
24 KiB
Python

"""Reuse initial ownership for distinct resources and live lookup for repeated sources."""
import asyncio
import os
from collections import Counter
from unittest.mock import patch
import frontmatter
import pytest
from reme.components import R
from reme.components.runtime_context import RuntimeContext
from reme.schema import Response
from reme.steps.evolve import base_auto_resource as resource_module
from reme.steps.evolve.auto_resource import AutoResourceStep
from reme.steps.evolve.auto_text_resource import AutoTextResourceStep
from reme.steps.evolve.base_auto_resource import BaseAutoResourceStep
from .auto_resource_test_support import FakeVisionModel, caption_json, image_bytes
pytest_plugins = ("unit.auto_resource_test_plugin",)
pytestmark = pytest.mark.asyncio
_HISTORY_DAYS = ("2025-12-01", "2025-12-02", "2025-12-03")
def _seed_history(env) -> None:
"""Create older days untouched by the resource changes under test."""
for day in _HISTORY_DAYS:
env.write_note(f"daily/{day}/archive.md", f"[[resource/archive-{day}.txt]]")
def _observe_history(env, monkeypatch) -> Counter:
"""Observe directory walks, parsing, and daily_list, not every possible standalone stat call."""
calls = Counter()
daily_root = (env.workspace / "daily").resolve()
history_text = {
path.read_text(encoding="utf-8"): day
for day in _HISTORY_DAYS
if (path := daily_root / day / "archive.md").is_file()
}
watched_directories = {
str(daily_root): "history_enumerations",
**{str(daily_root / day): ("enumerate", day) for day in _HISTORY_DAYS},
}
original_listdir = os.listdir
original_scandir = os.scandir
original_loads = frontmatter.loads
daily_list = env.app_context.jobs["daily_list"]
def record_enumeration(path):
if isinstance(path, (str, bytes, os.PathLike)):
key = watched_directories.get(os.fsdecode(path))
if key is not None:
calls[key] += 1
def counted_listdir(path="."):
record_enumeration(path)
return original_listdir(path)
def counted_scandir(path="."):
record_enumeration(path)
return original_scandir(path)
def counted_loads(text, *args, **kwargs):
if isinstance(text, str) and text in history_text:
calls[("parse", history_text[text])] += 1
return original_loads(text, *args, **kwargs)
async def counted_daily_list(**kwargs):
calls[("daily_list", kwargs["date"])] += 1
return await daily_list(**kwargs)
# Path.iterdir uses listdir or scandir depending on the Python version.
# Return the original iterator unchanged, without wrapping DirEntry objects.
monkeypatch.setattr(os, "listdir", counted_listdir)
monkeypatch.setattr(os, "scandir", counted_scandir)
monkeypatch.setattr(frontmatter, "loads", counted_loads)
monkeypatch.setitem(env.app_context.jobs, "daily_list", counted_daily_list)
return calls
def _assert_history_reads(calls, count=1) -> None:
assert calls["history_enumerations"] == count
for day in _HISTORY_DAYS:
assert calls[("enumerate", day)] == count
assert calls[("daily_list", day)] == count
assert calls[("parse", day)] == count
def _model() -> FakeVisionModel:
return FakeVisionModel(caption_json("caption", "A sample image", "A red square."))
@pytest.mark.parametrize("routed", [False, True], ids=["image", "unified-router"])
@pytest.mark.parametrize("resource_count", [1, 8])
@pytest.mark.parametrize("write_notes", [False, True], ids=["missing-deletions", "real-updates"])
async def test_loose_batch_reads_history_once(
routed,
resource_count,
write_notes,
auto_resource_env,
monkeypatch,
):
"""More resources, including actual note writes, must not cause another history walk."""
env = auto_resource_env
_seed_history(env)
changes = []
notes = []
for index in range(resource_count):
source = f"resource/photo-{index}.png"
if write_notes:
env.write_binary(source, image_bytes())
notes.append(env.write_note(f"daily/2026-01-01/photo-{index}.md", f"[[{source}]]"))
changes.append({"change": "modified" if write_notes else "deleted", "path": source})
before = {note: note.read_bytes() for note in notes}
calls = _observe_history(env, monkeypatch)
with patch.object(BaseAutoResourceStep, "_today", return_value="2026-01-01"):
response = await env.run(env.processor(_model(), routed=routed), changes)
assert response.success is True
assert response.metadata["processed"] == resource_count
results = response.metadata["results"]
assert [result["path"] for result in results] == [item["path"] for item in changes]
assert all(result["metadata"]["action"] == ("modified" if write_notes else "skipped") for result in results)
_assert_history_reads(calls)
if write_notes:
assert all(note.read_bytes() != previous for note, previous in before.items())
assert all(result["metadata"]["modified"] for result in results)
# All existing notes share one already-read day. Pre-write lookup must
# reuse it; normal day-index rebuilds are not daily_list calls.
assert calls[("daily_list", "2026-01-01")] == 1
async def test_mixed_processors_share_history_and_keep_result_order(auto_resource_env, monkeypatch):
"""Both processors reuse the batch lookup while returning results in input order."""
env = auto_resource_env
_seed_history(env)
text_note = env.write_note("daily/2026-01-01/report.md", "[[resource/report.txt]]")
image_note = env.write_note("daily/2026-01-01/photo.md", "[[resource/photo.png]]")
calls = _observe_history(env, monkeypatch)
changes = [
{"change": "deleted", "path": "resource/report.txt"},
{"change": "deleted", "path": "resource/photo.png"},
]
response = await env.run(env.processor(_model(), routed=True), changes)
assert response.success is True
results = response.metadata["results"]
assert [result["path"] for result in results] == [item["path"] for item in changes]
assert all(result["metadata"]["action"] == "deleted" for result in results)
assert not text_note.exists()
assert not image_note.exists()
_assert_history_reads(calls)
assert calls[("daily_list", "2026-01-01")] == 1
@pytest.mark.parametrize("routed", [False, True], ids=["text-step", "text-only-router"])
async def test_text_only_batch_uses_history_without_image_processor(routed, auto_resource_env, monkeypatch):
"""A text-only pipeline can reuse history without configuring an image processor."""
env = auto_resource_env
_seed_history(env)
env.app_context.registry = R.copy()
notes = [env.write_note(f"daily/2026-01-01/report-{i}.md", f"[[resource/report-{i}.txt]]") for i in range(3)]
calls = _observe_history(env, monkeypatch)
options = {"app_context": env.app_context, "file_store": env.file_store}
step = (
AutoResourceStep(**options, dispatch_steps=["auto_text_resource_step"])
if routed
else AutoTextResourceStep(**options)
)
response = await env.run(step, [{"change": "deleted", "path": f"resource/report-{i}.txt"} for i in range(3)])
assert response.success is True
assert all(not note.exists() for note in notes)
_assert_history_reads(calls)
assert calls[("daily_list", "2026-01-01")] == 1
@pytest.mark.parametrize("dated", [False, True], ids=["loose-root", "dated-resource"])
async def test_new_image_uses_live_post_write_resolution(dated, auto_resource_env, monkeypatch):
"""Only dated input needs a pre-write daily_list; both paths read the newly written note."""
env = auto_resource_env
_seed_history(env)
prefix = "resource/2026-01-01" if dated else "resource"
source = env.write_binary(f"{prefix}/caption.png", image_bytes())
calls = _observe_history(env, monkeypatch)
plain_call = FakeVisionModel.__call__
async def observe_pre_write(model, messages, **kwargs):
assert calls[("daily_list", "2026-01-01")] == (1 if dated else 0)
return await plain_call(model, messages, **kwargs)
monkeypatch.setattr(FakeVisionModel, "__call__", observe_pre_write)
with patch.object(BaseAutoResourceStep, "_today", return_value="2026-01-01"):
response = await env.run(
env.processor(_model(), routed=True),
[{"change": "added", "path": str(source)}],
)
assert response.success is True
path = response.metadata["results"][0]["metadata"]["path"]
assert path == "daily/2026-01-01/caption.md"
assert (env.workspace / path).is_file()
_assert_history_reads(calls, 0 if dated else 1)
assert calls[("daily_list", "2026-01-01")] == (2 if dated else 1)
async def test_reused_step_and_context_rebuild_history_next_invocation(auto_resource_env):
"""External edits between invocations are visible; batch state never survives the call."""
env = auto_resource_env
original = env.write_note("daily/2026-01-01/original.md", "[[resource/photo.png]]")
step = env.processor(_model(), routed=True)
changes = [{"change": "deleted", "path": "resource/photo.png"}]
context = RuntimeContext(changes=changes, user_value="preserve")
context_keys = set(context.data)
first = await step(context)
assert first.success is True
assert first.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-01/original.md"
assert not original.exists()
assert set(context.data) == context_keys
assert context["changes"] is changes
assert context["user_value"] == "preserve"
recreated = env.write_note("daily/2026-01-02/recreated.md", "[[resource/photo.png]]")
second = await step(context)
assert second.success is True
assert second.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-02/recreated.md"
assert second.metadata["results"][0]["metadata"]["action"] == "deleted"
assert not recreated.exists()
assert set(context.data) == context_keys
assert context["changes"] is changes
assert context["user_value"] == "preserve"
async def test_create_update_delete_recreate_maintains_ownership_across_midnight(
auto_resource_env,
monkeypatch,
):
"""A card keeps its original day until deletion permits a fresh card on today's date."""
env = auto_resource_env
env.write_binary("resource/caption.png", image_bytes())
clock = {"day": "2026-01-01"}
refresh = resource_module.refresh_day_index
async def refresh_and_advance(*args, **kwargs):
result = await refresh(*args, **kwargs)
clock["day"] = "2026-01-02"
return result
monkeypatch.setattr(BaseAutoResourceStep, "_today", lambda _step: clock["day"])
monkeypatch.setattr(resource_module, "refresh_day_index", refresh_and_advance)
response = await env.run(
env.processor(_model(), routed=True),
[{"change": change, "path": "resource/caption.png"} for change in ("added", "modified", "deleted", "added")],
)
assert response.success is True
metadata = [result["metadata"] for result in response.metadata["results"]]
assert [item["action"] for item in metadata] == ["added", "modified", "deleted", "added"]
assert [item["path"] for item in metadata] == [
"daily/2026-01-01/caption.md",
"daily/2026-01-01/caption.md",
"daily/2026-01-01/caption.md",
"daily/2026-01-02/caption.md",
]
assert not (env.workspace / "daily/2026-01-01/caption.md").exists()
assert (env.workspace / "daily/2026-01-02/caption.md").is_file()
@pytest.mark.parametrize("first_change", ["added", "deleted"])
async def test_repeated_source_retries_after_partial_write_or_delete(
first_change,
auto_resource_env,
monkeypatch,
):
"""A repeated source uses live lookup even when its previous event failed after mutation."""
env = auto_resource_env
env.write_binary("resource/caption.png", image_bytes())
if first_change == "deleted":
env.write_note("daily/2026-01-01/caption.md", "[[resource/caption.png]]")
clock = {"day": "2026-01-01"}
refresh = resource_module.refresh_day_index
refresh_calls = 0
async def fail_first_refresh(*args, **kwargs):
nonlocal refresh_calls
refresh_calls += 1
if refresh_calls == 1:
clock["day"] = "2026-01-02"
raise RuntimeError("day index unavailable after disk change")
return await refresh(*args, **kwargs)
monkeypatch.setattr(BaseAutoResourceStep, "_today", lambda _step: clock["day"])
monkeypatch.setattr(resource_module, "refresh_day_index", fail_first_refresh)
next_change = "modified" if first_change == "added" else "added"
response = await env.run(
env.processor(_model(), routed=True),
[{"change": change, "path": "resource/caption.png"} for change in (first_change, next_change)],
)
failed, recovered = response.metadata["results"]
assert response.success is False
assert response.metadata["modified"] is True
assert failed["success"] is False
assert failed["metadata"]["modified"] is True
assert "day index unavailable" in failed["metadata"]["error"]
assert recovered["success"] is True
day = "2026-01-01" if first_change == "added" else "2026-01-02"
assert recovered["metadata"]["path"] == f"daily/{day}/caption.md"
assert recovered["metadata"]["created"] is (first_change == "deleted")
assert (env.workspace / f"daily/{day}/caption.md").is_file()
assert len(list((env.workspace / "daily").glob("*/caption.md"))) == 1
async def test_repeated_existing_updates_keep_original_day_and_path(
auto_resource_env,
monkeypatch,
):
"""Non-consecutive updates recognize the same source in absolute and relative form."""
env = auto_resource_env
_seed_history(env)
source = env.write_binary("resource/photo.png", image_bytes())
note = env.write_note("daily/2026-01-01/original.md", "[[resource/photo.png]]")
env.write_binary("resource/other.png", image_bytes())
other = env.write_note("daily/2026-01-01/other.md", "[[resource/other.png]]")
before = note.read_bytes()
calls = _observe_history(env, monkeypatch)
model = _model()
changes = [
{"change": "modified", "path": str(source)},
{"change": "modified", "path": "resource/other.png"},
{"change": "modified", "path": "resource/photo.png"},
]
with patch.object(BaseAutoResourceStep, "_today", return_value="2026-01-02"):
response = await env.run(env.processor(model, routed=True), changes)
assert response.success is True
metadata = [result["metadata"] for result in response.metadata["results"]]
assert [item["path"] for item in metadata] == [
"daily/2026-01-01/original.md",
"daily/2026-01-01/other.md",
"daily/2026-01-01/original.md",
]
assert all(item["action"] == "modified" and item["created"] is False for item in metadata)
assert len(model.calls) == 3
assert note.read_bytes() != before
assert other.is_file()
assert not (env.workspace / "daily/2026-01-02").exists()
# Unchanged paths alone cannot prove that the repeated source used live lookup.
_assert_history_reads(calls, 2)
async def test_cross_day_duplicate_only_fails_ambiguous_resource(auto_resource_env):
"""Ambiguous ownership prevents mutation without blocking another valid resource."""
env = auto_resource_env
first = env.write_note("daily/2026-01-01/first.md", "[[resource/duplicate.png]]")
second = env.write_note("daily/2026-01-02/second.md", "[[resource/duplicate.png]]")
valid = env.write_note("daily/2026-01-02/valid.md", "[[resource/valid.png]]")
before = {note: note.read_bytes() for note in (first, second)}
response = await env.run(
env.processor(_model(), routed=True),
[
{"change": "deleted", "path": "resource/duplicate.png"},
{"change": "deleted", "path": "resource/valid.png"},
],
)
ambiguous, deleted = response.metadata["results"]
assert response.success is False
assert ambiguous["success"] is False
assert "Multiple daily resource notes claim resource/duplicate.png" in ambiguous["metadata"]["error"]
assert ambiguous["metadata"]["modified"] is False
assert deleted["success"] is True
assert deleted["metadata"]["action"] == "deleted"
assert not valid.exists()
assert all(note.read_bytes() == contents for note, contents in before.items())
async def test_same_day_duplicates_keep_first_match_after_deletion(auto_resource_env):
"""Deleting the first same-day match exposes the next one, preserving existing behavior."""
env = auto_resource_env
first = env.write_note("daily/2026-01-01/first.md", "[[resource/photo.png]]")
second = env.write_note("daily/2026-01-01/second.md", "[[resource/photo.png]]")
with patch.object(BaseAutoResourceStep, "_today", return_value="2026-01-02"):
response = await env.run(
env.processor(_model(), routed=True),
[{"change": "deleted", "path": "resource/photo.png"} for _ in range(3)],
)
assert response.success is True
metadata = [result["metadata"] for result in response.metadata["results"]]
assert [item["action"] for item in metadata] == ["deleted", "deleted", "skipped"]
assert [item["path"] for item in metadata[:2]] == ["daily/2026-01-01/first.md", "daily/2026-01-01/second.md"]
assert not first.exists()
assert not second.exists()
async def test_loose_and_dated_changes_keep_separate_lookup_modes(auto_resource_env):
"""A dated event must not inherit a loose event's cached path, including on repeated deletion."""
env = auto_resource_env
sources = ["resource/first.png", "resource/2026-01-02/dated.png", "resource/last.png"]
notes = [
env.write_note("daily/2026-01-01/first.md", f"[[{sources[0]}]]"),
env.write_note("daily/2026-01-02/first.md", f"[[{sources[1]}]]"),
env.write_note("daily/2026-01-02/second.md", f"[[{sources[1]}]]"),
env.write_note("daily/2026-01-01/last.md", f"[[{sources[2]}]]"),
]
response = await env.run(
env.processor(_model(), routed=True),
[{"change": "deleted", "path": source} for source in (sources[0], sources[1], sources[1], sources[2])],
)
assert response.success is True
metadata = [result["metadata"] for result in response.metadata["results"]]
assert [item["action"] for item in metadata] == ["deleted"] * 4
assert [item["path"] for item in metadata] == [note.relative_to(env.workspace).as_posix() for note in notes]
assert all(not note.exists() for note in notes)
async def test_initial_read_failure_does_not_publish_partial_history(auto_resource_env, monkeypatch):
"""A transient history read failure makes the next item retry the initial scan."""
env = auto_resource_env
_seed_history(env)
owned_note = env.write_note("daily/2026-01-01/original.md", "[[resource/photo.png]]")
daily_list = env.app_context.jobs["daily_list"]
failed_once = False
async def fail_once_mid_scan(**kwargs):
nonlocal failed_once
if kwargs["date"] == _HISTORY_DAYS[1] and not failed_once:
failed_once = True
return Response(success=False, answer="temporary history read failure")
return await daily_list(**kwargs)
monkeypatch.setitem(env.app_context.jobs, "daily_list", fail_once_mid_scan)
response = await env.run(
env.processor(_model(), routed=True),
[
{"change": "deleted", "path": "resource/missing.png"},
{"change": "deleted", "path": "resource/photo.png"},
],
)
failed, recovered = response.metadata["results"]
assert response.success is False
assert failed["success"] is False
assert failed["metadata"]["modified"] is False
assert "temporary history read failure" in failed["metadata"]["error"]
assert recovered["success"] is True
assert recovered["metadata"]["path"] == "daily/2026-01-01/original.md"
assert recovered["metadata"]["action"] == "deleted"
assert not owned_note.exists()
async def test_empty_history_is_initialized_once(auto_resource_env, monkeypatch):
"""An empty ownership table is a successful scan, not an uninitialized cache."""
env = auto_resource_env
(env.workspace / "daily").mkdir(exist_ok=True)
calls = _observe_history(env, monkeypatch)
response = await env.run(
env.processor(_model(), routed=True),
[{"change": "deleted", "path": f"resource/missing-{index}.png"} for index in range(8)],
)
assert response.success is True
assert all(result["metadata"]["action"] == "skipped" for result in response.metadata["results"])
assert calls["history_enumerations"] == 1
async def test_router_exception_restores_context_and_next_call_rebuilds(auto_resource_env):
"""Even an exception after dispatch cannot leak batch state into a reused context."""
env = auto_resource_env
original = env.write_note("daily/2026-01-01/original.md", "[[resource/photo.png]]")
step = env.processor(_model(), routed=True)
changes = [{"change": "deleted", "path": "resource/photo.png"}]
context = RuntimeContext(changes=changes, user_value="preserve")
context_keys = set(context.data)
dispatch = step._dispatch_processor # pylint: disable=protected-access
async def dispatch_then_fail(*args, **kwargs):
await dispatch(*args, **kwargs)
raise RuntimeError("dispatch interrupted after processor completion")
with patch.object(step, "_dispatch_processor", side_effect=dispatch_then_fail):
with pytest.raises(RuntimeError, match="dispatch interrupted"):
await step(context)
assert not original.exists()
assert set(context.data) == context_keys
assert context["changes"] is changes
assert context["user_value"] == "preserve"
recreated = env.write_note("daily/2026-01-02/recreated.md", "[[resource/photo.png]]")
response = await step(context)
assert response.success is True
assert response.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-02/recreated.md"
assert not recreated.exists()
assert set(context.data) == context_keys
@pytest.mark.parametrize("routed", [False, True], ids=["image", "unified-router"])
async def test_cancelled_invocation_restores_context_and_rebuilds(routed, auto_resource_env):
"""Cancel at the model boundary after lookup, without mocking the resource processing path."""
env = auto_resource_env
env.write_binary("resource/photo.png", image_bytes())
original = env.write_note("daily/2026-01-01/original.md", "[[resource/photo.png]]")
before = original.read_bytes()
entered = asyncio.Event()
# The inherited structured method deliberately raises to exercise plain-call fallback.
class WaitingModel(FakeVisionModel): # pylint: disable=abstract-method
"""Keep the image request pending until the invocation is cancelled."""
async def __call__(self, messages, **kwargs):
del messages, kwargs
entered.set()
await asyncio.Event().wait()
step = env.processor(WaitingModel("unused"), routed=routed)
changes = [{"change": "modified", "path": "resource/photo.png"}]
context = RuntimeContext(changes=changes, user_value="preserve")
context_keys = set(context.data)
task = asyncio.create_task(step(context))
try:
await asyncio.wait_for(entered.wait(), timeout=5)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
finally:
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert original.read_bytes() == before
assert set(context.data) == context_keys
assert context["changes"] is changes
assert context["user_value"] == "preserve"
original.unlink()
recreated = env.write_note("daily/2026-01-02/recreated.md", "[[resource/photo.png]]")
context["changes"] = [{"change": "deleted", "path": "resource/photo.png"}]
response = await step(context)
assert response.success is True
assert response.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-02/recreated.md"
assert not recreated.exists()
assert set(context.data) == context_keys