TenantedCache

A SemanticCache bound to one tenant, through which nothing can reach another tenant's entries.

val cache = semanticCache(embedder) { requireTenant = true }

val forThisRequest = cache.forTenant(request.customerId)
forThisRequest.getOrPut(prompt) { llm.complete(it) }

The failure this closes

The cache key carried the prompt, the conversation and, since 2.1.0, the embedder. It did not carry who was asking, so a deployment serving more than one customer had one key space shared between all of them. Two tenants asking a byte-identical question got one entry, and on the exact-match fast path the second was served the first one's answer without similarity, without guards and without the verifier, because that path exists precisely to skip all three. Every safety layer in this library sat downstream of a key collision it could not see.

The identical-prompt case is the obvious one and not the dangerous one. The dangerous one is a prompt carrying retrieved context: two tenants ask the same question of different documents, the question is what gets embedded, and the answer that comes back belongs to somebody else's data. That is a data leak with a cache hit's latency and no log line.

Why a view rather than a parameter

A tenant parameter on every method would be a parameter somebody can omit, and an isolation property that depends on nobody making a mistake later is the thing this library refuses to accept anywhere else. A view cannot be used without naming the tenant, because obtaining one is how you name it.

With SemanticCacheBuilder.requireTenant on, the untenanted methods on SemanticCache throw, so a call that forgot the tenant is a failure at the call site rather than a hit belonging to somebody else. A missing tenant is a distinct state, never a default, because a default is what silently reintroduces the shared space.

What it does to the store

The tenant is folded into the scope the CacheStore sees, ahead of the caller's own scope name. That puts the isolation inside this library rather than in the wiring: every store partitions by scope already, including one somebody else wrote, so nothing has to be re-implemented per backend and nothing can be configured wrongly. scope keeps meaning what it meant, and a tenanted lookup simply never asks the store about a scope belonging to anybody else.

stats() is deliberately not on this view. The counters belong to the cache, not to a tenant, and a per-tenant hit rate is a metrics question a CacheListener answers with the scope it is already given.

Functions

Link copied to clipboard
suspend fun clear(scope: String = SemanticCache.DEFAULT_SCOPE)
Link copied to clipboard
suspend fun explain(prompt: String, scope: String = SemanticCache.DEFAULT_SCOPE): CacheExplanation
Link copied to clipboard
suspend fun get(prompt: String, scope: String = SemanticCache.DEFAULT_SCOPE, context: List<String> = emptyList()): String?
Link copied to clipboard
suspend fun getOrPut(prompt: String, scope: String = SemanticCache.DEFAULT_SCOPE, metadata: Map<String, String> = emptyMap(), compute: suspend (String) -> String): String
suspend fun <T> getOrPut(prompt: String, codec: ResponseCodec<T>, scope: String = SemanticCache.DEFAULT_SCOPE, metadata: Map<String, String> = emptyMap(), compute: suspend (String) -> T): T
suspend fun getOrPut(prompt: String, context: List<String>, tags: Set<String> = emptySet(), scope: String = SemanticCache.DEFAULT_SCOPE, metadata: Map<String, String> = emptyMap(), compute: suspend (String) -> String): String
Link copied to clipboard
suspend fun getOrPutAll(prompts: List<String>, scope: String = SemanticCache.DEFAULT_SCOPE, metadata: Map<String, String> = emptyMap(), compute: suspend (String) -> String): List<String>
Link copied to clipboard
suspend fun getOrPutStreaming(prompt: String, scope: String = SemanticCache.DEFAULT_SCOPE, metadata: Map<String, String> = emptyMap(), compute: suspend (String) -> Flow<String>): Flow<String>
suspend fun getOrPutStreaming(prompt: String, replay: StreamReplay, scope: String = SemanticCache.DEFAULT_SCOPE, metadata: Map<String, String> = emptyMap(), compute: suspend (String) -> Flow<String>): Flow<String>
Link copied to clipboard
suspend fun invalidate(id: String): Boolean
Link copied to clipboard
suspend fun invalidateByTag(tag: String, scope: String? = null): Int
Link copied to clipboard
suspend fun lookup(prompt: String, scope: String = SemanticCache.DEFAULT_SCOPE, context: List<String> = emptyList()): CacheLookup
Link copied to clipboard
suspend fun put(prompt: String, response: String, scope: String = SemanticCache.DEFAULT_SCOPE, metadata: Map<String, String> = emptyMap(), tags: Set<String> = emptySet()): String
Link copied to clipboard
suspend fun size(scope: String = SemanticCache.DEFAULT_SCOPE): Int
Link copied to clipboard
fun tenant(): String

Which tenant this view is bound to.

Link copied to clipboard
suspend fun warm(entries: List<WarmEntry>): List<String>