Skip new and importing projects in safety-net refreshes
What does this MR do and why?
The permission safety net rechecks users' project access and fixes anything wrong. Most of the "missing access" it reports isn't real.
When a project is created or imported, its members get access from the project's own refresh job, which usually runs a minute or two later. If the safety net checks one of those users in that gap, it adds the access itself. The project's job would have added the same rows. On Oct 23–24, 2026, this race made up about 95% of the missing access the safety net reported.
This MR makes safety-net refreshes skip projects created in the last 10 minutes and projects with an import in progress. The missing-access count can then be trusted, and the safety net stops doing writes that the project's job repeats. Normal refreshes are unchanged, including the low-urgency ones used by group sync.
The change is behind the default-off safety_net_skip_projects_pending_refresh flag, so we can compare the safety net's counts before and after.
Database queries
ProjectAuthorization.project_ids_pending_own_refresh runs two queries, only for projects whose access would change, in batches of up to 1,000 IDs. Most safety-net refreshes change nothing and skip them entirely. Plans are from a Database Lab clone of gitlab-production-main, using 1,000 real project IDs (shown as <1000 ids>).
Recently created projects: 0.06–0.17 ms, 4 buffers, using index_projects_api_created_at_id_desc.
SELECT "projects"."id" FROM "projects"
WHERE "projects"."id" IN (<1000 ids>) AND "projects"."created_at" >= '<now - 10 minutes>'
LIMIT 1000Plan
Limit (cost=0.57..3.40 rows=1 width=4) (actual time=0.148..0.149 rows=0 loops=1)
Buffers: shared hit=4
-> Index Only Scan using index_projects_api_created_at_id_desc on projects (cost=0.57..3.40 rows=1 width=4) (actual time=0.147..0.148 rows=0 loops=1)
Index Cond: ((created_at >= (now() - '00:10:00'::interval)) AND (id = ANY ('{<1000 ids>}'::bigint[])))
Heap Fetches: 0
Buffers: shared hit=4
Planning Time: 0.507 ms
Execution Time: 0.165 msImports in progress: 0.13–0.15 ms, 69 buffers, cold or warm. It starts from index_project_mirror_data_on_status, because only a few imports are in progress across the site (20 at the time of testing), and filters those by project ID.
SELECT "project_mirror_data"."project_id" FROM "project_mirror_data"
WHERE "project_mirror_data"."project_id" IN (<1000 ids>) AND "project_mirror_data"."status" IN ('scheduled', 'started')
LIMIT 1000Plan
Limit (cost=2.94..53.95 rows=1 width=4) (actual time=0.132..0.133 rows=0 loops=1)
Buffers: shared hit=69
-> Index Scan using index_project_mirror_data_on_status on project_mirror_data (cost=2.94..53.95 rows=1 width=4) (actual time=0.131..0.132 rows=0 loops=1)
Index Cond: ((status)::text = ANY ('{scheduled,started}'::text[]))
Filter: (project_id = ANY ('{<1000 ids>}'::bigint[]))
Rows Removed by Filter: 20
Buffers: shared hit=69
Planning Time: 0.310 ms
Execution Time: 0.151 msAn earlier version joined projects to project_mirror_data through Project.is_importing. It read all 1,000 project index entries (about 4,500 buffers, 1.1 seconds on a cold cache), so it was replaced with the query above.
References
- https://gitlab.com/gitlab-org/gitlab/-/work_items/635980
- Rollout issue: https://gitlab.com/gitlab-org/gitlab/-/work_items/635986
Screenshots or screen recordings
Not applicable. This is a background job change with no UI.
How to set up and validate locally
- In the Rails console, enable the flag:
Feature.enable(:safety_net_skip_projects_pending_refresh) - Create a project in a group you're a member of, then remove your access row before the project's refresh job runs:
user = User.find_by(username: 'root') project = Projects::CreateService.new(user, name: 'safety-net-test', namespace_id: Group.first.id).execute ProjectAuthorization.where(user: user, project: project).delete_all - Run a safety-net refresh and confirm the new project is left out:
Gitlab::ApplicationContext.with_raw_context(authorized_projects_refresh_purpose: 'safety_net') do AuthorizedProjectUpdate::FindRecordsDueForRefreshService .new(user, skip_projects_pending_refresh: true).execute end # => the new project is not in either list - Run the same refresh without the skip. The project is added back as usual.
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.