Skip to content

Performance

Melange compiles permission checks into specialized SQL functions. This page documents measured latency and scaling behavior.

Summary

  • check, expand, explain, and bulk check run in constant time regardless of dataset size.
  • list_accessible_objects cost scales with the number of matching objects and path depth, not total tuple count.
  • list_accessible_subjects cost scales with the number of subjects that have access. For broad relations over a large user base this reaches seconds.

Methodology

Numbers come from the kitchen-sink benchmark (just bench-kitchensink), a single model that exercises every generator code path (all seven relation features and all seven list strategies) across four dataset sizes.

  • Hardware: Apple M2 Pro, PostgreSQL 16, local connection.
  • Measured: latency of a single generated function call (SELECT check_permission(...), SELECT * FROM list_accessible_objects(...)) over one pooled connection. This is the database cost only.
  • Averaging: mean of 6 runs (4 runs at the 1M scale). Run-to-run variance is ±1-8%.

These are database-side latencies. The Go checker adds input validation, parameter binding, and connection-pool acquisition: add roughly 150-300 µs to the check numbers, plus the network round-trip to Postgres. The in-memory cache removes the database round-trip for repeated checks (~80 ns).

Model

The kitchen-sink schema exercises the following relation shapes:

TypeRelations
user, service_accountTwo concrete subject principals.
groupSelf-referential userset (member: [group#member]), wildcard, and exclusion (active_member).
organizationRole hierarchy (owner, admin, member) and userset.
team, projectCross-type TTU (member from org), multi-type wildcards, computed unions.
folderSelf-referential recursive TTU (viewer from parent), cross-type anchor, intersection.
documentDirect, userset, TTU, implied, and wildcard grants, plus every intersection and exclusion shape.
report, commentPure-TTU and pure-userset (Composed strategy), and closure-inherited exclusion.

Scales

Each scale grows users, groups, orgs, folder depth, and documents together. Scale names are round-number labels; actual tuple counts are shown.

ScaleUsersOrgsGroupsGroup chainFolder depthTuples
1K200540443,824
10K1,000151205525,386
100K5,0004040066155,692
1M25,0001002,00067840,877

Check, expand, explain, bulk

These operations resolve a single question in constant time. Latency is flat across scales. Check is measured for four query-subject types: a plain user with deep inherited access, a userset-typed subject (group:g#member), a service account, and a wildcard grant.

check_permission (µs):

Query subject1K10K100K1MScaling
Plain user (deep inherited)128149150127O(1)
Userset subject (group#member)131155159146O(1)
Service account115135137116O(1)
Wildcard grant126148148128O(1)

Other single-answer surfaces (µs):

Operation1K10K100K1MScaling
expand_permission (userset tree)135138136127O(1)
explain_permission (trace)286296287290O(1)
check_permission_bulk292307291294O(1)

Each check allocates ~1 KiB and 21 allocations at every scale. There is no runtime graph traversal whose cost grows with the data.

ListObjects

list_accessible_objects returns every object a subject can reach. Cost depends on the number of matching objects and path depth, not on total tuple count. A query with a small result set stays fast at every scale. A query that walks a large recursive graph grows with it.

list_accessible_objects, averaged, sorted by cost:

Query1K10K100K1MScaling
Service account to groups (few results)166 µs178 µs170 µs348 µs~O(1)
Userset subject to orgs (group#member)878 µs4.0 ms21 ms121 msO(paths)
Folder viewer (recursive TTU)930 µs2.5 ms4.5 ms15 msO(paths)
Document editor (userset and TTU)1.0 ms1.7 ms5.5 ms25 msO(paths)
Document can_edit (intersection)1.1 ms2.0 ms5.6 ms25 msO(paths)
Document gated (intersection)3.3 ms7.6 ms22 ms104 msO(paths)
Document can_view (large union, wildcard)6.2 ms15 ms45 ms212 msO(paths)

The service-account query returns few objects and stays ~170 µs from 4K to 840K tuples. can_view unions direct grants, userset membership, recursive folder inheritance, and a public wildcard, so it grows with the number of accessible paths. Intersections cost more than unions because each candidate object is validated against multiple sub-relations.

ListSubjects

list_accessible_subjects returns every subject with access to an object. For a broadly-granted relation this enumerates a large fraction of the user base. Cost scales with the number of subjects, not with the requested page size.

list_accessible_subjects, all users who can view a document:

Query1K10K100K1MScaling
viewer (recursive, all paths)41 ms206 ms1.4 s16.8 sO(subjects)
can_view (viewer but not blocked)60 ms268 ms1.6 s17.8 sO(subjects)

At 1M tuples and 25,000 users, listing every subject of a deeply-inherited relation takes seconds. Resolving “who can see this” over a recursive, wildcard-bearing relation must expand the whole reachable subject graph. Use broad ListSubjects for batch and offline work, not interactive request paths. For “does this user have access”, use check.

Pagination does not reduce this cost. The database walks the full permission graph to produce an ordered page regardless of LIMIT. Page size affects payload size, not query time.

Validation

Invalid requests are rejected in the Go checker before any database query:

ValidationLatency
Invalid relation~25 ns
Invalid type~30 ns
Invalid user format~30 ns
Invalid contextual tuple~25 ns

Parallel checks

Checks are independent. On a 12-core machine, throughput scales about 3-4x over sequential checks.

Caching

The optional in-memory cache removes the database round-trip for repeated checks:

ScenarioLatencyRatio
Cold (database query)~130 µs1x
Warm (memory lookup)~80 ns~1,600x
cache := melange.NewCache(
    melange.WithTTL(time.Minute),
)
checker := melange.NewChecker(db, melange.WithCache(cache))

Use caching when:

  • Read-to-write ratio is high (permissions checked often, changed rarely).
  • A staleness window is acceptable (typically 30s-5min).
  • Memory is available for cache storage.

Skip caching when:

  • Permissions change frequently.
  • Real-time consistency is required.
  • Memory is constrained.

Optimization

Prefer check over list on interactive paths

Check is constant time (~130 µs database, ~350-450 µs end-to-end). If a request can be phrased as “does this subject have access”, use check. Reserve ListSubjects and large ListObjects for batch, export, and admin surfaces.

Schema shape

Schema shape is the main driver of list cost:

TierPatternsList behavior
CheapDirect, computed userset, union, wildcardsScales only with result count.
ModerateTTU, exclusion, userset referencesAdds a traversal per path.
ExpensiveIntersection, recursive TTU, deep union chainsMultiplies work per candidate.

Prefer union over intersection where the semantics allow. An intersection validates every candidate against multiple sub-relations:

# Intersection: gated ranges 3-100 ms for ListObjects across scales
define viewer: writer and editor

# Union is cheaper
define viewer: writer or editor

Prefer shallow hierarchies. Deep, self-referential ... from parent chains drive the recursive CTE and dominate list latency at scale.

Avoid runtime contextual tuples on hot paths

Contextual tuples add temporary-table setup per call. Use stored tuples where possible, and batch checks that share a contextual set.

Index tuples view source tables

Index the tables behind the melange_tuples view, including expression indexes for any ::text id casts. Without them, PostgreSQL falls back to sequential scans:

-- Composite index covering the columns the view selects
CREATE INDEX idx_org_members_lookup
    ON organization_members (organization_id, role, user_id);

-- Expression index for text id conversion
CREATE INDEX idx_org_members_text
    ON organization_members ((organization_id::text), (user_id::text));

See Tuples View for indexing guidance.

Paginate large ListObjects results

For list_accessible_objects with many results, use cursor-based pagination to bound payload size:

objects, cursor, err := checker.ListObjects(ctx, user, "viewer", "document",
    melange.PageOptions{Limit: 50})
nextPage, nextCursor, err := checker.ListObjects(ctx, user, "viewer", "document",
    melange.PageOptions{Limit: 50, After: cursor})

Page size affects response size, not query time. Use small pages (10-100) for interactive APIs.

Monitor query performance

Use EXPLAIN ANALYZE, or the generated explain_* functions, to find sequential scans, stale statistics, or deep nested loops:

EXPLAIN ANALYZE SELECT check_permission('user', '123', 'viewer', 'document', '456');

Comparison

ApproachCheck latencyConsistencyScaling
Melange (in-database)~130 µsTransactionalO(1) for checks
OpenFGA (external service)1-5 msEventualO(1) for checks
Application-level RBAC10-100 µsVariesO(roles)
Direct SQL queries50-300 µsTransactionalO(query complexity)

Melange runs checks inside the database transaction with latency comparable to application-level RBAC, while supporting Zanzibar-style relation modeling.

Further reading