Apr 5, 20266 min read

User-Configurable Cache TTL: A Pattern Stolen from Magento for Multi-Tenant SaaS

How I gave each workspace control over its own cache lifetime, with auto-flush on TTL decrease, cross-entity invalidation, and E2E tests that prove it works.

User-configurable cache TTL

I spent years working with Magento before building AI platforms. Say what you will about its architecture, but Magento got one thing right: it gave merchants full control over their own cache.

System > Tools > Cache Management. Selective refresh per cache type. A configurable TTL for public content. Automatic invalidation on config changes. The merchant never had to ask a developer to clear a cache.

When I built the CMS layer of my platform, I stole that pattern wholesale. Every workspace controls its own cache TTL. When the TTL decreases, stale caches flush automatically. And there's a big red button for when you just want everything gone.

The Problem with Hardcoded TTLs

The initial implementation cached everything at fixed intervals: 120 seconds for lists, 300 seconds for individual entities. This is the default for most SaaS platforms. It's also wrong for most SaaS platforms.

Workspace TypeIdeal TTLWhy
News publisher60sContent changes constantly
Documentation site600sPages rarely update
Staging0sDebugging needs fresh data

Hardcoding the TTL means optimizing for nobody. The alternative — different cache durations per plan tier — is still a guess. The workspace admin knows their update frequency better than any pricing page.

The Settings Model

The workspace's CmsSettings table stores cache_time_minutes as an integer with a default of 30:

DEFINE FIELD OVERWRITE cache_time_minutes ON cms_settings TYPE int DEFAULT 30;

Every handler that caches a response reads the workspace's TTL:

pub async fn get_cache_ttl(state: &AppState, workspace_id: &str) -> u64 {
    let cache_key = cms_settings_cache_key(workspace_id);
    let minutes = if let Ok(Some(cached)) = state.redis.get::<CmsSettings>(&cache_key).await {
        cached.cache_time_minutes
    } else if let Ok(settings) = state.dal.cms_settings.get_or_create(workspace_id).await {
        let _ = state.redis.set(&cache_key, &settings, Some(60)).await;
        settings.cache_time_minutes
    } else {
        30
    };
    u64::from(minutes) * 60
}

The call site is one line: let ttl = get_cache_ttl(&state, &workspace.workspace_id).await;. Used across agents, categories, pages, components, actions, and tags.

The Recursion Problem

Notice the settings cache uses a fixed 60-second TTL (Some(60)). This is the Magento lesson. You can't look up the TTL from the config that tells you the TTL — that's infinite recursion.

The settings cache has its own short, hardcoded lifetime. When settings change, the settings cache is explicitly flushed as part of the workspace-wide flush.

Auto-Flush on TTL Decrease

When an admin decreases the cache TTL from 300 to 60, every cached response from the old 300-second window is now potentially stale. Waiting for natural expiry would serve stale data for up to 4 more minutes.

if updated.cache_time_minutes < existing.cache_time_minutes {
    let redis = state.redis.clone();
    let ws_id = workspace.workspace_id.clone();
    tokio::spawn(async move {
        flush_workspace_caches(&redis, &ws_id).await;
    });
}

Only on decrease. Increasing the TTL doesn't create staleness — existing entries just expire sooner than the new maximum, which is fine. Magento invalidates the config cache on any settings change, but it doesn't distinguish direction. I wanted something more precise: only flush when the window actually shrinks.

The flush deletes all seven workspace-scoped list caches in a single Redis pipeline:

pub async fn flush_workspace_caches(redis: &RedisClient, workspace_id: &str) {
    let keys = [
        format!("cache:agents:ws:{workspace_id}"),
        format!("cache:categories:ws:{workspace_id}"),
        format!("cache:tags:ws:{workspace_id}"),
        format!("cache:components:ws:{workspace_id}"),
        format!("cache:pages:ws:{workspace_id}"),
        format!("cache:actions:ws:{workspace_id}"),
        cms_settings_cache_key(workspace_id),
    ];
    if let Err(e) = redis.del_many(&keys).await {
        tracing::warn!("Cache flush failed: {e}");
    }
}

del_many pipelines the deletions into a single Redis round-trip. Individual entity caches (cache:agent:{id}) expire naturally — there could be thousands of them, and flushing them all would be expensive for marginal benefit.

Cross-Entity Invalidation

The harder problem is not TTL-based expiry. It's knowing which caches to bust when an entity changes.

MutationAlso InvalidatesWhy
Action update/deleteAgent list cacheAgents reference action_ids
Category deleteAgent list cacheAgents reference category_ids
Component update/deletePage + category list cachesPages embed component content
Agent deleteCategory list cacheCategories track agent_order
Media updateAgent + category list cachesEntities embed resolved media

Each handler explicitly lists which caches to bust:

// Bust agent list cache — agents reference action_ids
let agent_cache_key = format!("cache:agents:ws:{}", workspace.workspace_id);
let _ = state.redis.del(&agent_cache_key).await;

This is manual and explicit. Not an automated dependency graph. Not a pub/sub event system. Just a comment and a del call in the right place. Magento uses the same approach with cache tags — developers explicitly declare which tags to invalidate.

It's boring and it works. The risk is missing an invalidation path. I found two missing ones during this work: component updates weren't busting page caches, and component deletes weren't busting category caches. Both were surfaced by E2E tests. I cover production safety patterns in more detail in an earlier post.

The Flush Button

Magento's Admin > System > Cache Management has a "Flush Cache Storage" button. I added the same:

pub async fn flush_cache(
    State(state): State<AppState>,
    ctx: Ctx,
) -> AppResult<Json<ApiResponse<serde_json::Value>>> {
    // ...
    tokio::spawn(async move {
        flush_workspace_caches(&redis, &ws_id).await;
    });
    Ok(Json(ApiResponse::new(json!({ "status": "flush_initiated" }))))
}

Fire-and-forget via tokio::spawn. The response returns immediately. The frontend shows a toast.

This exists because sometimes the answer is "I don't know why the cache is stale, just clear it." Giving the admin that power prevents a support ticket.

E2E Tests That Prove It

Cache invalidation is notoriously hard to test because the bug is invisible — you get a 200 with correct-shaped but stale data. The E2E test pattern:

  1. Create an entity
  2. GET the list (primes the cache)
  3. Update the entity's name
  4. GET the list again
  5. Assert the new name is visible — not the old one
// Step 2: GET returns "Original Name" ✓
// Step 3: Update name to "Updated Name"
// Step 4: GET returns "Updated Name" ✓ (cache was invalidated)
// Step 5: Delete the entity
// Step 6: GET returns empty list ✓ (delete invalidated cache)

If the invalidation is missing, step 4 returns the stale "Original Name" and the test fails. This pattern caught the two missing cross-entity invalidation paths I mentioned earlier.

The Full Pattern

ConcernSolution
Configurable TTLcache_time_minutes in settings table, read by every handler
Settings cache recursionFixed 60s TTL for settings themselves
TTL decrease stalenessAuto-flush in tokio::spawn background task
Cross-entity invalidationExplicit del calls per mutation, manual dependency mapping
Emergency flushAdmin button → POST /flush-cache → pipeline DEL of 7 keys
Multi-key deletionRedis pipeline, single round-trip
TestingE2E: create → cache → mutate → assert fresh data

Magento taught me that cache management is a user feature, not an infrastructure detail. If your tenants can't control their own cache, you'll end up controlling it for them — one support ticket at a time.

Enjoyed this article?

Share it with others or connect with me