Draft: Batch the loads and policy checks in the Orbit redaction service
What does this MR do and why?
With the production flag on, this MR makes Orbit redaction use 3.6x less CPU and send 3.6x fewer SQL statements per call. The production SQL time per call falls 2.3x, from 27.9 ms to 11.9 ms. Authorization decisions do not change.
Orbit redaction checks if the user can read each row that an Orbit query returns. On GitLab.com, this check is about 22% of the Orbit query time. The earlier commits remove the per-row queries of the preloads. The new commits load each type with one joined statement and let ids with equal policy facts share one policy check.
Overall result
We replayed 829 calls sampled from production (39,788 ids, real batch sizes and project spreads) on master 733fd5d9a6c8 and on this branch at 65bfb2fad27e. The head commit after it removes a few branches that no input can reach and adds specs. The replay on the head gives the same statements and decisions. CPU is the sum of the per-call medians on a VM. Production SQL is the price of each captured statement on postgres.ai clones of production with production ids: warm execution time plus 0.5 ms per statement. resolve_project_access_from_memberships is on, which is the production state.
| Ids per call | Calls | CPU ratio | SQL ms per call, master → MR | SQL ratio | Statements per call, master → MR |
|---|---|---|---|---|---|
| 1 | 256 | 2.75x | 15.1 → 4.0 | 3.75x | 14.6 → 3.0 |
| 2 | 59 | 2.40x | 16.1 → 5.0 | 3.22x | 15.5 → 3.6 |
| 3-10 | 219 | 2.39x | 23.1 → 9.5 | 2.43x | 19.5 → 6.7 |
| 11-100 | 203 | 3.10x | 28.2 → 12.9 | 2.18x | 20.3 → 7.1 |
| 101-1000 | 82 | 5.80x | 75.3 → 39.0 | 1.93x | 37.2 → 10.6 |
| >1000 | 10 | 4.00x | 137.7 → 65.7 | 2.10x | 118.3 → 7.8 |
| Total | 829 | 3.60x | 27.9 → 11.9 | 2.34x | 20.8 → 5.8 |
| Total, flag off | 829 | 3.53x | 26.1 → 8.4 | 3.11x | 19.3 → 3.5 |
For calls with one or two ids, the SQL gain is larger than the CPU gain, because the SQL cost of a small call follows its statement count, and CPU keeps a fixed Ruby cost per call. For calls with more than 10 ids, the CPU gain is larger, because most of it comes from the shared policy checks, and the clone price of the wide fact and load statements that stay includes cold page reads.
Changes
| Commit | Change | Effect |
|---|---|---|
| 30 earlier commits to the last one | Preload what the policies read, and skip the preseed for two subjects or fewer | No per-row queries in nine policy reads |
| Load subjects with one joined statement | Each type loads with its project, namespace, and root in one statement, without the wide text and token columns | Load statement 7-10x faster warm |
| Seed organization owner ids | The project and group statements also select the organization owner ids | One-id project call: 2 → 1 statements |
| Share one policy check across equal facts | One statement reads the policy facts of the batch; ids with equal facts share one Ability.allowed? run |
One policy run per fact vector |
| Group parent-decided subjects | Labels, milestones, CI stages, packages, and security subjects group by their parent | One policy run per parent |
| Group groups by their fact vector | Groups with equal visibility, root, SAML, IP restriction, organization, and access level share one check | 500 member groups: 8.0x CPU |
| Prune CI partitions | CI statements filter on the recent partitions, and load the missing ids without the filter | p_ci_builds planning: about 110 ms → 6 ms |
| Cover loaders and fact paths | Specs for the loaders, the fact statements, and the gates; removes branches that no input can reach | 273 examples, 0 failures |
Safety
Ability.allowed?is still the only decider. The fact statements only select which ids can share one run. Ids share a run only when their fact vectors are equal.- Banned users, custom roles, admins, auditors, tokens, and external authorization keep the per-id check. An error in the fact path also falls back to the per-id check.
- A replay of 1,658 calls (79,576 ids) against a per-id
Ability.allowed?reference gives 0 mismatches, withresolve_project_access_from_membershipson and off. - We broke the code on purpose in three ways: a wrong root, a gate that is off, and a missing share-lock check. The equivalence check finds the wrong root and the open gate with both flag settings, and the share-lock break with the flag on.
Not in this MR
- Projects that the fallback path loads do not get the project preseeds.
- Groups of group-level work items and notes do not get a preseed. This is not a regression.
- A cache across requests. This MR changes only the work inside one call.
References
- Prior work: !257992 (closed) had the first changes together with model, preloader, and policy changes. It is closed. This MR moves the service part onto current master.
- gitlab-org/gitlab#630828 (closed) fixed the per-check class map cost of this service in September 2026.
Screenshots or screen recordings
Not applicable (no UI change).
How to set up and validate locally
-
Open a Rails console on a GDK with an Ultimate license. Pick a user and a project with at least 100 user notes.
-
Count the queries:
ids = Note.where(project: project).user.limit(100).ids service = Authz::RedactionService.new(user: user, resources_by_type: { 'note' => { 'ids' => ids, 'ability' => 'read_note' } }, source: 'console') ActiveRecord::QueryRecorder.new { service.execute }.count -
Compare the count and the result hash with master. The result hash is the same, and the count no longer grows with the number of ids.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.