Skip to content

Commit 6995fc0

Browse files
committed
feat: Use temp table for deletion
Signed-off-by: Alan Peixinho <alan.peixinho@profusion.mobi>
1 parent 36aa334 commit 6995fc0

3 files changed

Lines changed: 139 additions & 72 deletions

File tree

backend/docs/prune_db command.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Models use `DO_NOTHING` foreign keys, so the command applies manual cascade rule
1616
### Optional Parameters
1717

1818
- `--tables`: Limit pruning to specific tables (comma-separated). Valid options: `checkouts`, `builds`, `tests`. Default: all three.
19-
- Cascade still applies inside the selected tables: a build matched because its checkout is old is deleted when `builds` is selected, and a test matched because its build is doomed is deleted when `tests` is selected.
19+
- Cascade only drags a child when the child's parent table is also selected. For example, `--tables tests` removes only tests past the cutoff; recent tests under an old build/checkout are kept because those parents are not being pruned. With `--tables builds,tests`, an old build still drags its recent tests, but an old checkout does not drag its recent builds (checkouts are not selected).
2020
- Tables not listed are not deleted. For example, `--tables builds` removes old builds but leaves their tests in place. Selecting a parent without its children (e.g. only `checkouts`) can therefore leave orphaned rows.
2121
- `--origins`: Limit age-based pruning to specific origins (comma-separated). If omitted, any origin is considered.
2222
- Cascade ignores origin: once a parent row is doomed, its children are removed even if they belong to a different origin.

backend/kernelCI_app/management/commands/prune_db.py

Lines changed: 103 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -98,70 +98,91 @@ def handle(self, *args, **options):
9898
dry_run = options["dry_run"]
9999
origins = options["origins"]
100100

101-
params = {"cutoff": cutoff, "batch_size": options["batch_size"]}
101+
params = {"cutoff": cutoff}
102102
origins_condition = ""
103103
if origins:
104-
params["origins"] = list(origins)
104+
params["origins"] = origins
105105
origins_condition = "AND origin = ANY(%(origins)s)"
106106

107-
where_clauses = self._build_where_clauses(origins_condition, protect_incidents)
107+
where_clauses = self._build_where_clauses(
108+
origins_condition, protect_incidents, selected_tables
109+
)
110+
temp_tables = {t: f"prune_{t}" for t in selected_tables}
108111

109112
with connections["default"].cursor() as cursor:
110-
counts = {
111-
t: self._count(cursor, t, where_clauses[t], params)
112-
for t in selected_tables
113-
}
114-
total = sum(counts.values())
115-
116-
lines = [f"Rows older than {cutoff.isoformat()}:"]
117-
lines += [f"* {t}:\t{counts[t]:>8}" for t in selected_tables]
118-
lines += ["----------------------", f"* total:\t{total:>8}"]
119-
lines.append("Note: counts include children cascaded from pruned parents.")
120-
if protect_incidents:
121-
lines.append("Note: rows linked to an incident are kept.")
122-
self.stdout.write("\n".join(lines))
113+
try:
114+
# Snapshot ids while parents still exist, so child predicates can
115+
# resolve them. Do all tables before deleting anything.
116+
for table in selected_tables:
117+
self._materialize(
118+
cursor, table, temp_tables[table], where_clauses[table], params
119+
)
123120

124-
if total == 0:
125-
self.stdout.write(self.style.SUCCESS("Nothing to prune."))
126-
return
121+
counts = {
122+
t: self._count(cursor, temp_tables[t]) for t in selected_tables
123+
}
124+
total = sum(counts.values())
127125

128-
if dry_run:
129-
self.stdout.write(
130-
self.style.WARNING(
131-
"[DRY RUN] No rows deleted. Run without --dry-run to execute."
132-
)
126+
lines = [f"Rows older than {cutoff.isoformat()}:"]
127+
lines += [f"* {t}:\t{counts[t]:>8}" for t in selected_tables]
128+
lines += ["----------------------", f"* total:\t{total:>8}"]
129+
lines.append(
130+
"Note: counts include children cascaded from pruned parents."
133131
)
134-
return
135-
136-
if not options["yes"]:
137-
summary = ", ".join(f"{counts[t]} {t}" for t in selected_tables)
138-
try:
139-
answer = (
140-
input(f"Delete {summary} ({total} rows total)? [y/N] ")
141-
.strip()
142-
.lower()
132+
if protect_incidents:
133+
lines.append("Note: rows linked to an incident are kept.")
134+
self.stdout.write("\n".join(lines))
135+
136+
if total == 0:
137+
self.stdout.write(self.style.SUCCESS("Nothing to prune."))
138+
return
139+
140+
if dry_run:
141+
self.stdout.write(
142+
self.style.WARNING(
143+
"[DRY RUN] No rows deleted. Run without --dry-run to "
144+
"execute."
145+
)
143146
)
144-
except EOFError:
145-
answer = ""
146-
if answer not in ("y", "yes"):
147-
self.stdout.write("Aborted.")
148147
return
149148

150-
# Delete child-first (reverse of PRUNABLE_TABLES order): each batch commits
151-
# on its own, so a crash mid-run leaves children already gone before their
152-
# parents, never the reverse. Reordering this would risk orphans.
153-
deleted = 0
154-
for table in reversed(selected_tables):
155-
deleted += self._batch_delete(
156-
cursor, table, where_clauses[table], params
157-
)
149+
if not options["yes"]:
150+
summary = ", ".join(f"{counts[t]} {t}" for t in selected_tables)
151+
try:
152+
answer = (
153+
input(f"Delete {summary} ({total} rows total)? [y/N] ")
154+
.strip()
155+
.lower()
156+
)
157+
except EOFError:
158+
answer = ""
159+
if answer not in ("y", "yes"):
160+
self.stdout.write("Aborted.")
161+
return
158162

159-
self.stdout.write(self.style.SUCCESS(f"Successfully pruned {deleted} rows."))
163+
# Delete child-first (reverse of PRUNABLE_TABLES order): each batch
164+
# commits on its own, so a crash mid-run leaves children already gone
165+
# before their parents, never the reverse. Reordering this would risk
166+
# orphans.
167+
deleted = 0
168+
for table in reversed(selected_tables):
169+
deleted += self._batch_delete(
170+
cursor, table, temp_tables[table], options["batch_size"]
171+
)
160172

161-
def _build_where_clauses(self, origins_condition, protect_incidents):
173+
self.stdout.write(
174+
self.style.SUCCESS(f"Successfully pruned {deleted} rows.")
175+
)
176+
finally:
177+
for temp_table in temp_tables.values():
178+
cursor.execute(f"DROP TABLE IF EXISTS {temp_table}")
179+
180+
def _build_where_clauses(
181+
self, origins_condition, protect_incidents, selected_tables
182+
):
162183
"""Build the per-table WHERE clauses, chaining the cascade so each child
163-
matches when its parent is doomed, and appending incident protection when
164-
enabled."""
184+
matches when its selected parent is doomed, and appending incident protection
185+
when enabled."""
165186
age = f"_timestamp < %(cutoff)s {origins_condition}"
166187

167188
# A row tied to an incident is protected, along with the ancestors that would
@@ -186,32 +207,50 @@ def _build_where_clauses(self, origins_condition, protect_incidents):
186207
)
187208

188209
checkout_where = age + exclusion.get("checkouts", "")
189-
build_where = (
190-
f"(({age}) OR checkout_id IN (SELECT id FROM checkouts WHERE {checkout_where}))"
191-
+ exclusion.get("builds", "")
192-
)
193-
test_where = (
194-
f"(({age}) OR build_id IN (SELECT id FROM builds WHERE {build_where}))"
195-
+ exclusion.get("tests", "")
196-
)
210+
211+
build_terms = [f"({age})"]
212+
if "checkouts" in selected_tables:
213+
build_terms.append(
214+
f"checkout_id IN (SELECT id FROM checkouts WHERE {checkout_where})"
215+
)
216+
build_where = "(" + " OR ".join(build_terms) + ")" + exclusion.get("builds", "")
217+
218+
test_terms = [f"({age})"]
219+
if "builds" in selected_tables:
220+
test_terms.append(
221+
f"build_id IN (SELECT id FROM builds WHERE {build_where})"
222+
)
223+
test_where = "(" + " OR ".join(test_terms) + ")" + exclusion.get("tests", "")
224+
197225
return {
198226
"checkouts": checkout_where,
199227
"builds": build_where,
200228
"tests": test_where,
201229
}
202230

203-
def _count(self, cursor, table, where, params):
204-
cursor.execute(f"SELECT COUNT(*) FROM {table} WHERE {where}", params)
231+
def _materialize(self, cursor, table, temp_table, where, params):
232+
"""Snapshot the doomed ids into a temp table so the nested predicate runs
233+
once instead of per batch."""
234+
cursor.execute(f"DROP TABLE IF EXISTS {temp_table}")
235+
cursor.execute(
236+
f"CREATE TEMP TABLE {temp_table} AS SELECT id FROM {table} WHERE {where}",
237+
params,
238+
)
239+
240+
def _count(self, cursor, temp_table):
241+
cursor.execute(f"SELECT COUNT(*) FROM {temp_table}")
205242
return cursor.fetchone()[0]
206243

207-
def _batch_delete(self, cursor, table, where, params):
208-
delete_sql = (
209-
f"DELETE FROM {table} WHERE id IN "
210-
f"(SELECT id FROM {table} WHERE {where} LIMIT %(batch_size)s)"
244+
def _batch_delete(self, cursor, table, temp_table, batch_size):
245+
sql = (
246+
f"WITH batch AS ("
247+
f"DELETE FROM {temp_table} WHERE id IN "
248+
f"(SELECT id FROM {temp_table} LIMIT %(batch_size)s) RETURNING id"
249+
f") DELETE FROM {table} WHERE id IN (SELECT id FROM batch)"
211250
)
212251
deleted_total = 0
213252
while True:
214-
cursor.execute(delete_sql, params)
253+
cursor.execute(sql, {"batch_size": batch_size})
215254
deleted = cursor.rowcount
216255
if deleted == 0:
217256
break

backend/kernelCI_app/tests/integrationTests/prune_db_test.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -130,29 +130,57 @@ def test_origins_cascade_ignores_child_origin():
130130

131131
@pytest.mark.django_db
132132
def test_tables_tests_only():
133-
"""--tables limits deletion to the selected table."""
133+
"""--tables tests deletes only old tests; parents and a recent test under an old
134+
build/checkout are kept because those parents are not being pruned."""
134135
checkout = CheckoutFactory(field_timestamp=_days_ago(30))
135136
build = BuildFactory(checkout=checkout, field_timestamp=_days_ago(30))
136-
test = TestFactory(build=build, field_timestamp=_days_ago(30))
137+
old_test = TestFactory(build=build, field_timestamp=_days_ago(30))
138+
recent_test = TestFactory(build=build, field_timestamp=_days_ago(1))
137139

138140
_prune(yes=True, tables=["tests"])
139141

140142
assert Checkouts.objects.filter(id=checkout.id).exists()
141143
assert Builds.objects.filter(id=build.id).exists()
142-
assert not Tests.objects.filter(id=test.id).exists()
144+
assert not Tests.objects.filter(id=old_test.id).exists()
145+
assert Tests.objects.filter(id=recent_test.id).exists()
143146

144147

145148
@pytest.mark.django_db
146149
def test_tables_builds_only():
150+
"""--tables builds deletes only old builds; a recent build survives, the checkout is
151+
untouched, and an unlisted child test is left in place (orphaned)."""
147152
checkout = CheckoutFactory(field_timestamp=_days_ago(30))
148-
build = BuildFactory(checkout=checkout, field_timestamp=_days_ago(30))
149-
test = TestFactory(build=build, field_timestamp=_days_ago(30))
153+
old_build = BuildFactory(checkout=checkout, field_timestamp=_days_ago(30))
154+
orphaned_test = TestFactory(build=old_build, field_timestamp=_days_ago(30))
155+
recent_build = BuildFactory(checkout=checkout, field_timestamp=_days_ago(1))
150156

151157
_prune(yes=True, tables=["builds"])
152158

153159
assert Checkouts.objects.filter(id=checkout.id).exists()
154-
assert not Builds.objects.filter(id=build.id).exists()
155-
assert Tests.objects.filter(id=test.id).exists()
160+
assert not Builds.objects.filter(id=old_build.id).exists()
161+
assert Builds.objects.filter(id=recent_build.id).exists()
162+
assert Tests.objects.filter(id=orphaned_test.id).exists()
163+
164+
165+
@pytest.mark.django_db
166+
def test_tables_builds_tests_cascade_within_selection():
167+
"""With builds and tests selected, an old build still drags its recent tests, but
168+
an old checkout does not drag its recent builds (checkouts not selected)."""
169+
checkout = CheckoutFactory(field_timestamp=_days_ago(30))
170+
old_build = BuildFactory(checkout=checkout, field_timestamp=_days_ago(30))
171+
old_build_recent_test = TestFactory(build=old_build, field_timestamp=_days_ago(1))
172+
recent_build = BuildFactory(checkout=checkout, field_timestamp=_days_ago(1))
173+
recent_build_recent_test = TestFactory(
174+
build=recent_build, field_timestamp=_days_ago(1)
175+
)
176+
177+
_prune(yes=True, tables=["builds", "tests"])
178+
179+
assert Checkouts.objects.filter(id=checkout.id).exists()
180+
assert not Builds.objects.filter(id=old_build.id).exists()
181+
assert not Tests.objects.filter(id=old_build_recent_test.id).exists()
182+
assert Builds.objects.filter(id=recent_build.id).exists()
183+
assert Tests.objects.filter(id=recent_build_recent_test.id).exists()
156184

157185

158186
@pytest.mark.django_db

0 commit comments

Comments
 (0)