Fix stuck running scan_result_policy_violations after clean pipeline
What does this MR do and why?
A scan_result_policy_violations row can get stuck in running status forever. The merge request then shows "All policy rules must be satisfied" with a spinner and detailed_merge_status: security_policy_violations. No pipeline rerun, policy removal/re-add, or the mergeRequestResyncSecurityPolicies GraphQL mutation clears it.
The root cause is in Security::SecurityOrchestrationPolicies::UpdateViolationsService: it only deletes and upserts the exact rows its caller hands it. A row the caller cannot name is never reconciled. Two cases produce such a row:
-
With
deprecate_scan_result_policiesenabled, deletes were filtered byapproval_policy_rule_idonly. A legacy policy source — an approval rule linked only to ascan_result_policy_read, with noapproval_policy_rule— has no id there. The rowApprovalProjectRule#create_violationcreates for it carries a NULLapproval_policy_rule_id, so the filter can never match it.delete_violations_by_written_identitynow deletes each row by the identity it was written with:approval_policy_rule_idwhen the source has one,scan_result_policy_idwhen it does not. This part is not scoped torunning— it keeps the existing "this rule now passes, clear whatever status the row had" behavior.scan_result_policy_idis unique per merge request, so it cannot reach another source's row. -
When two
scan_result_policy_reads (one per approval action) map to the sameapproval_policy_rule,dedupe_by_approval_policy_rulekeeps one row for the upsert and drops the other. The dropped row's own pre-existing violation was never reconciled.delete_orphaned_violationsnow cleans those up, scoped torunningrows, so an already-resolvedfailed/warnrow is never erased as a side effect.
Deliberately not fixed: a violation excluded because its violation_data fails schema validation is left alone. The policy is still violated, it just cannot be persisted, so its running row is the correct fail-closed state. Issue 617804 lists three gaps; this fixes two of them and leaves this one on purpose.
Ships behind fix_scan_result_policy_violation_reconciliation (gitlab_com_derisk, default off, milestone 19.5).
Rollout issue: #630688 (closed)
On GitLab.com there are currently 12 running rows with a NULL approval_policy_rule_id, older than 3 days, sitting on 10 open merge requests across 8 projects. Checked two of those merge requests in a read-only production console: the current code's delete matches 0 rows, the fixed code's delete matches exactly the stuck row. Once the flag is on, these rows clear on the next evaluation of their merge request (a new pipeline or a policy resync); merge requests that never get another evaluation are cleaned up by the post-deployment migration in !257017 (merged).
Reviewable LOC: 71 (plus 105 in tests)
Context for LLM agents
- The invalid-
violation_datacase is left fail-closed on purpose: the row still represents a real, unresolved violation, and clearing it without a persisted verdict would let an unenforced policy pass silently. - The written-identity delete is safe because
scan_result_policy_idis unique per merge request — deleting by it can only ever match the row it was written for, never another source's row. - An earlier revision of this MR tried a fallback lookup by
approval_policy_rule_idinstead. That could not work: the stuck rows store NULL in that column, so there was nothing to fall back to. - A duplicate pair of sources for one rule with differing statuses is out of scope here; tracked in #630687.
References
Related issue: #617804
Screenshots or screen recordings
Backend-only, no UI change.
How to set up and validate locally
- Enable the feature flag in the rails console:
Feature.enable(:fix_scan_result_policy_violation_reconciliation) - Set up a merge request with an approval policy approval rule whose
approval_policy_rule_idis NULL (onlyscan_result_policy_idset), and arunningviolation row for it. - Run an evaluation pass and confirm the row is deleted.
- Disable the flag and repeat the same setup: confirm the row survives instead.
Database
The new queries are batched DELETEs on scan_result_policy_violations, each scoped to one merge request. At most a few rows per merge request, so each_batch runs one batch. No migration.
Plans from Joe on gitlab-production-main (EXPLAIN (ANALYZE, BUFFERS) on a clone). merge_request_id is this MR's id; scan_result_policy_id and approval_policy_rule_id are placeholder values, so the plans return 0 rows. The filter columns and index choice match production calls.
1. Legacy source delete (by scan_result_policy_id)
Existence probe for the audit event, each_batch bounds, then the delete.
SELECT 1 AS one FROM "scan_result_policy_violations" WHERE "scan_result_policy_violations"."merge_request_id" = 521180568 AND "scan_result_policy_violations"."scan_result_policy_id" = 1 AND "scan_result_policy_violations"."violation_data" IS NOT NULL LIMIT 1;Plan · 2.934 ms · Joe command 503132
Limit (cost=0.43..3.45 rows=1 width=4) (actual time=2.114..2.115 rows=0 loops=1)
Buffers: shared hit=3 read=3
I/O Timings: read=2.076 write=0.000
-> Index Scan using index_scan_result_policy_violations_on_policy_and_merge_request on public.scan_result_policy_violations (cost=0.43..3.45 rows=1 width=4) (actual time=2.112..2.112 rows=0 loops=1)
Index Cond: ((scan_result_policy_violations.scan_result_policy_id = 1) AND (scan_result_policy_violations.merge_request_id = 521180568))
Filter: (scan_result_policy_violations.violation_data IS NOT NULL)
Buffers: shared hit=3 read=3
I/O Timings: read=2.076 write=0.000SELECT "scan_result_policy_violations"."id" FROM "scan_result_policy_violations" WHERE "scan_result_policy_violations"."merge_request_id" = 521180568 AND "scan_result_policy_violations"."scan_result_policy_id" = 1 ORDER BY "scan_result_policy_violations"."id" ASC LIMIT 1;Plan · 0.851 ms · Joe command 503134
Limit (cost=3.46..3.46 rows=1 width=8) (actual time=0.030..0.031 rows=0 loops=1)
Buffers: shared hit=9
I/O Timings: read=0.000 write=0.000
-> Sort (cost=3.46..3.46 rows=1 width=8) (actual time=0.029..0.029 rows=0 loops=1)
Sort Key: scan_result_policy_violations.id
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=9
I/O Timings: read=0.000 write=0.000
-> Index Scan using index_scan_result_policy_violations_on_policy_and_merge_request on public.scan_result_policy_violations (cost=0.43..3.45 rows=1 width=8) (actual time=0.014..0.014 rows=0 loops=1)
Index Cond: ((scan_result_policy_violations.scan_result_policy_id = 1) AND (scan_result_policy_violations.merge_request_id = 521180568))
Buffers: shared hit=6
I/O Timings: read=0.000 write=0.000SELECT "scan_result_policy_violations"."id" FROM "scan_result_policy_violations" WHERE "scan_result_policy_violations"."merge_request_id" = 521180568 AND "scan_result_policy_violations"."scan_result_policy_id" = 1 AND "scan_result_policy_violations"."id" >= 1 ORDER BY "scan_result_policy_violations"."id" ASC LIMIT 1 OFFSET 1000;Plan · 0.787 ms · Joe command 503136
Limit (cost=3.46..3.47 rows=1 width=8) (actual time=0.041..0.042 rows=0 loops=1)
Buffers: shared hit=9
I/O Timings: read=0.000 write=0.000
-> Sort (cost=3.46..3.46 rows=1 width=8) (actual time=0.040..0.040 rows=0 loops=1)
Sort Key: scan_result_policy_violations.id
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=9
I/O Timings: read=0.000 write=0.000
-> Index Scan using index_scan_result_policy_violations_on_policy_and_merge_request on public.scan_result_policy_violations (cost=0.43..3.45 rows=1 width=8) (actual time=0.019..0.020 rows=0 loops=1)
Index Cond: ((scan_result_policy_violations.scan_result_policy_id = 1) AND (scan_result_policy_violations.merge_request_id = 521180568))
Filter: (scan_result_policy_violations.id >= 1)
Buffers: shared hit=6
I/O Timings: read=0.000 write=0.000DELETE FROM "scan_result_policy_violations" WHERE "scan_result_policy_violations"."merge_request_id" = 521180568 AND "scan_result_policy_violations"."scan_result_policy_id" = 1 AND "scan_result_policy_violations"."id" >= 1;Plan · 0.834 ms · Joe command 503138
Delete on public.scan_result_policy_violations (cost=0.43..3.45 rows=0 width=0) (actual time=0.019..0.020 rows=0 loops=1)
Buffers: shared hit=6
I/O Timings: read=0.000 write=0.000
-> Index Scan using index_scan_result_policy_violations_on_policy_and_merge_request on public.scan_result_policy_violations (cost=0.43..3.45 rows=1 width=6) (actual time=0.018..0.019 rows=0 loops=1)
Index Cond: ((scan_result_policy_violations.scan_result_policy_id = 1) AND (scan_result_policy_violations.merge_request_id = 521180568))
Filter: (scan_result_policy_violations.id >= 1)
Buffers: shared hit=6
I/O Timings: read=0.000 write=0.0002. Orphaned running rows dropped by deduplication
each_batch start, then the delete.
SELECT "scan_result_policy_violations"."id" FROM "scan_result_policy_violations" WHERE "scan_result_policy_violations"."status" = 0 AND "scan_result_policy_violations"."merge_request_id" = 521180568 AND "scan_result_policy_violations"."scan_result_policy_id" = 1 ORDER BY "scan_result_policy_violations"."id" ASC LIMIT 1;Plan · 0.770 ms · Joe command 503140
Limit (cost=3.46..3.46 rows=1 width=8) (actual time=0.033..0.034 rows=0 loops=1)
Buffers: shared hit=9
I/O Timings: read=0.000 write=0.000
-> Sort (cost=3.46..3.46 rows=1 width=8) (actual time=0.032..0.033 rows=0 loops=1)
Sort Key: scan_result_policy_violations.id
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=9
I/O Timings: read=0.000 write=0.000
-> Index Scan using index_scan_result_policy_violations_on_policy_and_merge_request on public.scan_result_policy_violations (cost=0.43..3.45 rows=1 width=8) (actual time=0.016..0.016 rows=0 loops=1)
Index Cond: ((scan_result_policy_violations.scan_result_policy_id = 1) AND (scan_result_policy_violations.merge_request_id = 521180568))
Filter: (scan_result_policy_violations.status = 0)
Buffers: shared hit=6
I/O Timings: read=0.000 write=0.000DELETE FROM "scan_result_policy_violations" WHERE "scan_result_policy_violations"."status" = 0 AND "scan_result_policy_violations"."merge_request_id" = 521180568 AND "scan_result_policy_violations"."scan_result_policy_id" = 1 AND "scan_result_policy_violations"."id" >= 1;Plan · 0.883 ms · Joe command 503142
Delete on public.scan_result_policy_violations (cost=0.43..3.45 rows=0 width=0) (actual time=0.020..0.020 rows=0 loops=1)
Buffers: shared hit=6
I/O Timings: read=0.000 write=0.000
-> Index Scan using index_scan_result_policy_violations_on_policy_and_merge_request on public.scan_result_policy_violations (cost=0.43..3.45 rows=1 width=6) (actual time=0.019..0.019 rows=0 loops=1)
Index Cond: ((scan_result_policy_violations.scan_result_policy_id = 1) AND (scan_result_policy_violations.merge_request_id = 521180568))
Filter: ((scan_result_policy_violations.id >= 1) AND (scan_result_policy_violations.status = 0))
Buffers: shared hit=6
I/O Timings: read=0.000 write=0.0003. Delete by approval_policy_rule_id
Existing query shape, now run separately from 1.
DELETE FROM "scan_result_policy_violations" WHERE "scan_result_policy_violations"."merge_request_id" = 521180568 AND "scan_result_policy_violations"."approval_policy_rule_id" = 1 AND "scan_result_policy_violations"."id" >= 1;Plan · 5.207 ms · Joe command 503144
Delete on public.scan_result_policy_violations (cost=4.20..5.71 rows=0 width=0) (actual time=4.337..4.339 rows=0 loops=1)
Buffers: shared hit=3 read=3
I/O Timings: read=4.289 write=0.000
-> Bitmap Heap Scan on public.scan_result_policy_violations (cost=4.20..5.71 rows=1 width=6) (actual time=4.336..4.337 rows=0 loops=1)
Recheck Cond: ((scan_result_policy_violations.merge_request_id = 521180568) AND (scan_result_policy_violations.approval_policy_rule_id = 1))
Filter: (scan_result_policy_violations.id >= 1)
Buffers: shared hit=3 read=3
I/O Timings: read=4.289 write=0.000
-> BitmapAnd (cost=4.20..4.20 rows=1 width=0) (actual time=4.327..4.328 rows=0 loops=1)
Buffers: shared hit=3 read=3
I/O Timings: read=4.289 write=0.000
-> Bitmap Index Scan on index_scan_result_policy_violations_on_merge_request_id (cost=0.00..1.95 rows=3 width=0) (actual time=4.326..4.326 rows=0 loops=1)
Index Cond: (scan_result_policy_violations.merge_request_id = 521180568)
Buffers: shared hit=3 read=3
I/O Timings: read=4.289 write=0.000
-> Bitmap Index Scan on index_scan_result_policy_violations_on_approval_policy_rule_id (cost=0.00..1.99 rows=9 width=0) (never executed)
Index Cond: (scan_result_policy_violations.approval_policy_rule_id = 1)
I/O Timings: read=0.000 write=0.000MR 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.
Ran locally: ee/spec/services/security/security_orchestration_policies/update_violations_service_spec.rb, policy_rule_evaluation_service_spec.rb, update_approvals_service_spec.rb, sync_preexisting_states_approval_rules_service_spec.rb, unblock_pending_merge_request_violations_worker_spec.rb, unenforceable_policy_rules_notification_service_spec.rb — 683 examples, 0 failures. RuboCop clean on the changed files. update_violations_service_spec.rb re-run after the latest change: 60 examples, 0 failures.