Skip to content

Bound dynamic-provider cache TTL by default + add invalidation API (#105)#106

Open
heskew wants to merge 1 commit into
mainfrom
bugfix/dynamic-provider-cache-invalidation
Open

Bound dynamic-provider cache TTL by default + add invalidation API (#105)#106
heskew wants to merge 1 commit into
mainfrom
bugfix/dynamic-provider-cache-invalidation

Conversation

@heskew
Copy link
Copy Markdown
Member

@heskew heskew commented May 28, 2026

Fixes #105.

Problem

The dynamic-provider cache (for onResolveProvider-resolved providers) was never evicted under the default config. cacheDynamicProviders defaulted to trueInfinity, and nothing — backing-store writes, plugin config reloads, login/logout — cleared it. Once an oac-*/dynamic provider was resolved, disabling it, deleting it, or rotating its clientId/clientSecret had no effect until process restart, and a revoked SSO config kept authenticating through the per-request session-validation middleware.

Change

  1. Bounded default TTL. cacheDynamicProviders now defaults to 300s (new exported DEFAULT_DYNAMIC_PROVIDER_CACHE_TTL_SECONDS) instead of forever, so every worker thread re-resolves within the window. This matches the cache's documented "bounded window" intent.
  2. Explicit invalidation API. New public DynamicProviderCache.delete(name) and exported invalidateDynamicProvider(providerConfigId) / clearDynamicProviderCache(), backed by a module-level reference to the active cache (mirrors the existing activeHookManager pattern). A consumer can evict immediately when its backing config changes.

The cache is in-memory per worker thread, so explicit invalidation reaches only the calling thread; the bounded TTL is the cross-thread convergence mechanism. Both behaviors are documented in the code.

⚠️ Behavior change

The default goes from cache-forever → 300s. Any deployment relying on indefinite in-process caching of a dynamically-resolved provider will now re-resolve every 5 minutes. cacheDynamicProviders: true still opts back into forever. This is intentional — forever-with-no-invalidation is the bug.

Tests

  • test/lib/dynamicProviderCache.test.js: delete() coverage; corrected the default-TTL test (was asserting "forever"; now asserts the bounded default expires past the window).
  • test/dynamic-provider-invalidation.test.js (new): exercises the index.ts wiring — invalidate-before-load is a no-op, resolve-on-miss → cache-hit → invalidate evicts → re-resolve, clear(), and the scope-close ownership guard.
  • Full suite: 553 pass / 2 skip / 0 fail. Lint + format clean.

Cross-model review (thorough: codex + gemini + Harper adjudication)

Per the engineering guidelines. No blocker introduced. Gemini's "module-level singleton race" was adjudicated as NOISE under Harper's runtime (per-thread module context, single-threaded, no shared Map); it mirrors the pre-existing activeHookManager singleton, and the scope-close guard (=== dynamicProviderCache) was confirmed correct. Codex: no findings. The one real item — the default-TTL behavior change — is the fix itself, flagged above.

Follow-ups

  • Consumer wiring (central-manager): CM's updateOrganizationSettings should call invalidateDynamicProvider(configId) after writing/disabling/rotating an org OAuth config for immediate effect. Separate CM change.
  • Backport to v1.x (the 1.x line CM currently runs) — to follow.

🤖 Generated with Claude Code

)

The dynamic-provider cache (for onResolveProvider-resolved providers) was
never evicted under the default config: cacheDynamicProviders defaulted to
true (Infinity), and nothing — backing-store writes, config reloads,
login/logout — cleared it. Disabling, deleting, or rotating credentials on a
dynamically-resolved provider therefore had no effect until process restart,
and a revoked SSO config kept authenticating through the session-validation
middleware.

Two changes:
- Default cacheDynamicProviders to a bounded 300s TTL instead of forever, so
  every worker thread re-resolves within the window. `true` still opts into
  forever. This matches the cache's documented "bounded window" intent.
- Add a public DynamicProviderCache.delete() and export
  invalidateDynamicProvider()/clearDynamicProviderCache() so a consumer can
  evict immediately when its backing config changes. The cache is
  per-worker-thread, so explicit invalidation reaches only the calling thread;
  the bounded TTL is the cross-thread convergence mechanism (documented).

Adds delete() unit tests and corrects the default-TTL test (was asserting
"forever"; now asserts the bounded default expires past the window).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces module-level functions (invalidateDynamicProvider and clearDynamicProviderCache) to allow consumers to invalidate or clear dynamically-resolved providers from the in-memory cache. It also changes the default cache TTL from infinite (true) to a bounded 300 seconds to prevent serving stale data indefinitely. The feedback highlights a potential race condition when multiple plugin instances are active in the same thread, as a single activeDynamicProviderCache reference can be prematurely set to null upon closing one instance. The reviewer suggests using a Set of active caches to safely track and propagate invalidation requests across all active instances.

Comment thread src/index.ts
Comment on lines +47 to +75
// Active dynamic-provider cache for the loaded plugin instance, so consumers
// can invalidate entries when their backing config changes. Per-worker-thread:
// this references the cache in the thread that ran handleApplication.
let activeDynamicProviderCache: DynamicProviderCache | null = null;

/**
* Invalidate a single dynamically-resolved provider in the in-memory cache.
* Call this from application code after the backing config for `providerConfigId`
* changes (disabled, deleted, or credentials rotated) so the next request
* re-resolves it via the `onResolveProvider` hook instead of serving stale data.
*
* NOTE: the cache is per-worker-thread, so this evicts only in the thread that
* runs the call. Other threads converge within the configured TTL
* (`cacheDynamicProviders`, default {@link DEFAULT_DYNAMIC_PROVIDER_CACHE_TTL_SECONDS}s).
* For immediate cluster-wide effect, pair invalidation with a short TTL.
*
* @returns true if an entry was present and removed in this thread.
*/
export function invalidateDynamicProvider(providerConfigId: string): boolean {
return activeDynamicProviderCache?.delete(providerConfigId) ?? false;
}

/**
* Clear every dynamically-resolved provider from the in-memory cache (this
* worker thread only — see {@link invalidateDynamicProvider} for cross-thread notes).
*/
export function clearDynamicProviderCache(): void {
activeDynamicProviderCache?.clear();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Using a single activeDynamicProviderCache reference can lead to issues when multiple plugin instances are active in the same thread. Specifically, if one instance is closed, it sets activeDynamicProviderCache to null, which completely disables invalidation for any other active instances.

Using a Set of active caches solves this race condition and ensures that invalidation requests are correctly propagated to all active instances.

const activeDynamicProviderCaches = new Set<DynamicProviderCache>();

/**
 * Invalidate a single dynamically-resolved provider in the in-memory cache.
 * Call this from application code after the backing config for `providerConfigId`
 * changes (disabled, deleted, or credentials rotated) so the next request
 * re-resolves it via the `onResolveProvider` hook instead of serving stale data.
 *
 * NOTE: the cache is per-worker-thread, so this evicts only in the thread that
 * runs the call. Other threads converge within the configured TTL
 * (`cacheDynamicProviders`, default {@link DEFAULT_DYNAMIC_PROVIDER_CACHE_TTL_SECONDS}s).
 * For immediate cluster-wide effect, pair invalidation with a short TTL.
 *
 * @returns true if an entry was present and removed in this thread.
 */
export function invalidateDynamicProvider(providerConfigId: string): boolean {
	let deleted = false;
	for (const cache of activeDynamicProviderCaches) {
		if (cache.delete(providerConfigId)) {
			deleted = true;
		}
	}
	return deleted;
}

/**
 * Clear every dynamically-resolved provider from the in-memory cache (this
 * worker thread only — see {@link invalidateDynamicProvider} for cross-thread notes).
 */
export function clearDynamicProviderCache(): void {
	for (const cache of activeDynamicProviderCaches) {
		cache.clear();
	}
}

Comment thread src/index.ts
Comment on lines +136 to +137
// Expose this thread's cache for consumer-driven invalidation
activeDynamicProviderCache = dynamicProviderCache;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Add the newly created cache to the set of active caches instead of overwriting a single reference.

Suggested change
// Expose this thread's cache for consumer-driven invalidation
activeDynamicProviderCache = dynamicProviderCache;
// Expose this thread's cache for consumer-driven invalidation
activeDynamicProviderCaches.add(dynamicProviderCache);

Comment thread src/index.ts
Comment on lines +354 to +356
if (activeDynamicProviderCache === dynamicProviderCache) {
activeDynamicProviderCache = null;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Remove the cache from the set of active caches when the scope is closed.

		activeDynamicProviderCaches.delete(dynamicProviderCache);

@claude
Copy link
Copy Markdown

claude Bot commented May 28, 2026

Reviewed; no blockers found.

@github-actions
Copy link
Copy Markdown
Contributor

3 blockers found.

1. Documentation drift: Configuration default

File: docs/configuration.md:28
What: The configuration table still lists the default for cacheDynamicProviders as true (forever).
Why it matters: This is now misleading as the PR changes the default behavior to a bounded 300-second TTL. Users expecting permanent caching will be surprised by periodic re-resolutions.
Suggested fix: Update the "Default" column from true to 300 (or 300s) and update the description to reflect the new default.

2. Documentation drift: Lifecycle hooks stale wording

File: docs/lifecycle-hooks.md:147
What: The documentation explicitly states that the plugin caches resolved provider configs "permanently" by default and the example still marks cacheDynamicProviders: true as the default.
Why it matters: Users relying on the documentation will have incorrect expectations about how long dynamic configurations persist in memory.
Suggested fix: Update the description to state that caching is bounded (300s) by default and update the code example comment to reflect the new default.

3. Missing documentation for new public APIs

File: docs/api-reference.md:192
What: The PR introduces new public exports invalidateDynamicProvider() and clearDynamicProviderCache() but does not add them to the Programmatic API section of the API reference.
Why it matters: Integrators won't know these APIs exist without reading the source code, defeating the purpose of adding them for consumer-driven invalidation.
Suggested fix: Add a section under "Programmatic API" documenting both functions, their parameters, and the per-worker-thread caveat mentioned in the source code.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dynamic provider cache is never evicted — disable/delete/credential-rotation of a resolved provider has no effect until restart

1 participant