Cache each policy class's combined declarations

What this changes

  • conditions, ability_map, global_actions and delegations give 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. Adds Base.definition_generation and Base.invalidate_definitions!.
  • Caching only works if reads leave the maps alone, so AbilityMap#actions no longer inserts an entry for an ability that no rule mentions. It returns a shared frozen empty array. Worth flagging: code that did map.actions(:x) << rule to register a rule now raises FrozenError.

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

🤖 Generated with Claude Code

Edited by Ian Anderson

Merge request reports

Loading
Loading