Skip to content

fix(storage,di): invalidate the entity cache after commit; populate framework plumbing once (#450, #451) - #465

Merged
JabbaKadabra merged 1 commit into
masterfrom
fix/cache-invalidation-and-proxy-logging-450-451
Jul 26, 2026
Merged

fix(storage,di): invalidate the entity cache after commit; populate framework plumbing once (#450, #451)#465
JabbaKadabra merged 1 commit into
masterfrom
fix/cache-invalidation-and-proxy-logging-450-451

Conversation

@JabbaKadabra

Copy link
Copy Markdown
Collaborator

Summary

Two infrastructure defects that each come from a registration or ordering detail rather than the logic on top of it.

#450IEntityCache invalidation ran inside the still-open transaction. CanUseCache suppresses the cache for the writing flow only (ambient.IsActive is AsyncLocal), so between that invalidation and the commit a reader on another flow could miss the cache, read the pre-commit row and Set() it back — with nothing invalidating afterwards. The stale entity was then served until the 5-minute TTL expired, and it carries a stale UpdatedAt, which is the optimistic-concurrency token: every write made against it failed the pre-check in UpdateCoreAsync. A millisecond race became minutes of failing writes on hot, cached, ingest-mutated entities like IAgent.

#451 — every upstream proxy request was logged 4×. RegisterServiceCollection builds a fresh ServiceCollection per call, and AddHttpClient shares its plumbing through TryAddEnumerable, which dedupes only within one collection. The four modules that call it (Api, Application, Licensing, Proxy) therefore each contributed an IHttpMessageHandlerBuilderFilter, and each filter's logging handler wrapped every request — exactly the four identical sets of log lines with elapsed times 0.01 ms apart.

Closes #450
Closes #451

Changes

  • AbstractRepository.InvalidateCacheEntry (and a new InvalidateCache for the whole-cache case) invalidate now and again on commit, queued through AmbientDbContext.RegisterPostCommit — the same deferral Notify already uses for change events.
  • Every write path routes through those helpers: add, add-range, update, upsert, remove, remove-all, plus the bypass writes in ArchivableRepository (archive/unarchive) and AgentRepository (SetCurrentVersionIdAsync, and the agent-version cache on create). No cache?.Invalidate(...) call sites remain outside them.
  • AutofacExtensions.RegisterServiceCollection drops descriptors whose (service, implementation, lifetime) triple an earlier call already populated into the same container. An identical type-based registration can never mean two different things; genuine multi-registrations use distinct implementation types, and instance/factory descriptors — which is what carries each named client's configuration — are left exactly as written.

Verification

Both regression tests were confirmed to fail with the fix reverted and pass with it:

  • CachedRepositoryTests.UpdateAsync_WhenAConcurrentReaderRepopulatesMidTransaction_TheEntryIsDroppedAfterCommit — reads the row from a flow with ExecutionContext.SuppressFlow() (so the ambient AsyncLocal is not inherited and the read repopulates the cache), then asserts the commit clears it. This tests the mechanism rather than provider isolation, since the EF in-memory provider has no real transaction.
  • HttpClientRegistrationTests (Proxy.Tests) — four modules' worth of AddHttpClient, asserting one filter of each kind, plus that every named client keeps its own configuration.
  • AutofacExtensionsTests (Common.Tests) — dedupe, distinct implementations preserved, instance registrations untouched.

Full backend suite green: Common 187, Storage 781, Domain 378, Application 474 (6 skipped), Api 471, Proxy 85, Infrastructure 53, Licensing 61, Messaging 9, Serialization 63. Build: 24 projects, 0 warnings.

Docs: docs/database.md gains a section on why invalidation happens twice; docs/architecture.md documents the RegisterServiceCollection dedupe. Two [Unreleased] → Fixed changelog entries.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UX21BWN6krigPSJJQVNexi

…ramework plumbing once

Two infrastructure defects that both come from a registration or ordering
detail rather than from the logic on top of it.

IEntityCache invalidation ran inside the still-open transaction. CanUseCache
suppresses the cache for the *writing* flow only — ambient.IsActive is
AsyncLocal — so between that invalidation and the commit a reader on another
flow could miss the cache, read the pre-commit row and Set() it back, with
nothing invalidating afterwards. The stale entity was then served until the
5-minute TTL expired, and because it carries a stale UpdatedAt — the
optimistic-concurrency token — every write made against it failed the
pre-check in UpdateCoreAsync. A millisecond race became minutes of failing
writes on hot, cached, ingest-mutated entities like IAgent, which is the
leading suspect for the agent-row conflicts seen during trace ingestion.

InvalidateCacheEntry (and a new InvalidateCache for the whole-cache case) now
invalidate immediately *and* again on commit, queued through
RegisterPostCommit — the same deferral Notify already uses for change events.
Every write path routes through them, including the bypass writes in
AgentRepository and ArchivableRepository, so no site can drift back.

Separately, RegisterServiceCollection builds a fresh ServiceCollection per
call. AddHttpClient shares its plumbing through TryAddEnumerable, which dedupes
only within one collection, so the four modules that call it (Api, Application,
Licensing, Proxy) each contributed their own IHttpMessageHandlerBuilderFilter —
and each filter's logging handler wrapped every outgoing request. That is the
exact 4x duplication seen on the proxy's hottest path: one request, four
identical sets of log lines with elapsed times differing by 0.01 ms.

Populate now drops descriptors whose (service, implementation, lifetime) triple
an earlier call already registered in the same container. An identical
type-based registration can never mean two different things; genuine
multi-registrations use distinct implementation types, and instance/factory
descriptors — which carry the per-name client configuration — are untouched.

Refs #450, #451

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX21BWN6krigPSJJQVNexi
@JabbaKadabra
JabbaKadabra merged commit 06d5501 into master Jul 26, 2026
12 checks passed
@JabbaKadabra
JabbaKadabra deleted the fix/cache-invalidation-and-proxy-logging-450-451 branch July 26, 2026 15:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant