Files
mal/.codex/audits/frontend-performance-2026-07-03/fixes/09-canonical-episode-singleflight.md

4.4 KiB

Fix 09: deduplicate canonical episode refreshes

Priority: P2
Risk: Medium
Primary benefit: Provider load and cold anime-detail completion

Issue

The anime detail page loads release information and audio availability as independent HTMX fragments. Both can call EpisodeService.GetCanonicalEpisodes at nearly the same time.

During the cold audit, logs showed two episode refreshes for the same anime starting together. The Jikan layer collapsed part of its own refresh, but provider-ID resolution, availability checks, merging, and cache writes still ran twice.

Goal

Allow independent fragments to remain asynchronous while ensuring only one canonical-episode refresh runs for a given anime and refresh mode.

Where to deduplicate

Keep the existing fast cache checks outside the shared refresh. Only enter request collapsing after the service has decided that provider/Jikan refresh work is required.

Suggested key:

anime_id | refresh_policy

At minimum distinguish ordinary refresh from an explicit force refresh. A force refresh must not silently join a request that is intentionally serving an existing fresh cache entry.

Proposed flow

  1. Validate anime input.
  2. Read the canonical episode cache.
  3. Return immediately when it is fresh and the caller did not force refresh.
  4. Enter a keyed singleflight refresh.
  5. Recheck the cache inside the shared function; another request may have completed between steps 2 and 4.
  6. Fetch Jikan/provider data once.
  7. Merge and write the canonical cache once.
  8. Return cloned result data to all waiting callers.

The second cache check is important. It prevents back-to-back refreshes caused by requests that both observed the same stale entry before either joined the shared call.

Context and cancellation

Do not let one browser cancellation terminate useful work for all joined callers.

Recommended policy:

  • Create a service-owned bounded refresh context using a strict timeout.
  • Preserve tracing/request values when safe, but detach individual request cancellation.
  • Let each waiting caller stop waiting when its own context is cancelled.
  • Allow the shared refresh to finish for the cache and remaining callers.
  • Stop all refreshes during application shutdown through a service lifecycle context if one is available.

Using the first caller's context directly is simpler but creates a fragile leader/follower relationship: closing one detail fragment can fail the other fragment's refresh.

Result ownership

Canonical episode results contain slices. Return a clone to callers or treat the type as immutable by contract. Do not let one handler mutate data shared with another joined request or cache entry.

Error behavior

  • If refresh fails and stale canonical data is allowed by current policy, serve stale as today.
  • All joined callers should observe the same refresh error/result.
  • Do not negative-cache provider failures beyond the existing failure/backoff policy.
  • Preserve failure counters and next-refresh timestamps exactly once.

Affected files

  • internal/episodes/service/service.go
  • Optional helper under internal/episodes/service/
  • Episode service constructor/state
  • Episode service concurrency tests

Tests

  • Two concurrent ordinary refreshes call Jikan/provider once.
  • Cache is rechecked inside the shared refresh.
  • Force and ordinary refresh policies do not collide incorrectly.
  • One caller cancellation does not cancel the refresh for another caller.
  • All waiters receive equivalent cloned results.
  • Failed refresh records failure once.
  • Stale fallback behavior remains unchanged.
  • Race detector passes under concurrent refresh tests.

Run the focused tests with -race because this change introduces shared state.

Observability

Track:

  • Refresh owner count.
  • Joined caller count.
  • Cache hit after joining/recheck.
  • Shared refresh duration and result.
  • Caller cancellation while waiting.

Rollout

No route or template changes are required. Ship behind the service boundary, then verify that duplicate episodes_refresh_start events for one anime disappear under concurrent fragment loads.

Acceptance criteria

  • Concurrent release-info and audio-availability loads trigger one canonical refresh.
  • No additional provider request occurs after the inner cache recheck.
  • Caller cancellation does not poison unrelated waiters.
  • Existing stale/failure semantics remain intact.
  • go test -race ./internal/episodes/... passes.