Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ The [EQ2LexiconACTPlugin](https://github.com/VortexUK/EQ2LexiconACTPlugin) sends

Plugin auto-detects the EQ2 server from its log file path (`<install>/logs/<server>/eq2log_<character>.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)**:

Expand Down
41 changes: 25 additions & 16 deletions backend/server/api/parses/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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]

Expand Down
41 changes: 41 additions & 0 deletions tests/server/test_parses_ingest_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading