Draft: Add BatchedPreDrain helper for CASCADE FK children
What this does
Adds a new helper class Gitlab::Database::BatchedPreDrain.
When we delete a record that has many CASCADE foreign-key children (for example a large project or group), PostgreSQL deletes every child row inside the single transaction that removes the parent. On big records that transaction becomes huge and runs for a long time, which can cause problems on the database.
This helper deletes those children first, in small batches, before the parent is removed. Each batch is its own DELETE statement, so no single transaction grows large.
How it works
- Finds the children from the schema. It reads the
CASCADEforeign keys that point at the parent table, then follows them down to grandchildren and deeper. - Deletes leaves first. It uses
TablesSortedByForeignKeysso grandchildren are removed before the children they belong to. - Deletes in small batches. Each batch is a
DELETE ... LIMIT nscoped to the one record being drained. - Respects a budget. It stops after a time or row limit (reusing
LooseForeignKeys::ModificationTracker's 30s / 100k defaults) and returns:over_budgetso the caller can run it again later. Otherwise it returns:completed. - Supports per-table hooks. Some tables need extra work on delete (for example object storage or search index cleanup). Callers can register a hook for those tables instead of a plain SQL delete.
- Returns per-table counts. The result includes how many rows were deleted from each table.
- Skips cycles. If a group of tables all reference each other through
CASCADE, the helper leaves them alone and lets the parentDELETEhandle them.
This is a building block only. No existing code calls it yet; that wiring comes in follow-up work.
Usage
result = Gitlab::Database::BatchedPreDrain.new(project).execute
result.status # => :completed or :over_budget
result.deleted_by_table # => { "issues" => 1200, "notes" => 4300 }Callers must run this outside a transaction so each batch commits on its own.
Testing
New spec builds a small throwaway schema (parent → child → grandchild, plus a cycle) and covers:
- children deleted in dependency order (leaves first)
- budget exhaustion returns
:over_budget - per-table hook is used for registered tables
- all-
CASCADEcycles are skipped - result includes per-table counts
- rows for other parents are left untouched
Closes #619116
Data deletion and recoverability
This helper deletes CASCADE foreign-key children of a parent record. It does
not delete anything that would survive the parent's own destruction: these are
exactly the rows PostgreSQL would delete anyway when the parent DELETE cascades.
The helper only moves that deletion earlier and splits it into small committed
batches, so the end state is identical to a normal cascading delete.
Deleted rows are not independently recoverable. Recovery follows the same path as any other row loss — a point-in-time restore from database backups. Because this is a building block with no callers yet, nothing triggers these deletions in this MR; the actual deletion behaviour and its safeguards will be reviewed when a caller wires it up.
Database review
The helper generates one query shape per child table: a batched
DELETE ... WHERE (pk) IN (SELECT pk ... WHERE fk IN (<ancestor scope>) LIMIT n).
For a direct child the ancestor scope is the parent id; for a grandchild it is a
nested subselect back up to the root. No new indexes are added — every FK column
these queries filter on is already indexed.
Plans below were captured on gitlab-production-main (Database Lab) using the
gitlab-org namespace (id = 9970) and the default batch size of 1000.
Direct child: members
DELETE FROM members
WHERE (members.id) IN (
SELECT members.id
FROM members
WHERE members.member_namespace_id IN (9970)
LIMIT 1000
);Index Only Scan on index_members_on_member_namespace_id_compound, then
members_pkey for the delete. No sequential scans. Runtime 2296ms, almost
entirely cold-cache I/O (I/O Timings: read=2265ms); the plan work itself is
~30ms. Plan
EXPLAIN (ANALYZE, BUFFERS)
Delete on public.members (cost=45.23..3636.67 rows=0 width=0) (actual time=2296.384..2296.387 rows=0 loops=1)
Buffers: shared hit=7074 read=1770 dirtied=996
WAL: records=1016 fpi=963 bytes=7882879
I/O Timings: read=2265.129 write=0.000
-> Nested Loop (cost=45.23..3636.67 rows=1000 width=34) (actual time=129.588..2283.336 rows=1000 loops=1)
Buffers: shared hit=4141 read=1767 dirtied=16
-> HashAggregate (cost=44.67..54.67 rows=1000 width=32) (actual time=124.314..125.267 rows=1000 loops=1)
Group Key: "ANY_subquery".id
-> Subquery Scan on "ANY_subquery" (cost=0.57..42.17 rows=1000 width=32) (actual time=7.123..123.770 rows=1000 loops=1)
-> Limit (cost=0.57..32.17 rows=1000 width=4) (actual time=7.104..123.422 rows=1000 loops=1)
-> Index Only Scan using index_members_on_member_namespace_id_compound on public.members members_1 (cost=0.57..101.05 rows=3180 width=4) (actual time=7.103..123.277 rows=1000 loops=1)
Index Cond: (members_1.member_namespace_id = 9970)
Heap Fetches: 31
-> Index Scan using members_pkey on public.members (cost=0.57..3.58 rows=1 width=10) (actual time=2.156..2.156 rows=1 loops=1000)
Index Cond: (members.id = "ANY_subquery".id)
Trigger RI_ConstraintTrigger_a_39064 for constraint fk_b2e4a4b68a: time=13.123 calls=1000Direct child: namespace_settings
DELETE FROM namespace_settings
WHERE (namespace_settings.namespace_id) IN (
SELECT namespace_settings.namespace_id
FROM namespace_settings
WHERE namespace_settings.namespace_id IN (9970)
LIMIT 1000
);namespace_settings is keyed by namespace_id, so both the scope and the delete
use namespace_settings_pkey (Index Only Scan). Runtime 5.6ms.
Plan
EXPLAIN (ANALYZE, BUFFERS)
Delete on public.namespace_settings (cost=4.16..7.19 rows=0 width=0) (actual time=5.619..5.621 rows=0 loops=1)
Buffers: shared hit=9 read=5 dirtied=3
-> Nested Loop (cost=4.16..7.19 rows=1 width=34) (actual time=5.580..5.583 rows=1 loops=1)
-> HashAggregate (cost=3.59..3.60 rows=1 width=32) (actual time=5.560..5.562 rows=1 loops=1)
Group Key: "ANY_subquery".namespace_id
-> Subquery Scan on "ANY_subquery" (cost=0.56..3.59 rows=1 width=32) (actual time=5.544..5.555 rows=1 loops=1)
-> Limit (cost=0.56..3.58 rows=1 width=4) (actual time=5.507..5.517 rows=1 loops=1)
-> Index Only Scan using namespace_settings_pkey on public.namespace_settings namespace_settings_1 (cost=0.56..3.58 rows=1 width=4) (actual time=5.505..5.515 rows=1 loops=1)
Index Cond: (namespace_settings_1.namespace_id = 9970)
Heap Fetches: 2
-> Index Scan using namespace_settings_pkey on public.namespace_settings (cost=0.56..3.58 rows=1 width=10) (actual time=0.016..0.017 rows=1 loops=1)
Index Cond: (namespace_settings.namespace_id = "ANY_subquery".namespace_id)Grandchild: member_approvals (namespaces ← members ← member_approvals)
DELETE FROM member_approvals
WHERE (member_approvals.id) IN (
SELECT member_approvals.id
FROM member_approvals
WHERE member_approvals.member_id IN (
SELECT members.id
FROM members
WHERE members.member_namespace_id IN (9970)
)
LIMIT 1000
);Demonstrates the nested-subselect ancestor scope for a grandchild. member_approvals
holds no rows for this namespace, so the outer scan returns 0 rows and the members
subquery is never executed. When rows exist the planner uses
index_member_approval_on_member_id and members_pkey (both indexed). Runtime 0.005ms.
Plan
EXPLAIN (ANALYZE, BUFFERS)
Delete on public.member_approvals (cost=0.57..3.61 rows=0 width=0) (actual time=0.004..0.005 rows=0 loops=1)
-> Nested Loop Semi Join (cost=0.57..3.61 rows=1 width=38) (actual time=0.003..0.004 rows=0 loops=1)
Join Filter: ("ANY_subquery".id = member_approvals.id)
-> Seq Scan on public.member_approvals (cost=0.00..0.00 rows=1 width=14) (actual time=0.003..0.003 rows=0 loops=1)
-> Subquery Scan on "ANY_subquery" (cost=0.57..3.60 rows=1 width=40) (never executed)
-> Limit (cost=0.57..3.59 rows=1 width=8) (never executed)
-> Nested Loop (cost=0.57..3.59 rows=1 width=8) (never executed)
-> Seq Scan on public.member_approvals member_approvals_1 (cost=0.00..0.00 rows=1 width=16) (never executed)
-> Index Scan using members_pkey on public.members (cost=0.57..3.59 rows=1 width=4) (never executed)
Index Cond: (members.id = member_approvals_1.member_id)
Filter: (members.member_namespace_id = 9970)