Portal Community

What Is Cached

DataCache key patternTTL
Full app definitionappdef:{tenantId}:{appId}15 minutes
App page listapppages:{tenantId}:{appId}15 minutes
Individual pageapppage:{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.