App Studio
Service Layer Caching
App definitions are read-heavy — every page load in every user session triggers a read of the app definition. The AIExtension.Service layer caches app definitions in Redis, making reads near-instant. Cache invalidation is automatic on every write operation.
What Is Cached
| Data | Cache key pattern | TTL |
|---|---|---|
| Full app definition | appdef:{tenantId}:{appId} | 15 minutes |
| App page list | apppages:{tenantId}:{appId} | 15 minutes |
| Individual page | apppage:{tenantId}:{appId}:{pageId} | 15 minutes |
| Widget placements (by page) | widgets:{tenantId}:{appId}:{pageId} | 15 minutes |
| App list (tenant) | applist:{tenantId} | 5 minutes |
Cache Read Pattern
// Read-through cache pattern in AppStudioDataService:
public async Task GetAppAsync(string tenantId, string appId)
{
var cacheKey = $"appdef:{tenantId}:{appId}";
// 1. Try Redis — O(1), sub-millisecond
var cached = await _redis.GetStringAsync(cacheKey);
if (cached != null)
return JsonSerializer.Deserialize(cached);
// 2. Miss — load from database
var entity = await _db.AppDefinitions
.Where(a => a.TenantId == tenantId && a.AppId == appId)
.FirstOrDefaultAsync();
if (entity == null) return null;
var definition = JsonSerializer.Deserialize(entity.DefinitionJson);
// 3. Populate cache for next read
await _redis.SetStringAsync(
cacheKey,
entity.DefinitionJson,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15)
});
return definition;
}
Cache Invalidation on Write
Every write operation invalidates the affected cache keys. Invalidation uses delete (not expire) — subsequent reads immediately go to the database and repopulate the cache:
public async Task SaveAppAsync(string tenantId, AppDefinition app, string userId)
{
// 1. Persist to database
await UpsertAppDefinitionAsync(tenantId, app);
// 2. Invalidate all related cache keys
await Task.WhenAll(
_redis.RemoveAsync($"appdef:{tenantId}:{app.AppId}"),
_redis.RemoveAsync($"apppages:{tenantId}:{app.AppId}"),
_redis.RemoveAsync($"applist:{tenantId}")
);
// 3. Write audit log
await WriteAuditLogAsync(tenantId, app.AppId, "SaveApp", userId);
return app;
}
// SaveLayoutAsync also invalidates widget cache:
public async Task SaveLayoutAsync(string tenantId, string appId, string pageId,
IList placements, string userId)
{
await ReplacePageWidgetsAsync(tenantId, appId, pageId, placements);
await Task.WhenAll(
_redis.RemoveAsync($"widgets:{tenantId}:{appId}:{pageId}"),
_redis.RemoveAsync($"appdef:{tenantId}:{appId}") // Full def cache also cleared
);
await WriteAuditLogAsync(tenantId, appId, $"SaveLayout:{pageId}", userId);
}
Cache Stampede Prevention
On cache miss, multiple concurrent requests could all hit the database simultaneously (stampede). The service uses a Redis lock pattern to prevent this:
// Simplified — actual implementation uses a distributed lock library
var lockKey = $"lock:{cacheKey}";
using var @lock = await _redisLock.AcquireAsync(lockKey, TimeSpan.FromSeconds(5));
if (@lock.IsAcquired)
{
// Double-check after acquiring lock
var cached = await _redis.GetStringAsync(cacheKey);
if (cached != null) return Deserialize(cached);
// Load from DB — only one caller wins the lock
var data = await LoadFromDbAsync(tenantId, appId);
await _redis.SetStringAsync(cacheKey, Serialize(data), options);
return data;
}
else
{
// Lock contention — fall back to DB read directly
return await LoadFromDbAsync(tenantId, appId);
}
Cache is transparent to callers
App Studio controllers call
GetAppAsync and receive the definition — they have no knowledge of whether the result came from Redis or the database. This is intentional: caching is an implementation detail of the service layer, not a concern for the API layer.