Loading…
Loading…
An in-process, short-TTL cache for multi-tenant systems. Caches values on a per-tenant basis, preventing data leakage between tenants.
The TenantKeyedCache is a short-time-to-live (TTL), in-process cache designed for multi-tenant environments. Its primary purpose is to prevent data leakage between tenants, which can occur when using a simple, JVM-wide cache.
In a multi-tenant system, different API consumers operate within their own isolated data scopes, or "tenants". When caching data fetched from a tenant-scoped table or setting, it is critical that one tenant's data is never served to another. The TenantKeyedCache solves this by keying every cache entry to the specific tenant ID bound to the thread that requests the data.
This component is intended for use by internal services that need to cache tenant-specific data, such as configuration settings or frequently accessed resources. It relies on the system's TenantContext to identify the current tenant.
Key characteristics:
The TenantKeyedCache associates cached data with a tenant ID provided by TenantContext.current(). This is the same context used by TenantBindingDataSource to set the app.tenant_id on the database connection, ensuring data is fetched from and cached for the correct tenant.
The get(Supplier<V> loader) method is the primary entry point for retrieving a value. The logic follows a clear sequence to ensure tenant isolation and data freshness.
flowchart TD
subgraph "TenantKeyedCache.get() Flow"
A[Start] --> B{Tenant bound via TenantContext?};
B -- No --> C[Bypass cache];
C --> D[Invoke loader function];
D --> E[Return fresh value];
B -- Yes --> F[Get tenant ID];
F --> G{Valid entry exists in cache?};
G -- Yes (Cache Hit) --> H[Return cached value];
G -- No (Cache Miss / Expired) --> I[Record current generation number];
I --> J[Invoke loader function to fetch fresh value];
J --> K{Generation changed during load?};
K -- Yes (Invalidated) --> L[Discard fresh value];
L --> M[Return fresh value (not cached)];
K -- No --> N[Store fresh value in cache];
N --> M;
end
H --> Z[End];
E --> Z;
M --> Z;loader function is executed immediately, and its result is returned directly. Nothing is stored in the cache. In a properly configured system, an attempt to read tenant data without a bound tenant may fail at the database layer (e.g., with error S2R-TENANCY-UNBOUND).loader function to fetch a fresh value from the source of truth (e.g., the database).To prevent race conditions where a value is invalidated while a slow load operation is in progress, the cache uses a generation-tracking mechanism.
epoch counter is incremented every time invalidateAll() is called.generations counter is incremented when invalidateCurrentTenant() is called for a specific tenant.epoch and its specific generations value.When a cache miss occurs, the get method records the tenant's current generation number before calling the loader. After the loader returns, the value is only stored in the cache if the tenant's generation number has not changed. If an invalidation occurred during the load, the generation number will have increased, and the newly loaded (but now stale) value is discarded.
WARNING
Transactional Context Mismatches
The cache key is determined by the tenant bound to the calling thread via TenantContext.current(). In complex transactions that use TenantContext.runAs to switch tenant contexts, the thread's tenant may differ from the connection's tenant (which is fixed at the start of the transaction).
Reading from this cache inside such a transaction can lead to corrupting the cache. For example, data loaded for Tenant A (from the connection) could be incorrectly stored in the cache under the key for Tenant B (from the thread). Do not read through this cache inside a transaction that switches tenant context.
Cache invalidation is also tenant-aware.
invalidateCurrentTenant(): Drops the cache entry for the tenant currently bound to the thread. If the special __platform__ tenant is bound (whose settings can overlay all tenants), or if no tenant is bound, this operation escalates to a full cache invalidation.invalidateAll(): Drops every entry for every tenant in the cache. This is a global invalidation.When the number of cached entries exceeds the SWEEP_THRESHOLD (currently 1024), the next write operation will trigger a sweep to remove any expired entries, freeing up memory.
To use the cache, first instantiate it with a desired TTL. Then, use the get method, providing a Supplier function that knows how to load the data on a cache miss.
// 1. Instantiate the cache with a 5-minute TTL.
TenantKeyedCache<String> configCache = new TenantKeyedCache<>(300000L);
// ...
// 2. Use the cache to retrieve a setting.
// The lambda expression is the loader, which is only executed on a cache miss.
String tenantSetting = configCache.get(() -> {
// Logic to fetch a setting from the database for the current tenant.
return database.fetchSettingForCurrentTenant("some.setting.key");
});
// ...
// 3. Invalidate the cache when the underlying data changes.
// For example, after an API call updates the setting.
configCache.invalidateCurrentTenant();
The TenantKeyedCache is instantiated with a time-to-live for its entries.
| Parameter | Type | Required | Description |
|---|---|---|---|
ttlMillis | long | Yes | The time-to-live for cache entries in milliseconds. Must be a positive number. |
| Method | Return Type | Description |
|---|---|---|
get(Supplier<V> loader) | V | Returns the cached value for the current tenant. If the value is not in the cache or is expired, it executes the loader function, caches the result, and returns it. If no tenant is bound, it always executes the loader and does not cache the result. |
invalidateCurrentTenant() | void | Drops the cache entry for the tenant bound to the current thread. If no tenant is bound or the platform tenant is bound, it invalidates all entries in the cache. |
invalidateAll() | void | Drops every entry for every tenant from the cache. |
| Constant | Value | Description |
|---|---|---|
SWEEP_THRESHOLD | 1024 | The number of entries past which expired entries are swept on the next store operation. |
IllegalArgumentException: The constructor will throw this exception if ttlMillis is less than or equal to zero. The TTL must be a positive duration.TenantContext is correctly populated for the request thread. In some configurations, an unbound read may result in a downstream error from the database (e.g., S2R-TENANCY-UNBOUND).TenantContext.TenantBindingDataSource) that uses the tenant context to bind database connections to a specific tenant.