Skip to content
Melange v0.8.5

Melange v0.8.5

July 12, 2026·pthm
pthm

Melange v0.8.5 is a performance and correctness release for list_accessible_objects and list_accessible_subjects. It came out of #67, where a ~90k-tuple workload reported a 30-second list_accessible_objects and runtimes that grew with the number of relations in the schema. The list generators were rebuilt around set-oriented SQL and a schema-size-independent dispatcher. On that workload list_accessible_objects drops from ~4s to ~12ms, and dispatch no longer scales with schema size. Six correctness fixes ride along, closing under- and over-reporting gaps that predated the rewrite.

No breaking changes from v0.8.4, and no API changes. Every improvement is in generated SQL — run melange migrate to regenerate the check, list, and dispatcher functions. Your Check and List call sites are unchanged.

What was slow

Two things scaled the wrong way.

Dispatch scaled with the schema. The per-relation CASE sub-expression the dispatcher used fell out of PostgreSQL’s simple-expression fast path, so every check paid a planning cost proportional to the number of relations in the model. Adding unrelated relations slowed down every permission check.

List functions fanned out per candidate. list_accessible_objects gathered candidate objects and then called check_permission_internal once per candidate to confirm the grant. On a large object table that is thousands of function invocations per list call, each re-resolving the permission graph from scratch.

Performance

Schema-independent dispatch

The dispatcher is now a nested, object-type-first IF-chain of guarded RETURNs instead of one giant CASE. It stays inside PostgreSQL’s fast path and routes in constant time regardless of how many relations the schema defines. Adding relations no longer taxes unrelated checks.

Set-oriented list composition

The per-candidate check_permission_internal fan-out is gone. Where a list function used to confirm each candidate with its own check call, it now composes directly against the target relation’s own list function with a semi-join:

-- before: one check_permission_internal(...) per candidate row
-- after:  a single set-oriented semi-join
WHERE t.subject_id IN (
    SELECT obj.object_id
    FROM   list_org_admin_obj(p_subject_type, p_subject_id, NULL, NULL) obj
)

Composition only kicks in when it is provably safe: a compile-time walk over the relation graph refuses to compose across a cycle or a path that could exceed the depth limit, falling back to the per-candidate check for those shapes. Plain (concrete, non-userset, non-wildcard) subjects are handled entirely set-at-a-time; a narrow, guarded per-candidate arm is kept only for userset-typed query subjects, which the list functions may under-report.

Exclusions as anti-joins

Complex but not exclusions in list_objects now render as a single set-oriented anti-join instead of a per-candidate filter, so subtracted grants are removed in one pass.

Smaller inlined tables

The closure and userset VALUES tables inlined into each generated function are now filtered per relation to just the types each function references, instead of carrying every relation in the schema. Combined with folding userset self-checks to a compile-time IN-list, the generated SQL stops growing with unrelated parts of the model — the other half of the schema-size story.

Fewer search_path lookups

Dispatchers and public wrappers reference only schema-qualified names, so they no longer set a per-call search_path GUC; leaf functions that read the unqualified melange_tuples view keep theirs. This trims overhead on the hot dispatch path while preserving contextual-tuple support.

Measured impact

On the ~90k-tuple workload from #67 (PostgreSQL 16):

FunctionBeforeAfter
list_accessible_objects3982 ms~12 ms
list_accessible_objects (+50 unrelated relations)7769 ms~12 ms
list_accessible_subjects170 ms~2 ms

The middle row is the schema-size effect: before, padding the model with 50 unrelated relations nearly doubled the time; after, it is flat. list_accessible_subjects also became more correct in the process — the old implementation under-reported some valid subjects that the rewrite now returns.

Correctness fixes

These are pre-existing bugs in the list functions, several surfaced while validating the performance work:

  • Self-referential recursive TTU lists are now complete and sound (#12). A relation that combines a cross-type anchor with a self-referential parent walk — local_admin: admin from org or local_admin from parent — was routed through a strategy that could not represent the parent recursion, so list_objects/list_subjects dropped objects reached only through the parent chain even though check_permission granted them. Routing these to the recursive strategy fixed the under-report; a follow-up tightened the subject-side parent-closure walk so it no longer traverses a cross-type target’s own same-named linking relation and over-reports.
  • Userset query subjects list through the complex-closure arm. Listing objects for a userset-typed subject (for example group:eng#member) now resolves through the complex-closure path instead of being skipped.
  • Ambiguous subject_id qualified in the wildcard + exclusion list_subjects path, fixing a query that could error or return the wrong column.
  • TTU-reachable wildcards surface in list_subjects. A relation whose access flows through a TTU parent to a [user:*] grant now returns the wildcard, matching check_permission.

Migration notes

No breaking changes from v0.8.4. Run migrations to regenerate the functions:

melange migrate

Two migration-tooling fixes are worth calling out if you install Melange via go install or keep a view over the migrations table:

  • Version columns are now TEXT. Module pseudo-versions from go install (38+ characters) overflowed the old VARCHAR(32) codegen_version column and failed every migrate. Fresh installs use TEXT; existing tables are widened in place.
  • The widen is guarded against dependent views. PostgreSQL rejects ALTER COLUMN TYPE when a view depends on the column, so the widen is now conditional and re-migration no longer breaks if you have a view over melange_migrations.

If you use melange generate migration, regenerate to pick up the new SQL:

melange generate migration \
  --schema melange/schema.fga \
  --output db/migrations \
  --git-ref main

Try it out

# Install / upgrade CLI
brew install pthm/melange/melange

# Or pull the container image
docker pull ghcr.io/pthm/melange:v0.8.5

# Or install the .deb / .rpm package from the GitHub release

# Regenerate and apply the faster functions
melange migrate

# Go runtime
go get github.com/pthm/melange/melange@v0.8.5

# TypeScript runtime
npm install @pthm/melange

See the SQL API reference for the generated function surface and How it works for the compile-time model behind the composition.

Feedback

Open an issue for bug reports or feature requests.