perf(orm): memoize implicit m2m join-table and model lookups (#2715)#2730
perf(orm): memoize implicit m2m join-table and model lookups (#2715)#2730abetss wants to merge 1 commit into
Conversation
resolveManyToManyJoinTable scanned the entire schema (O(models*fields)) on every call, once per table per (sub)query while building policy filters; getModel was the next hottest frame. These lookups are pure functions of the immutable schema, so memoize them in QueryUtils keyed by the schema (WeakMap), add an O(1) getManyToManyJoinTable, and make resolveManyToManyJoinTable a thin lookup into it. Descriptor and behavior unchanged. Fixes zenstackhq#2715
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughAdds ChangesM2M Join Table Memoization
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed: private package registry requires authentication. Disable ESLint in CodeRabbit settings or use public packages. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Fixes #2715.
Summary
PolicyHandler.resolveManyToManyJoinTable(tableName)performed anO(models × fields)scan of the entire schema on every call — and it's called once per table per (sub)query while building policy filters (plus on the mutation/validation path). For a regular non-m2m table the scan runs to completion and returnsundefined, so every query pays a full schema scan per referenced table. On a CPU profile of a deep, policy-filtered read this (getModelPolicyFilterForManyToManyJoinTable) was ~29% self-time, withgetModel(16%) close behind; the database itself was 0.3%.The resolution is a pure function of
(schema, tableName), and the schema is immutable for a client's lifetime. This PR memoizes it.What changed
Rather than caching inside the policy plugin, the structural lookups are memoized once in
@zenstackhq/orm'sQueryUtils, keyed by the immutable schema, so every consumer (ORM query building + all plugins) shares them:query-utils.ts: aWeakMap<SchemaDef, …>index memoizinggetModelandgetManyToManyRelation, plus a new O(1)getManyToManyJoinTable(schema, tableName)that builds aMap<joinTable, endpoints>in a single pass on first use.policy-handler.ts:resolveManyToManyJoinTableis now a thin O(1) lookup into that index (the per-call scan is gone). The descriptor it returns is unchanged.Typed throughout (no
any/as).Why it's access-control-safe
resolveManyToManyJoinTablereads onlyschema.models+ the table name and returns a structural descriptor ({firstModel, firstField, firstIdField, …}). It takes no$auth/per-request state and mutates nothing. The per-user policy filter is still built fresh, per call, downstream ingetModelPolicyFilterForManyToManyJoinTablefrom that descriptor +auth(). So the cache cannot change any access-control outcome — it only skips recomputing a deterministic lookup.What this deliberately does not cache (to stay forward-compatible):
auth()/value-dependent.The
WeakMapis keyed by the schema object, so the cache dies with the schema (no leak, no cross-schema collision), and the negative (undefined) result is cached too — which matters because non-m2m tables are the dominant call pattern.Benchmarks
Deep, policy-filtered read on a ~114-model schema (Bun 1.3.2 / JavaScriptCore; medians). Bun is the worst case for this JS-bound path; Node/V8 shows the same ranking at ~¼ the magnitude.
The PK-lookup wins (which touch no m2m code at all) come from memoizing
getModel— that's why the index lives inQueryUtilsrather than the policy plugin.A sanitized CPU profile (
.cpuprofile) backing the original 29%/16% attribution is available — happy to attach or gist it.Tests
tests/regression/test/issue-2715.test.tsguards the memoization (resolution is correct for two-model, self-relation, explicit@relationname, and multiple relations; repeat resolution returns the same cached descriptor; negative results are cached). Verified it fails if the memoization is removed.relation/many-to-many,policy/migrated/relation-many-to-many-filter,connect-disconnect,self-relation) pass unchanged — the behavior-preservation gate.Questions for maintainers
WeakMap<SchemaDef, …>insideQueryUtils. If you'd prefer no module-level state, it could hang off the long-lived client/schema object instead — happy to move it.getModel/getManyToManyRelationmemoization alongside the join-table fix because the profile and the PK-lookup numbers showgetModelis the larger lever. I can split it out if you'd rather keep this PR toresolveManyToManyJoinTableonly.Summary by CodeRabbit
Performance Improvements
New Features
Tests