Caching
Melange’s built-in cache reduces database load by storing permission check results in memory. Cached checks return in ~83ns compared to ~422μs for uncached checks.
Built-in Cache
cache := melange.NewCache(melange.WithTTL(5 * time.Minute))
checker := melange.NewChecker(db, melange.WithCache(cache))
// First check hits the database
allowed, _ := checker.Check(ctx, user, "can_read", repo)
// Subsequent identical checks hit the cache
allowed, _ = checker.Check(ctx, user, "can_read", repo)Characteristics:
- In-memory, process-local. Not shared across processes.
- Thread-safe. Safe for concurrent goroutines.
- Caches both allowed and denied results. Denied checks are cached too.
- Unbounded within TTL. Entries expire individually based on insertion time but the map grows without limit while entries are live.
Options
| Option | Description |
|---|---|
WithTTL(d time.Duration) | Time-to-live for entries. Default: no expiry. |
cache := melange.NewCache() // No expiry
cache := melange.NewCache(melange.WithTTL(time.Minute)) // 1 minute TTLManual Operations
cache.Size() // Number of entries
cache.Clear() // Remove all entriesWhat Is Cached
Check, CheckWithContextualTuples, Explain, and Expand results all participate. List operations (ListObjects, ListSubjects) always query the database.
Checks with contextual tuples bypass the cache entirely. Each contextual tuple check goes directly to the database because the temporary tuples are unique to that call.
Explain and Expand Caching
Checker.Explain and Checker.Expand consult and populate the same cache passed to WithCache, opt-in via interface satisfaction. The default CacheImpl satisfies all three:
cache := melange.NewCache(melange.WithTTL(5 * time.Minute))
checker := melange.NewChecker(db, melange.WithCache(cache))
// First call hits the database, populates the cache.
trace1, _ := checker.Explain(ctx, user, "viewer", doc)
// Second call hits the cache — even after the underlying tuple changes,
// until the TTL expires or Clear is called.
trace2, _ := checker.Explain(ctx, user, "viewer", doc)The Checker type-asserts the injected cache to ExplainCache / ExpandCache; implementations that only satisfy Cache fall through to the DB on every Explain / Expand call. Existing Check-only cache implementations keep working without changes.
Key Composition
The cache key includes every option that affects output — different option values produce different traces, so they must not alias.
| API | Cache key components |
|---|---|
Check | subject, relation, object |
Explain | subject, relation, object, MaxNodes |
Expand | object, relation, SubjectType filter, MaxLeaf |
Two Explain calls at different WithExplainMaxNodes values populate distinct entries because a truncated trace at one cap must not be served to a caller that passed a larger cap. Same for Expand’s WithSubjectTypeFilter and WithExpandMaxLeaf.
Clearing
Clear() resets all three families in one call. Size() returns the combined count. Tuple writes that would invalidate a cached Check would also invalidate cached Explain and Expand results, so one blanket Clear is the intended coarse-grained reset:
cache.Size() // 12 (4 Check + 5 Explain + 3 Expand)
cache.Clear() // nukes everything
cache.Size() // 0Request-Scoped Caching
Create a fresh cache per request to avoid stale results across requests:
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cache := melange.NewCache()
checker := melange.NewChecker(db, melange.WithCache(cache))
ctx := context.WithValue(r.Context(), "checker", checker)
next.ServeHTTP(w, r.WithContext(ctx))
})
}Within a single request, repeated checks for the same permission hit the cache. The cache is discarded when the request ends.
Long-Lived Cache
For applications where permissions change infrequently, use a shared cache with a TTL:
// Create once at startup
cache := melange.NewCache(melange.WithTTL(5 * time.Minute))
// Share across all requests
func newChecker() *melange.Checker {
return melange.NewChecker(db, melange.WithCache(cache))
}TTL guidance:
- Short (seconds): frequently changing permissions, high security requirements.
- Medium (minutes): typical web applications.
- Long or none: near-static permissions, performance-critical paths.
The cache grows unbounded within the TTL window. For long-running processes with many unique permission tuples, use a short TTL or clear periodically.
Custom Cache Implementations
Three interfaces cover the three cache families. Implement only the ones you need — Checker.Explain and Checker.Expand fall through to the DB when their interface isn’t satisfied.
type Cache interface {
Get(subject Object, relation Relation, object Object) (allowed bool, err error, ok bool)
Set(subject Object, relation Relation, object Object, allowed bool, err error)
}
type ExplainCache interface {
GetExplain(subject Object, relation Relation, object Object, maxNodes int) (*Trace, error, bool)
SetExplain(subject Object, relation Relation, object Object, maxNodes int, trace *Trace, err error)
}
type ExpandCache interface {
GetExpand(object Object, relation Relation, subjectType ObjectType, maxLeaf int) (*UsersetTree, error, bool)
SetExpand(object Object, relation Relation, subjectType ObjectType, maxLeaf int, tree *UsersetTree, err error)
}The default CacheImpl satisfies all three.
Example Redis implementation:
type RedisCache struct {
client *redis.Client
ttl time.Duration
}
func (c *RedisCache) Get(subject melange.Object, relation melange.Relation, object melange.Object) (bool, error, bool) {
key := fmt.Sprintf("perm:%s:%s:%s:%s:%s",
subject.Type, subject.ID, relation, object.Type, object.ID)
val, err := c.client.Get(ctx, key).Result()
if err == redis.Nil {
return false, nil, false // Not in cache
}
if err != nil {
return false, nil, false // Treat Redis errors as cache miss
}
return val == "1", nil, true
}
func (c *RedisCache) Set(subject melange.Object, relation melange.Relation, object melange.Object, allowed bool, err error) {
if err != nil {
return // Don't cache errors
}
key := fmt.Sprintf("perm:%s:%s:%s:%s:%s",
subject.Type, subject.ID, relation, object.Type, object.ID)
val := "0"
if allowed {
val = "1"
}
c.client.Set(ctx, key, val, c.ttl)
}Cache and Transactions
A checker created with *sql.Tx sees uncommitted changes. The cache does not differentiate between committed and uncommitted results. If you mix transactional and non-transactional checkers with the same cache, stale results are possible.
For transactional workflows, use a dedicated cache (or no cache) for the transaction-scoped checker:
// Non-transactional checker with shared cache
checker := melange.NewChecker(db, melange.WithCache(sharedCache))
// Transactional checker with its own cache
txChecker := melange.NewChecker(tx, melange.WithCache(melange.NewCache()))Cache Invalidation
The built-in cache supports only TTL-based expiry. For event-driven invalidation:
- Clear on write: call
cache.Clear()after modifying domain tables that feedmelange_tuples. - Key-targeted invalidation: implement
Cachewith a backend that supports key deletion. - Hybrid: use a short TTL combined with event-driven clearing for critical paths.
When Not to Cache
- Transactional checks that must see uncommitted changes. Use
*sql.Txwithout a cache. - Contextual tuple checks. These bypass the cache automatically.
- Very low check volume. At ~422μs per uncached check, caching adds complexity without meaningful benefit if you’re doing fewer than ~100 checks/second.
Next Steps
- Checking Permissions: full Checker API usage
- Go API: cache interface and options reference
- Performance: benchmark data