security_findings partition detach permanently blocked by a single un-purged scan at partition tail
Everyone can contribute. Help move this issue forward while earning points, leveling up and collecting rewards.
Summary
The security_findings partition detach logic can be permanently blocked by a single security_scans record that misses purge processing. Because SlidingListStrategy#extra_partitions uses take_while, one blocked partition halts detach evaluation for all subsequent stale partitions. This causes unbounded storage growth on instances with sufficient scan volume.
This is a follow-up defect to #569397 (closed) / !205123 (merged). That fix ensured partitions rotate correctly, but the detach mechanism has this separate defect that surfaces when the purge worker misses individual scans.
Environment
- GitLab 18.11.3-ee, self-managed, EKS
- PostgreSQL 16.10 on Amazon RDS
- Zendesk ticket: 735407
The two-part defect
Part 1 — detach_partition? inspects only the last finding
ee/app/models/security/finding.rb:
def detach_partition?(partition_number)
last_finding_in_partition(partition_number)&.scan&.findings_can_be_purged?
end
def last_finding_in_partition(partition_number)
where(partition_number: partition_number).last
endIf the single "last" finding's scan is not status: :purged, the whole partition is treated as non-detachable, regardless of the state of every other row.
Part 2 — SlidingListStrategy#extra_partitions uses take_while
lib/gitlab/database/partitioning/sliding_list_strategy.rb:
extra = possibly_extra.take_while { |p| detach_partition_if.call(p) }take_while stops iterating on the first false. Any partition that returns false blocks detach evaluation for all higher-numbered partitions permanently — even if they are perfectly eligible.
Combined effect: one lagging scan → one blocked partition → all subsequent stale partitions accumulate indefinitely.
Concrete evidence from the incident
Instance had 14 stale partitions (numbered 9–22), each 100–120 GB. Sampling the last finding's scan status per partition:
| Partition | Last scan ID | Status | Scan age | Detach eligible? |
|---|---|---|---|---|
| 9 | 3187985 | 1 (succeeded) | 7 mo | age yes, status no → blocked |
| 10 | 1996915 | 6 (purged) | 6 mo | yes → but blocked by 9 |
| 11 | 1997354 | 1 (succeeded) | 5 mo | age yes, status no → blocked |
| 12 | 4626690 | 1 (succeeded) | 4.5 mo | age yes, status no → blocked |
| 13 | 2186782 | 1 (succeeded) | 4 mo | age yes, status no → blocked |
| 14 | 2592395 | 6 (purged) | 3.5 mo | yes → but blocked by 9 |
| 15 | 2600903 | 6 (purged) | 3 mo | not yet stale (~85 days) |
| 16-22 | various | mostly 1 | < 90 days | not yet stale |
| 23 | (active) | — | current | active |
Sampling the tail of blocked partitions confirmed only 1-2 findings from a single un-purged scan were at the partition tail — everything else in the partition was already status: 6. Example from partition 9 (122 GB total):
SELECT s.id, s.status, s.created_at
FROM security_findings sf
JOIN security_scans s ON s.id = sf.scan_id
WHERE sf.id BETWEEN 1811739946 AND 1811839946 -- last 100K IDs of partition 9
AND s.status != 6;
id | status | created_at
---------+--------+-------------------------------
3187985 | 1 | 2025-12-16 08:58:07.845025+00
3187985 | 1 | 2025-12-16 08:58:07.845025+00One scan holding 122 GB attached.
Proof of the take_while mechanism
Direct reproduction in Rails console during the incident:
# Initial state — nothing detachable, despite 3 partitions meeting all criteria
Security::Finding.partitioning_strategy.extra_partitions.map { |p| p.value }
=> []
# Force-purge the rogue scan in partition 9 only:
# UPDATE security_scans SET status = 6 WHERE id = 3187985;
# Framework advances past partition 9, evaluates 10, then halts at 11:
Security::Finding.partitioning_strategy.extra_partitions.map { |p| p.value }
=> [9, 10]
# Force-purge rogue scans in 11, 12, 13:
# UPDATE security_scans SET status = 6 WHERE id IN (1997354, 4626690, 2186782);
# Framework evaluates through 14, halts at 15 (which is <90 days old, correct):
Security::Finding.partitioning_strategy.extra_partitions.map { |p| p.value }
=> [9, 10, 11, 12, 13, 14]Each UPDATE unblocked exactly one partition of downstream evaluation. Definitive proof of the take_while halt-on-first-false behaviour.
How scans get stuck at non-purged status past retention
Not investigated in depth during the incident. PurgeScansService.purge_stale_records uses Security::Scan.stale.ordered_by_created_at_and_id with a Redis cursor (LAST_PURGED_SCAN_TUPLE, 24h TTL) and batches of 100 with MAX_STALE_SCANS_SIZE = 200_000 per run. Possible mechanisms:
- Cursor persistence anomaly leaves individual records behind if a run crashes mid-batch.
- A specific scan hits a validation/trigger error during
update_all(status: :purged)and gets silently skipped. - The
stalescope excludes something we haven't spotted.
Either way, once a scan is >90 days old, in the stale scope, but stuck at status != 6, the detach machinery cannot recover on its own.
Proposed fixes
Fix A — detach_partition? should evaluate the whole partition, not just the last row
Change the check from "the last finding's scan is purged" to "no scan in this partition is both stale-eligible and un-purged":
def detach_partition?(partition_number)
return false unless oldest_record_stale?(partition_number) # existing 18.5 semantics
!by_partition_number(partition_number)
.joins(:scan)
.where.not(scan: { status: :purged })
.exists?
endCost: one exists check per partition per manager run. On indexed data this is cheap.
Fix B — SlidingListStrategy#extra_partitions should not use take_while
Replace with select or filter_map. One blocked partition should not halt evaluation of others:
extra = possibly_extra.select { |p| detach_partition_if.call(p) }There may have been a reason for take_while (ordering invariant assumption). If so, it should be documented and the sliding-list contract clarified. Otherwise, filter is safer.
Fix C (defence in depth) — investigate PurgeScansService cursor/batch semantics
Separate issue-worthy. LAST_PURGED_SCAN_TUPLE cursor with 24h TTL, 100-row batches, and 200K per-run cap has enough moving parts to leave individual rows unprocessed. Deserves independent audit.
Workaround (documented for anyone hitting this)
The existing workaround in #569397 — TRUNCATE the offending partition — works but is heavy-handed. A less invasive workaround verified during this incident:
-
Identify the rogue scan(s) at the tail of each stuck stale partition:
SELECT s.id, s.status, s.created_at FROM security_findings sf JOIN security_scans s ON s.id = sf.scan_id WHERE sf.id BETWEEN <last_id - 100000> AND <last_id> AND s.status != 6; -
Force-purge them:
UPDATE security_scans SET status = 6 WHERE id IN (<rogue_ids>); -
Trigger detach:
Gitlab::Database::Partitioning::PartitionManager.new(Security::Finding).sync_partitions -
Wait 7 days for
Postgresql::DetachedPartitionDropper, or manuallyDROP TABLEthe detached standalone tables to reclaim storage immediately.
References
- Ticket: ZD 735407 (internal)
- Original defect: #569397 (closed)
- Partial fix: !205123 (merged)
/cc @ghavenga (author of the #569397 (closed) fix)