Skip to content

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

OptionDescription
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 TTL

Manual Operations

cache.Size()   // Number of entries
cache.Clear()  // Remove all entries

What 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.

APICache key components
Checksubject, relation, object
Explainsubject, relation, object, MaxNodes
Expandobject, 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()   // 0

Request-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 feed melange_tuples.
  • Key-targeted invalidation: implement Cache with 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.Tx without 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