Invalidate the composed index when a registration changes (REUSE-WP-0020-T09)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
ci / validate-registry (push) Successful in 1m18s
Build and Publish Container Image / build-and-push (push) Successful in 23s

Enabling a federation source left /v1/federated serving its cached compose and
reporting stale: false while doing it, so a repo could be correctly registered
and silently invisible for as long as its cached index survived. That is how
evidence-binder stayed missing after re-enabling until a manual
POST /v1/federated/compose was issued.

Registration writes now mark the composed index stale, and a plain GET
recomposes when the flag is set. Clearing it there is not a silent clear: that
pass really did refetch. A PATCH touching only a description does not
invalidate anything.

This changes a contract documented in specs/FederationHubAPI.md, so the
staleness section is rewritten rather than left to drift, including the two
triggers that now set the flag.

The first two tests written for this were worthless -- they passed with the fix
removed, because a newly registered repo has no cache entry and gets fetched
regardless. The real failure needs a populated cache holding stale content
inside its 24h TTL. test_re_enabled_source_refetches_a_stale_cache models that
and fails on pre-fix code; verified by reverting the mark_stale calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-21 13:51:58 +02:00
parent d1de320743
commit 6cbc862371
5 changed files with 235 additions and 26 deletions

View file

@ -20,6 +20,11 @@ from reuse_surface.hub.webhooks import (
HUB_VERSION = "0.1.0"
# Registration fields that change what the federated index composes to. A
# PATCH touching any of them invalidates the composed index; a PATCH that only
# edits, say, the description does not.
COMPOSITION_FIELDS = frozenset({"enabled", "url", "index", "required", "domain"})
def _db_path() -> Path:
return Path(os.environ.get("REUSE_SURFACE_DB", "/data/reuse.db"))
@ -82,11 +87,13 @@ def create_app() -> FastAPI:
async def register_repo(request: Request) -> dict[str, Any]:
payload = await request.json()
try:
return store.create_repo(payload)
registration = store.create_repo(payload)
except FileExistsError as exc:
raise _http_error(409, "conflict", str(exc)) from exc
except ValueError as exc:
raise _http_error(400, "validation_error", str(exc)) from exc
store.mark_stale()
return registration
@app.get("/v1/repos/{repo}")
def get_repo(repo: str) -> dict[str, Any]:
@ -99,16 +106,20 @@ def create_app() -> FastAPI:
async def update_repo(repo: str, request: Request) -> dict[str, Any]:
payload = await request.json()
try:
return store.update_repo(repo, payload)
registration = store.update_repo(repo, payload)
except KeyError as exc:
raise _http_error(404, "not_found", str(exc)) from exc
except ValueError as exc:
raise _http_error(400, "validation_error", str(exc)) from exc
if COMPOSITION_FIELDS & set(payload):
store.mark_stale()
return registration
@app.delete("/v1/repos/{repo}", status_code=204, dependencies=[Depends(_require_auth)])
def delete_repo(repo: str) -> Response:
if not store.delete_repo(repo):
raise _http_error(404, "not_found", f"repo not found: {repo}")
store.mark_stale()
return Response(status_code=204)
async def _federated_response(
@ -116,19 +127,26 @@ def create_app() -> FastAPI:
accept: str | None,
format_param: str,
) -> Response:
# composed_at/stale track *forced* recomposes (refresh=True: manual
# POST, webhook, scheduled fallback), not every plain GET -- a plain
# GET still serves current best-effort data (compose_from_store's own
# per-source cache_ttl_seconds still applies) but must not silently
# clear a staleness signal nothing actually refreshed.
# composed_at/stale track recomposes that actually refetched, not
# every plain GET -- a plain GET otherwise serves current best-effort
# data (compose_from_store's own per-source cache_ttl_seconds still
# applies) and must not clear a staleness signal nothing acted on.
#
# A GET *does* refresh when the index is marked stale. Registration
# writes set that flag, and without this a newly registered or
# re-enabled repo stays invisible for as long as its cached index
# survives -- silently, since the response would keep reporting
# stale: false. Clearing the flag here is honest because this pass
# really did refetch.
async with compose_lock:
effective_refresh = refresh or store.get_compose_state()["stale"]
try:
federated, warnings = compose_from_store(
store, refresh=refresh, cache_dir=_cache_dir(), domain=DEFAULT_DOMAIN
store, refresh=effective_refresh, cache_dir=_cache_dir(), domain=DEFAULT_DOMAIN
)
except FileNotFoundError as exc:
raise _http_error(502, "compose_error", str(exc)) from exc
if refresh:
if effective_refresh:
store.record_compose()
compose_state = store.get_compose_state()