From bdf617a06aec7078d95eb1a4a6606556280feba4 Mon Sep 17 00:00:00 2001 From: VortexUK Date: Tue, 28 Jul 2026 21:03:28 +0100 Subject: [PATCH] =?UTF-8?q?fix(parses):=20ingest=20never=20awaits=20Census?= =?UTF-8?q?=20=E2=80=94=20guild=20resolve=20is=20cache/store-only=20inline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-07-28 incident: a never-seen uploader's inline Census guild lookup hit a degraded Census and blew the ACT plugin's 20s HttpClient timeout - the upload "failed" client-side while the server committed the parse anyway ~5s later. _resolve_uploader_guild_async now takes allow_census (default False): the response path serves from character_cache (any age) then the durable census_store; a never-seen uploader returns CENSUS_UNAVAILABLE, the encounter commits with guild_name=NULL, and the existing _backfill_encounter_guild background task (now allow_census=True) does the live lookup + roster prewarm after the response. Combatant snapshots already worked this way - the handler is now zero-Census end to end. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 ++- backend/server/api/parses/ingest.py | 41 +++++++++++++++--------- tests/server/test_parses_ingest_cache.py | 41 ++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c86c3c8a..4284fb30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,9 @@ The [EQ2LexiconACTPlugin](https://github.com/VortexUK/EQ2LexiconACTPlugin) sends Plugin auto-detects the EQ2 server from its log file path (`/logs//eq2log_.txt`) and stamps it as `logger_server` on every upload. The server uses it to override `EQ2_WORLD` for the Census guild lookup — so a Varsoon-configured deployment correctly resolves a Kaladim character's guild without needing per-deployment world config. -Backward compat: absent / null / empty `logger_server` → falls back to `EQ2_WORLD` env var as before. Older plugin versions and the local-ingest path keep working unchanged. The override path lives in `_resolve_uploader_guild_async(uploader, world=None)`. +Backward compat: absent / null / empty `logger_server` → falls back to `EQ2_WORLD` env var as before. Older plugin versions and the local-ingest path keep working unchanged. The override path lives in `_resolve_uploader_guild_async(uploader, world=None, *, allow_census=False)`. + +**Zero-Census response path (2026-07-28)**: the ingest handler never awaits Census before responding. Guild resolve is cache→census_store only (any age); a never-seen uploader returns CENSUS_UNAVAILABLE → commit with `guild_name=NULL` → `_backfill_encounter_guild` (BackgroundTasks, `allow_census=True`) does the live lookup + roster prewarm after the response. Combatant snapshots were already cache-only inline with background fill. Rationale: the plugin's HttpClient timeout is 20 s ([UploadClient.cs:52](https://github.com/VortexUK/EQ2LexiconACTPlugin/blob/main/src/Core/UploadClient.cs)) and one degraded inline Census call blew it — the upload "failed" client-side while the server committed anyway. **HMAC payload signing (v0.1.8+ plugin, server-side validator added 2026-05-25, strict mode same day)**: diff --git a/backend/server/api/parses/ingest.py b/backend/server/api/parses/ingest.py index ea2b05d9..749538d4 100644 --- a/backend/server/api/parses/ingest.py +++ b/backend/server/api/parses/ingest.py @@ -82,18 +82,25 @@ class _CensusUnavailable: async def _resolve_uploader_guild_async( uploader: str, world: str | None = None, + *, + allow_census: bool = False, ) -> str | None | _CensusUnavailable: """Cache-first guild lookup for the upload path. Order of attempts: 1. character_cache hit on the uploader's character → return its - guild_name (zero Census traffic). + guild_name (zero Census traffic; any age — stale beats a live call). 2. Miss → durable census_store hit → return its guild_name (zero Census). The store never deletes, so an uploader resolved once is served from here forever; and if they're in the store the guild was already loaded, so its members are too and combatant resolution finds them without a prewarm. - 3. Never-seen → single-character Census call via get_character_guild_name - to learn the guild name for this upload. + 3. Never-seen → CENSUS_UNAVAILABLE unless ``allow_census`` (background + tasks only). The HTTP response path must NEVER wait on Census — one + degraded lookup blows the plugin's 20 s HttpClient timeout + (2026-07-28 incident); the encounter commits with guild_name=NULL + and _backfill_encounter_guild resolves it after the response. + With ``allow_census``: single-character Census call via + get_character_guild_name. 4. If we learned a guild that way, fire-and-forget _fetch_and_cache_guild() to pull + persist the full roster so the rest of the raid hits step 1/2. Thundering-herd guard inside the helper dedupes concurrent prewarms. @@ -137,6 +144,9 @@ async def _resolve_uploader_guild_async( if rec is not None: return rec["data"].get("guild_name") or None + if not allow_census: + return CENSUS_UNAVAILABLE + try: async with shared_census_client() as client: guild_name = await client.get_character_guild_name(uploader, effective_world) @@ -342,7 +352,7 @@ async def _backfill_encounter_guild(encounter_id: int, uploader: str, world: str the user after the parse was already accepted. """ try: - result = await _resolve_uploader_guild_async(uploader, world) + result = await _resolve_uploader_guild_async(uploader, world, allow_census=True) if isinstance(result, _CensusUnavailable): _log.info( "[parses-ingest] Background guild backfill for encounter %s: Census still unavailable, will retry on next opportunity", @@ -876,19 +886,18 @@ async def ingest_parse( # no current_world() fallback to fall through to on this HTTP path. parse_world = sanitized_server - # Cache-aware guild resolve: hits character_cache first; on miss does a - # one-character Census call and pre-warms the full roster in the - # background so the rest of the raid's uploads are zero-Census. - # logger_server (plugin v0.1.10+) overrides EQ2_WORLD when present — - # enables a Varsoon-configured deployment to correctly resolve a - # Wuoshi upload, for instance. After the strict gate above the value is - # guaranteed valid; _resolve_uploader_guild_async re-sanitises it - # defensively and that fallback is now only reachable from the - # local-ingest pipeline. + # Guild resolve — cache/census_store only (any age). The response path + # NEVER waits on Census: a never-seen uploader gets CENSUS_UNAVAILABLE + # here, the encounter commits with guild_name=NULL, and the background + # backfill below does the live lookup after the response is out. (One + # degraded inline Census call was enough to blow the plugin's 20 s + # HttpClient timeout — 2026-07-28 incident.) logger_server (plugin + # v0.1.10+) overrides EQ2_WORLD; after the strict gate above the value + # is guaranteed valid. guild_result = await _resolve_uploader_guild_async(uploader, body.logger_server) - # Distinguish "Census down" (CENSUS_UNAVAILABLE) from "genuinely unguilded" (None). - # We commit the parse with guild_name=NULL in both cases and schedule a - # background retry only for the transient error case. + # CENSUS_UNAVAILABLE covers both "not in cache/store" and (via the + # backfill's own retry) "Census down" — either way: commit NULL now, + # backfill fills it in. census_error_on_guild = isinstance(guild_result, _CensusUnavailable) guild_name: str | None = None if census_error_on_guild else guild_result # type: ignore[assignment] diff --git a/tests/server/test_parses_ingest_cache.py b/tests/server/test_parses_ingest_cache.py index 7b67cba3..f2d456b4 100644 --- a/tests/server/test_parses_ingest_cache.py +++ b/tests/server/test_parses_ingest_cache.py @@ -98,6 +98,47 @@ async def test_uploader_guild_served_from_store_without_census_or_prewarm(store_ prewarm.assert_not_called() +@pytest.mark.asyncio +async def test_uploader_guild_response_path_never_calls_census(store_db): + """A never-seen uploader must NOT trigger an inline Census call on the + response path (default allow_census=False) — one degraded lookup blew + the plugin's 20 s HttpClient timeout (2026-07-28 incident). The resolve + returns CENSUS_UNAVAILABLE so the endpoint commits guild_name=NULL and + the background backfill picks it up.""" + client = MagicMock() + client.get_character_guild_name = AsyncMock(side_effect=AssertionError("Census must not be called inline")) + + with ( + patch.object(ingest, "character_cache", _empty_cache()), + patch.object(ingest, "shared_census_client", _client_factory(client)), + patch.object(ingest, "_prewarm_guild_silently", new=AsyncMock()) as prewarm, + ): + result = await ingest._resolve_uploader_guild_async("Neverseen", None) + + assert isinstance(result, ingest._CensusUnavailable) + client.get_character_guild_name.assert_not_called() + prewarm.assert_not_called() + + +@pytest.mark.asyncio +async def test_uploader_guild_backfill_path_uses_census(store_db): + """The background backfill opts in (allow_census=True): live lookup + + roster prewarm for a never-seen uploader.""" + client = MagicMock() + client.get_character_guild_name = AsyncMock(return_value="Exordium") + + with ( + patch.object(ingest, "character_cache", _empty_cache()), + patch.object(ingest, "shared_census_client", _client_factory(client)), + patch.object(ingest, "_prewarm_guild_silently", new=AsyncMock()) as prewarm, + ): + result = await ingest._resolve_uploader_guild_async("Neverseen", None, allow_census=True) + + assert result == "Exordium" + client.get_character_guild_name.assert_awaited_once() + prewarm.assert_called_once() + + @pytest.mark.asyncio async def test_ilvl_backfill_writes_through_to_store(store_db): # Store has class/level/guild but no equipment → ilvl None triggers backfill.