Invalidate the composed index when a registration changes (REUSE-WP-0020-T09)
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:
parent
d1de320743
commit
6cbc862371
5 changed files with 235 additions and 26 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue