Skip to content

fix(cache): invalidate persona AI context caches across pods - #31656

Merged
pmbrull merged 2 commits into
mainfrom
fix/persona-context-cross-pod-invalidation
Aug 19, 2026
Merged

fix(cache): invalidate persona AI context caches across pods#31656
pmbrull merged 2 commits into
mainfrom
fix/persona-context-cross-pod-invalidation

Conversation

@pmbrull

@pmbrull pmbrull commented Aug 17, 2026

Copy link
Copy Markdown
Member

Problem

PersonaContextCache.local (Caffeine) and SubjectCache.USER_CONTEXT_CACHE (Guava) are per-JVM and were never reached by the CacheInvalidationPubSub subscriber — neither implements Invalidatable and neither is passed to CacheBundle.registerInvalidatable. On a multi-replica deployment a persona change is visible on one pod and stale on the others for up to that cache's TTL (30 min / 15 min), intermittently, depending on which pod the load balancer picks.

Symptoms, all reported against Ask Collate:

  • Admin "regenerate persona context" appears broken. refresh() rewrites Redis and the calling pod's local entry only. The definition hash doesn't change, so peers keep serving the identically-keyed stale entry until their own TTL.
  • A persona switch intermittently doesn't take. After assigning a persona, peers keep a User without it, so SubjectContext.getActivePersona() silently discards the X-OpenMetadata-Persona header and falls back to the default persona (Requested persona '<id>' is not assigned to user '<user>').
  • Same user, same question, different answers depending on routing.

Reported downstream as open-metadata/openmetadata-collate#5847, which has the full analysis.

Changes

  • PersonaContextCache implements Invalidatable, registered with CacheBundle. A peer can't know the definition hash the local entry is keyed by (personaId:definitionHash), so it drops by personaId: prefix and clears generationStates.

    The drop is local only, deliberately: the publishing pod already wrote (or deleted) the authoritative Redis copy, and a peer deleting those keys would throw away a document that was just rebuilt and make every pod re-run the ES-heavy build.

  • refresh() publishes explicitly. It rewrites Redis but mutates no entity, so nothing broadcast it before. It goes out under a non-entity type (CacheInvalidationPubSub.TYPE_PERSONA_CONTEXT) so peers drop the document without bumping the persona write epoch or evicting entity caches for a change that touched no entity. The handler in CacheBundle gates the entity-cache work on that type; the Invalidatable fan-out still runs.

  • SubjectCache registers an Invalidatable too. A user write drops that user's context; a persona or team write drops all of them (a team's defaultPersona reaches users as inheritedPersonas, via membership or the parent hierarchy).

    The all-drop is blunt on purpose — the affected user set isn't derivable from the message, since assignments change through the persona, the user's defaultPersona, and a team's default alike. Persona writes are rare admin actions and the reload is a cached entity read. A context refresh publishes under TYPE_PERSONA_CONTEXT and so does not land here.

    The user match is case-insensitive: a user's FQN is the lower-cased quoted name, while the cache is keyed by the principal name as the request presented it.

  • PersonaRepository.postCreate publishes. EntityRepository.postCreate calls CacheBundle.invalidateEntity (local fan-out) but never publishes — only the update and delete paths do. So a persona created with users already assigned never reached peers at all.

  • Local context TTL capped at 60 s while Redis is up. Nothing invalidates when a referenced asset changes (a retagged table, an edited KB article, a new asset matching a rule), and that drift has no single entity to key an invalidation off — the cap is what bounds it, and it also bounds any dropped pub/sub message. Expiry then costs one mget of the shared copy, not a rebuild.

    With Redis down the local entry is the cache, so the definition's cacheTtlMinutes still stands there — capping unconditionally would make every pod re-run the build every minute.

Testing

PersonaContextCacheTest (2 new) and SubjectCacheTest (3 new) cover the remote-invalidate path: a message in, local entry gone, next read rebuilds; unrelated entity types leave the entry warm.

Both were confirmed non-vacuous by short-circuiting the two invalidators and watching all four positive tests fail (expected: <MISS> but was: <HIT>), then restoring.

  • mvn spotless:check clean, compile clean.
  • 315 tests green across aicontext, cache, policyevaluator, and PersonaRepositoryTest.

Not covered

  • No Redis-backed multi-pod integration test — the wiring is exercised at the Invalidatable boundary only.
  • generationStates is still pod-local, so GET /v1/personas/{id}/context/status can still report GENERATING on one pod and FRESH/STALE on another. Invalidation now clears it, but making the status genuinely shared needs Redis-backed state.
  • Invalidating on referenced-asset changes remains a design question rather than wiring; the TTL cap is the interim answer.

🤖 Generated with Claude Code

Fixes open-metadata/openmetadata-collate#5847

Greptile Summary

The PR adds distributed invalidation for persona AI context and user-context caches so persona changes converge across service pods.

  • Registers persona and subject cache invalidators with the shared cache bundle.
  • Publishes persona-context refresh and persona-create events to peer pods.
  • Handles persona, team, and user invalidations with cache-specific eviction behavior.
  • Caps local persona-context freshness while Redis is available.
  • Adds focused tests for remote invalidation behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
openmetadata-service/src/main/java/org/openmetadata/service/aicontext/PersonaContextCache.java Adds local-only distributed invalidation, explicit refresh publication, and a Redis-aware local TTL cap.
openmetadata-service/src/main/java/org/openmetadata/service/cache/CacheBundle.java Registers the new invalidators and separates non-entity persona-context signals from entity-cache eviction.
openmetadata-service/src/main/java/org/openmetadata/service/cache/CacheInvalidationPubSub.java Defines the non-entity persona-context invalidation message type.
openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PersonaRepository.java Publishes persona creation so peer pods invalidate contexts for initially assigned users.
openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/SubjectCache.java Clears peer user contexts for persona and team changes and selectively evicts users for USER messages.
openmetadata-service/src/test/java/org/openmetadata/service/aicontext/PersonaContextCacheTest.java Exercises persona-context remote invalidation and unrelated-message behavior.
openmetadata-service/src/test/java/org/openmetadata/service/security/policyevaluator/SubjectCacheTest.java Covers persona, team, user, and unrelated invalidation behavior for cached user contexts.

Sequence Diagram

sequenceDiagram
  participant Admin
  participant Writer as Writing pod
  participant Redis
  participant Peer as Peer pod
  participant Subject as SubjectCache
  Admin->>Writer: Update team/persona or refresh context
  Writer->>Redis: Update or invalidate shared cache
  Writer->>Redis: Publish invalidation message
  Redis-->>Peer: TEAM / USER / PERSONA / personaContext
  Peer->>Subject: Fan out through registered Invalidatable
  Subject->>Subject: Evict affected local user contexts
  Note over Peer: Next request reloads current shared or persisted state
Loading

Reviews (2): Last reviewed commit: "fix(cache): drop peer user contexts on t..." | Re-trigger Greptile

Context used:

PersonaContextCache.local and SubjectCache.USER_CONTEXT_CACHE are per-JVM
and were never reached by the CacheInvalidationPubSub subscriber, so on a
multi-replica deployment a persona change was visible on one pod and stale
on the others for up to that cache's TTL (30 min / 15 min).

- PersonaContextCache implements Invalidatable and is registered with
  CacheBundle. A peer can't know the definition hash the local entry is
  keyed by, so it drops by "personaId:" prefix and clears generationStates.
  The drop is local only: the publishing pod already wrote (or deleted) the
  authoritative Redis copy, and a peer deleting those keys would throw away
  a document that was just rebuilt.
- refresh() publishes explicitly. It rewrites Redis but mutates no entity,
  so nothing broadcast it before and an admin regenerate was silently a
  no-op on every pod but the one that served the request. It goes out under
  a non-entity type so peers don't bump the persona write epoch or evict
  entity caches for a document-only change.
- SubjectCache registers an Invalidatable too: a user write drops that
  user's context (matched case-insensitively, since the FQN is lower-cased
  while the cache is keyed by the principal name), a persona write drops
  all of them. Without this, SubjectContext.getActivePersona() kept
  discarding a freshly assigned persona as "not assigned" for up to 15
  minutes, which reads as a persona switch that intermittently doesn't take.
- PersonaRepository.postCreate publishes; creates don't broadcast otherwise,
  so a persona created with users assigned never reached peers.
- Cap the local context TTL at 60s while Redis is up. Asset drift (a
  retagged table, an edited KB article) has no entity to key an
  invalidation off, so the cap is what bounds it. With Redis down the local
  entry is the whole cache, so the definition's TTL still stands there.

Fixes open-metadata/openmetadata-collate#5847

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pmbrull
pmbrull requested a review from a team as a code owner August 17, 2026 20:30
Copilot AI lite review requested due to automatic review settings August 17, 2026 20:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

❌ PR checklist incomplete

This PR cannot be merged until the following are addressed on its linked issue:

  • Linked issue open-metadata/openmetadata-collate#5847 does not exist or is not accessible.

The fields live on the linked issue in the Shipping project (open the issue → right sidebar → Projects). After you set them, re-run this check (or push a commit) — issue/project changes do not re-trigger it automatically.

Maintainers can bypass this check by adding the skip-pr-checks label.

@github-actions github-actions Bot added Ingestion safe to test Add this label to run secure Github workflows on PRs labels Aug 17, 2026
Comment on lines +78 to +85
private static final Invalidatable INVALIDATOR =
(type, id, fqn) -> {
if (Entity.PERSONA.equals(type)) {
invalidateAllUserContexts();
} else if (Entity.USER.equals(type) && fqn != null) {
invalidateUserContextByFqn(fqn);
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Edge Case: Peer reload after persona write may still read stale assignments

On a cross-pod persona assignment change the writing pod publishes a PERSONA invalidation; peers run SubjectCache.invalidator -> invalidateAllUserContexts() and then lazily reload each user via Entity.getEntityByName(USER, ..., "...,personas,defaultPersona"). That reload resolves the user's personas through the user-keyed relationship/entity caches (CachedRelationshipDao/CachedEntityDao), but the remote PERSONA invalidation only invalidates the Persona entity (onRemoteCacheInvalidate("persona", personaId, fqn) / cachedReadBundle.invalidate), not the affected users' entity or user-side relationship cache entries. If those are warm on the peer, dropping USER_CONTEXT_CACHE just repopulates it with the same stale persona list, so getActivePersona() can still reject the requested persona — the exact symptom this PR targets. Consider having the writer publish USER invalidations for the affected users (or invalidate the user-side relationship cache on peers) so the context reload sees the new assignments.

Was this helpful? React with 👍 / 👎

Comment on lines +177 to +184
private static void invalidateUserContextByFqn(String fqn) {
try {
String userName = FullyQualifiedName.unquoteName(fqn);
USER_CONTEXT_CACHE.asMap().keySet().removeIf(key -> key.equalsIgnoreCase(userName));
} catch (Exception e) {
LOG.debug("Could not invalidate user context for fqn {}", fqn, e);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Performance: SubjectCache invalidator scans full context cache on every USER write

invalidateUserContextByFqn does USER_CONTEXT_CACHE.asMap().keySet().removeIf(key -> key.equalsIgnoreCase(userName)), a full linear scan of the context cache. Because the invalidator is registered globally, this runs for every remote USER write across the whole cluster. The cache is bounded so impact is limited, but on a busy deployment with many user writes this is repeated O(n) scans where an exact-key invalidate(userName) (plus its lower-cased form) would be O(1). Consider invalidating the specific key(s) directly instead of scanning.

Was this helpful? React with 👍 / 👎

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

✅ Playwright Results — workflow succeeded

Validated commit c059194c57ade45531098543b2b5651a9bed9548 in Playwright run 32107002274, attempt 1.

✅ 796 passed · ❌ 0 failed · 🟡 1 flaky · ⏭️ 0 skipped · 🧰 0 lifecycle flaky

Performance

Blocking targets: ✅ met · Optimization targets: 🟡 in progress

Shard-job maxima below are not the full workflow wall time; the linked run includes build, fixture, planning, and reporting.

🕒 Full workflow signal wall (to summary) 59m 30s

⏱️ Max setup 3m 51s · max shard execution 21m 25s · max shard-job elapsed before upload 24m 55s · reporting 5s

🌐 220.55 requests/attempt · 2.66 app boots/UI scenario · 7.55% common-shard skew

Optimization targets still in progress:

  • Browser traffic was 220.55 requests per attempt (convergence target: fewer than 200).
  • Application boot ratio was 2.66 per UI scenario (2503 boots / 940 scenarios; convergence target: at most 1).
Shard Passed Failed Flaky Skipped Lifecycle failed Lifecycle flaky
✅ Shard chromium-01 158 0 0 0 0 0
✅ Shard chromium-02 166 0 0 0 0 0
✅ Shard chromium-03 160 0 0 0 0 0
🟡 Shard chromium-04 143 0 1 0 0 0
✅ Shard data-asset-rules-01 61 0 0 0 0 0
✅ Shard domain-isolation-01 14 0 0 0 0 0
✅ Shard global-state-01 34 0 0 0 0 0
✅ Shard import-export-01 17 0 0 0 0 0
✅ Shard ingestion-01 1 0 0 0 0 0
✅ Shard reindex-01 2 0 0 0 0 0
✅ Shard search-01 11 0 0 0 0 0
✅ Shard search-rbac-01 29 0 0 0 0 0
🟡 1 flaky test(s) (passed on retry)
  • Pages/Entity.spec.tsUser as Owner with unsorted list (shard chromium-04, 1 retry)

📦 Download artifacts

How to debug locally
# Download playwright-test-results-<shard> artifact and unzip
npx playwright show-trace path/to/trace.zip    # view trace

@pmbrull pmbrull added this to Shipping Aug 18, 2026
A team's defaultPersona reaches users as inheritedPersonas, and membership
or parent-hierarchy edits change which teams contribute one. Those writes
publish type=team, which the invalidator ignored, so a peer kept the old
inherited persona for up to the 15-minute TTL — the same symptom this PR
fixes for direct assignments. TeamRepository already invalidates locally
(SubjectCache.invalidateAll on hierarchy/policy change); this is the peer
half.

Scoped to user contexts. USER_POLICIES_CACHE has its own 2-minute TTL and
peers relying on it is pre-existing behaviour this persona fix shouldn't
widen.

Also document why invalidateUserContextByFqn scans case-insensitively
rather than doing an O(1) exact-key invalidate: SecurityUtil.getUserName
splits the principal on [/@] without case-folding, so an IdP emitting
John.Doe@corp.com keys the cache under "John.Doe" while the FQN is
"john.doe". An exact-key lookup would silently miss those.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 18, 2026 06:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pmbrull

pmbrull commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Thanks — went through all three. One is a real gap and is fixed in c059194; the other two I'm pushing back on, with the reasoning below.

✅ greptile: team updates bypass peer eviction — fixed

Correct, and it's the same bug class as the one this PR targets. A team's defaultPersona reaches users as inheritedPersonas (UserRepository.getInheritedPersonas resolves Team→Persona HAS rows for the user's teams), and membership or parent-hierarchy edits change which teams contribute one. All of those publish type=team, which the invalidator ignored. TeamRepository:1348 already calls SubjectCache.invalidateAll() locally on hierarchy/policy change — this was missing the peer half.

Applied the suggestion, plus a test (testRemoteTeamWriteDropsAllUserContexts).

I kept it scoped to user contexts rather than invalidateAll(). USER_POLICIES_CACHE has its own 2-minute TTL, and peers relying on that is pre-existing behaviour I'd rather not widen inside a persona-context fix.

❌ gitar: peer reload may still read stale assignments — not applicable

The premise is that the reload resolves personas "through the user-keyed relationship/entity caches (CachedRelationshipDao/CachedEntityDao)". Neither caches persona assignments:

  • CachedRelationshipDao only caches owners, domains, and CONTAINS containers. getFromEntityRef gates the cache on fromEntityType == null && relationship == CONTAINS (EntityRepository.java:7458-7461), with the comment right above it: "Other relationship types (OWNS, HAS, FOLLOWS, ...) change per-write and must always hit the DB so downstream inheritance sees the freshest record." APPLIED_TO and DEFAULTS_TO are not cached.
  • Entity.USER is in UNCACHED_ENTITY_TYPES (EntityRepository.java:3947), so the Redis L2 read/write for users is skipped entirely — isCacheableEntityType gates both the read-through at :10860 and the populate at :10922.
  • The L1 CACHE_WITH_NAME holds the raw DB row, which doesn't carry relationship-derived fields. Personas are re-resolved on every load by the field fetchers, which go straight to JDBI: fetchAndSetPersonasrelationshipDAO().findFromBatch(userIds, APPLIED_TO, ...) (UserRepository.java:1059), fetchAndSetDefaultPersonafindFromBatch(..., DEFAULTS_TO, ...) (:1086), getPersonasfindFromfindFromRecordsrelationshipDAO().findFrom (EntityRepository.java:7315-7323).

So dropping USER_CONTEXT_CACHE on a peer repopulates from the database and does see the new assignments. Happy to be corrected if I've missed a path.

❌ gitar: O(n) scan in invalidateUserContextByFqn — kept deliberately, now documented

The exact-key alternative would silently reintroduce the bug. SecurityUtil.getUserName is principal.getName().split("[/@]")[0] (SecurityUtil.java:64-67) — no case-folding. An IdP emitting John.Doe@corp.com keys this cache under John.Doe, while the user's FQN is quoteName(name.toLowerCase()) = john.doe (UserRepository.java:182). Keys also arrive from createdBy/updatedBy strings via SubjectContext.getSubjectContext(...) in the CSV import and test-case paths, which carry user.getName() case. Neither exact nor lower-cased lookup finds John.Doe.

On cost: the scan is bounded by the cache's maximumSize, and it only runs on user writes — logins and profile edits. The genuinely hot path, per-request activity tracking, updates the row through userDAO().updateLastActivityTime (a raw JSON_SET, UserRepository.java:336) which never reaches invalidateCachesAfterStore and so publishes nothing.

Added a comment at the call site so this doesn't get "optimised" into a correctness bug later.

@pmbrull
pmbrull added this pull request to the merge queue Aug 19, 2026
Merged via the queue into main with commit 9da04e7 Aug 19, 2026
110 of 114 checks passed
@pmbrull
pmbrull deleted the fix/persona-context-cross-pod-invalidation branch August 19, 2026 15:13
@github-project-automation github-project-automation Bot moved this to Done ✅ in Shipping Aug 19, 2026
@gitar-bot

gitar-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown
Code Review ⚠️ Changes requested 0 resolved / 2 findings

Adds cross-pod invalidation for persona AI contexts and user state via new Invalidatable implementations and pub/sub messaging. However, peer pods can still serve stale inherited persona state because SubjectCache ignores team updates.

⚠️ Edge Case: Peer reload after persona write may still read stale assignments

📄 openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/SubjectCache.java:78-85 📄 openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/SubjectCache.java:214-220 📄 openmetadata-service/src/main/java/org/openmetadata/service/cache/CacheBundle.java:120-134

On a cross-pod persona assignment change the writing pod publishes a PERSONA invalidation; peers run SubjectCache.invalidator -> invalidateAllUserContexts() and then lazily reload each user via Entity.getEntityByName(USER, ..., "...,personas,defaultPersona"). That reload resolves the user's personas through the user-keyed relationship/entity caches (CachedRelationshipDao/CachedEntityDao), but the remote PERSONA invalidation only invalidates the Persona entity (onRemoteCacheInvalidate("persona", personaId, fqn) / cachedReadBundle.invalidate), not the affected users' entity or user-side relationship cache entries. If those are warm on the peer, dropping USER_CONTEXT_CACHE just repopulates it with the same stale persona list, so getActivePersona() can still reject the requested persona — the exact symptom this PR targets. Consider having the writer publish USER invalidations for the affected users (or invalidate the user-side relationship cache on peers) so the context reload sees the new assignments.

💡 Performance: SubjectCache invalidator scans full context cache on every USER write

📄 openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/SubjectCache.java:177-184

invalidateUserContextByFqn does USER_CONTEXT_CACHE.asMap().keySet().removeIf(key -> key.equalsIgnoreCase(userName)), a full linear scan of the context cache. Because the invalidator is registered globally, this runs for every remote USER write across the whole cluster. The cache is bounded so impact is limited, but on a busy deployment with many user writes this is repeated O(n) scans where an exact-key invalidate(userName) (plus its lower-cased form) would be O(1). Consider invalidating the specific key(s) directly instead of scanning.

🤖 Prompt for agents
Code Review: Adds cross-pod invalidation for persona AI contexts and user state via new `Invalidatable` implementations and pub/sub messaging. However, peer pods can still serve stale inherited persona state because `SubjectCache` ignores team updates.

1. ⚠️ Edge Case: Peer reload after persona write may still read stale assignments
   Files: openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/SubjectCache.java:78-85, openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/SubjectCache.java:214-220, openmetadata-service/src/main/java/org/openmetadata/service/cache/CacheBundle.java:120-134

   On a cross-pod persona assignment change the writing pod publishes a `PERSONA` invalidation; peers run `SubjectCache.invalidator` -> `invalidateAllUserContexts()` and then lazily reload each user via `Entity.getEntityByName(USER, ..., "...,personas,defaultPersona")`. That reload resolves the user's personas through the user-keyed relationship/entity caches (`CachedRelationshipDao`/`CachedEntityDao`), but the remote `PERSONA` invalidation only invalidates the Persona entity (`onRemoteCacheInvalidate("persona", personaId, fqn)` / `cachedReadBundle.invalidate`), not the affected users' entity or user-side relationship cache entries. If those are warm on the peer, dropping USER_CONTEXT_CACHE just repopulates it with the same stale persona list, so `getActivePersona()` can still reject the requested persona — the exact symptom this PR targets. Consider having the writer publish USER invalidations for the affected users (or invalidate the user-side relationship cache on peers) so the context reload sees the new assignments.

2. 💡 Performance: SubjectCache invalidator scans full context cache on every USER write
   Files: openmetadata-service/src/main/java/org/openmetadata/service/security/policyevaluator/SubjectCache.java:177-184

   `invalidateUserContextByFqn` does `USER_CONTEXT_CACHE.asMap().keySet().removeIf(key -> key.equalsIgnoreCase(userName))`, a full linear scan of the context cache. Because the invalidator is registered globally, this runs for every remote USER write across the whole cluster. The cache is bounded so impact is limited, but on a busy deployment with many user writes this is repeated O(n) scans where an exact-key `invalidate(userName)` (plus its lower-cased form) would be O(1). Consider invalidating the specific key(s) directly instead of scanning.

Options

Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Powered by Gitar — free for open source

pmbrull added a commit that referenced this pull request Aug 19, 2026
* fix(cache): invalidate persona AI context caches across pods

PersonaContextCache.local and SubjectCache.USER_CONTEXT_CACHE are per-JVM
and were never reached by the CacheInvalidationPubSub subscriber, so on a
multi-replica deployment a persona change was visible on one pod and stale
on the others for up to that cache's TTL (30 min / 15 min).

- PersonaContextCache implements Invalidatable and is registered with
  CacheBundle. A peer can't know the definition hash the local entry is
  keyed by, so it drops by "personaId:" prefix and clears generationStates.
  The drop is local only: the publishing pod already wrote (or deleted) the
  authoritative Redis copy, and a peer deleting those keys would throw away
  a document that was just rebuilt.
- refresh() publishes explicitly. It rewrites Redis but mutates no entity,
  so nothing broadcast it before and an admin regenerate was silently a
  no-op on every pod but the one that served the request. It goes out under
  a non-entity type so peers don't bump the persona write epoch or evict
  entity caches for a document-only change.
- SubjectCache registers an Invalidatable too: a user write drops that
  user's context (matched case-insensitively, since the FQN is lower-cased
  while the cache is keyed by the principal name), a persona write drops
  all of them. Without this, SubjectContext.getActivePersona() kept
  discarding a freshly assigned persona as "not assigned" for up to 15
  minutes, which reads as a persona switch that intermittently doesn't take.
- PersonaRepository.postCreate publishes; creates don't broadcast otherwise,
  so a persona created with users assigned never reached peers.
- Cap the local context TTL at 60s while Redis is up. Asset drift (a
  retagged table, an edited KB article) has no entity to key an
  invalidation off, so the cap is what bounds it. With Redis down the local
  entry is the whole cache, so the definition's TTL still stands there.

Fixes open-metadata/openmetadata-collate#5847

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cache): drop peer user contexts on team writes too

A team's defaultPersona reaches users as inheritedPersonas, and membership
or parent-hierarchy edits change which teams contribute one. Those writes
publish type=team, which the invalidator ignored, so a peer kept the old
inherited persona for up to the 15-minute TTL — the same symptom this PR
fixes for direct assignments. TeamRepository already invalidates locally
(SubjectCache.invalidateAll on hierarchy/policy change); this is the peer
half.

Scoped to user contexts. USER_POLICIES_CACHE has its own 2-minute TTL and
peers relying on it is pre-existing behaviour this persona fix shouldn't
widen.

Also document why invalidateUserContextByFqn scans case-insensitively
rather than doing an O(1) exact-key invalidate: SecurityUtil.getUserName
splits the principal on [/@] without case-folding, so an IdP emitting
John.Doe@corp.com keys the cache under "John.Doe" while the FQN is
"john.doe". An exact-key lookup would silently miss those.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pmbrull

pmbrull commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

picked to 2.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Ingestion safe to test Add this label to run secure Github workflows on PRs

Projects

Status: Done ✅

Development

Successfully merging this pull request may close these issues.

3 participants