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, with resolve_project_access_from_memberships on 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

  1. Open a Rails console on a GDK with an Ultimate license. Pick a user and a project with at least 100 user notes.

  2. 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
  3. 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.

Edited by Michael Angelo Rivera

Merge request reports

Loading
Loading