AIManagement: workspace configuration cache is never invalidated when the reader's ApplicationName differs from the entity's ApplicationName
Module: Volo.AIManagement (ABP Commercial) Version: observed on 10.4.0 (code paths unchanged in current source) Database provider: any (reproduced with MongoDB)
Summary
WorkspaceConfigurationStore and WorkspaceChangedHandler build the distributed cache key from different sources, so editing a workspace does not invalidate the cached configuration that other applications are actually reading. Those applications keep serving the stale provider/model/API key until the cache entry expires on its own.
Read path —
WorkspaceConfigurationStore.GetOrNullAsynccaches under the current application's name:// Volo.AIManagement.Domain/Volo/AIManagement/Workspaces/Configuration/WorkspaceConfigurationStore.cs var cacheKey = WorkspaceConfigurationCacheKeyGenerator.Create( ApplicationInfoAccessor.ApplicationName!, name);Invalidation path —
WorkspaceChangedHandlerremoves the key built from the entity'sApplicationName:// Volo.AIManagement.Domain/Volo/AIManagement/Workspaces/WorkspaceChangedHandler.cs return _cache.RemoveAsync( WorkspaceConfigurationCacheKeyGenerator.Create( eventData.Entity.ApplicationName ?? string.Empty, eventData.Entity.Name));
Whenever Workspace.ApplicationName ≠ the reading process's IApplicationInfoAccessor.ApplicationName, the handler removes a key nobody reads, and the key everybody reads is never removed.
This is easy to hit in a normal multi-application ABP solution because ApplicationWorkspaceManager.CreateAsync stamps the workspace with the ApplicationName of the process that created it:
// Volo.AIManagement.Domain/Volo/AIManagement/Workspaces/ApplicationWorkspaceManager.cs
newWorkspace.ApplicationName = ApplicationInfoAccessor.ApplicationName ?? string.Empty;
A workspace seeded by the DbMigrator carries the migrator's ApplicationName forever, while the Blazor host, the HttpApi host, etc. each cache it under their own ApplicationName.
Steps to reproduce
- Solution with separate DbMigrator and web host (standard ABP tiered/multi-app template), distributed cache backed by Redis.
- Seed a workspace from the DbMigrator (e.g. via
ApplicationWorkspaceManager.CreateAsync("MyWorkspace", "OpenAI", "model-a")). - From the web host, resolve it once (
IChatClientResolver.ResolveAsync("MyWorkspace")orIWorkspaceConfigurationStore.GetOrNullAsync("MyWorkspace")) → the configuration is cached under(webHostAppName, "MyWorkspace"). - Edit the workspace from the AI Management UI (change
ModelNametomodel-b) →WorkspaceChangedHandlerremoves(dbMigratorAppName, "MyWorkspace"). - Resolve again from the web host.
Expected
The resolver returns a chat client configured with model-b (admin edits take effect on the next call).
Actual
The resolver keeps returning model-a until the cache entry expires (or the host restarts). With a long default cache expiration this looks like "workspace edits are silently ignored".
Suggested fix
Make both sides derive the key from the same source. Options:
- Key the cache by the entity's
ApplicationNameon the read path too (requires a lookup, or dropping ApplicationName from the key entirely sinceWorkspace.Nameis already unique), or - On change, invalidate all application-scoped keys for that workspace name.
Related observation
AIManagementChatClient<T> resolves the inner client once and reuses it for the instance's lifetime (EnsureClientCreatedAsync), so long-lived consumers (Blazor Server circuits, background workers) additionally pin the configuration snapshot from their first call even after the cache is fixed. Consider re-resolving when the underlying configuration changes.
6 Answer(s)
-
0
Your analysis is correct: the module’s documented cache key format is application-scoped (
WorkspaceConfiguration:{ApplicationName}:{WorkspaceName}), so invalidating with a differentApplicationNamewill not evict what other processes have cached. In a typical tiered/multi-app solution (DbMigrator, HttpApi.Host, Blazor/Mvc UI, etc.), this can easily produce stale workspace configuration until TTL expires.Immediate workaround (no module source change): make
ApplicationNameconsistent across all processes that must share the same workspace configuration cache entries.- In every host (DbMigrator, HttpApi.Host, Blazor/Mvc host, AuthServer if applicable), set the same application name (whatever your solution uses for app config / application configuration caching scope).
- Recreate/seed workspaces after aligning names (or update existing workspace records’
ApplicationNameif you control the data), so reads and invalidations converge on the same key space.
If you maintain a fork / can patch the module: the safest fix is to ensure read and invalidation paths derive the cache key from the same source (either always the current app name, or always the workspace’s application name, or remove application name from the key if workspace name is globally unique in your design). Without official upstream code available here to reference precisely, I can’t provide a verified patch diff, but the mismatch you pointed out is the root cause.
About
AIManagementChatClient<T>lifetime pinning: even with correct distributed cache invalidation, any service that creates and holds an inner client instance for a long time will keep using the old config until it recreates the client. The practical mitigation is to register/consume such clients with a lifetime that matches your desired refresh behavior (e.g., avoid singleton/long-lived reuse if you expect admin edits to apply immediately), or implement a change-token/version check to rebuild the inner client when configuration changes.
(Reference used below is only for the key format; it doesn’t cover the bug itself.)
Sources:
- https://abp.io/docs/10.4//modules/ai-management/ (validated)
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
You're right, this is a bug.
WorkspaceConfigurationStorebuilds the cache key from the reading process'sApplicationName, whileWorkspaceChangedHandlerremoves the key built from the entity'sApplicationName. When those differ (a workspace seeded by the DbMigrator but read from another host), the edit removes the entity-scoped key rather than the one the reading host actually uses, so the configuration stays cached until it expires.This is a confirmed bug on our side and we'll get it fixed. I'll follow up here with the release once it's available.
For a stopgap in the meantime, you can key the cache by workspace name only. The store extension points this needs are
virtualonly from 10.4.1 onward, so you'd need to update to 10.4.1 or later first. Then replace the store:using System.Threading.Tasks; using Volo.Abp; using Volo.Abp.Caching; using Volo.Abp.DependencyInjection; using Volo.AIManagement.Factory; using Volo.AIManagement.Workspaces; using Volo.AIManagement.Workspaces.Configuration; [Dependency(ReplaceServices = true)] [ExposeServices(typeof(WorkspaceConfigurationStore), typeof(IWorkspaceConfigurationStore))] public class MyWorkspaceConfigurationStore : WorkspaceConfigurationStore { public MyWorkspaceConfigurationStore( IWorkspaceRepository workspaceRepository, IDistributedCache<ChatClientCreationConfiguration> singleItemCache, IApplicationInfoAccessor applicationInfoAccessor) : base(workspaceRepository, singleItemCache, applicationInfoAccessor) { } public override async Task<ChatClientCreationConfiguration?> GetOrNullAsync(string name) { #pragma warning disable CS8603 var cacheKey = $"WorkspaceConfiguration:{name}"; return await SingleItemCache.GetOrAddAsync(cacheKey, async () => await GetFromRepositoryAsync(name)); #pragma warning restore CS8603 } }And add a handler that invalidates the same name-only key (the built-in handler still runs and removes the old application-scoped key, which is harmless):
using System.Threading.Tasks; using Volo.Abp.Caching; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Entities.Events; using Volo.Abp.EventBus; using Volo.AIManagement.Factory; using Volo.AIManagement.Workspaces; public class MyWorkspaceCacheInvalidator : ILocalEventHandler<EntityChangedEventData<Workspace>>, ITransientDependency { private readonly IDistributedCache<ChatClientCreationConfiguration> _cache; public MyWorkspaceCacheInvalidator(IDistributedCache<ChatClientCreationConfiguration> cache) { _cache = cache; } public Task HandleEventAsync(EntityChangedEventData<Workspace> eventData) { return _cache.RemoveAsync($"WorkspaceConfiguration:{eventData.Entity.Name}", considerUow: true); } }Deployment:
- Put the store replacement in every server process that reads workspace configuration from the database (the ones resolving through the local
WorkspaceConfigurationStore), and the invalidator in every server process that updates workspaces through the app service/repository. - They must use the same distributed cache backend and the same
AbpDistributedCacheOptions.KeyPrefix. - A migrator that only seeds and never reads afterward doesn't need it.
- If you run a tiered setup where some hosts fetch configuration over HTTP, the client process itself doesn't need the replacement, but the AI Management host that serves that endpoint does — otherwise those clients still get stale data.
A couple of boundaries: it covers edits that keep the same workspace name — a rename also needs the old name's entry removed, so hold off on renames until the fix ships. And the cache key is tenant-scoped by default, so keep both the resolve and the edit in the host context for now.
On your related note:
AIManagementChatClient<T>resolves the inner client once and reuses it, andIChatClient<T>is registered transient, so a Blazor circuit or a long-lived worker that holds the instance keeps the configuration from its first call even after the cache is fixed. Injecting it in a short-lived request scope is fine. For long-lived consumers, create a DI scope per operation (per chat call or per job run) withIServiceScopeFactory.CreateAsyncScope(), resolveIChatClient<T>inside it, and dispose the scope afterward — don't keep the resolved instance in a circuit or singleton field.Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - Put the store replacement in every server process that reads workspace configuration from the database (the ones resolving through the local
-
0