Cache each policy class's combined declarations
What this changes
conditions,ability_map,global_actionsanddelegationsgive a policy class its own declarations merged with its parent's. They rebuilt that result on every call, and a single permission check makes dozens of those calls. They are now cached on the class.- To keep the cache from going stale, every declaration bumps a counter on
DeclarativePolicy::Base, and a class rebuilds when the counter has moved. Policies can still be defined or reopened lazily. AddsBase.definition_generationandBase.invalidate_definitions!. - Caching only works if reads leave the maps alone, so
AbilityMap#actionsno longer inserts an entry for an ability that no rule mentions. It returns a shared frozen empty array. Worth flagging: code that didmap.actions(:x) << ruleto register a rule now raisesFrozenError.
!52 (closed) tried this caching in 2023 and was closed for having no way to clear the cache, which broke lazily loaded policies. The counter is what answers that.
Numbers
Measured on GitLab's largest policy class (798 conditions, 752 abilities). Each row is one permission check, before and after.
| Permission check | Allocated | CPU |
|---|---|---|
read_code on a project |
1.05 MB to 20.8 KB | 1.252 ms to 0.150 ms |
read_group on a group |
739.3 KB to 18.4 KB | 0.770 ms to 0.122 ms |
read_issue on an issue |
348.2 KB to 25.0 KB | 0.744 ms to 0.438 ms |
Caching trades memory that was thrown away for memory held for the life of the process. Every cache for all 369 of GitLab's policy classes costs 0.88 MB, less than a single permission check used to throw away.
Correctness
All 752 of GitLab's project abilities were checked across 15 projects, 11,280 decisions, and every one came out the same before and after. The gem's own suite passes, with 18 new tests covering the cache and the counter that clears it.