@@ -98,65 +98,82 @@ 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 :
104104 params ["origins" ] = list (origins )
105105 origins_condition = "AND origin = ANY(%(origins)s)"
106106
107107 where_clauses = self ._build_where_clauses (origins_condition , protect_incidents )
108+ temp_tables = {t : f"prune_{ t } " for t in selected_tables }
108109
109110 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 ))
111+ try :
112+ # Snapshot ids while parents still exist, so child predicates can
113+ # resolve them. Do all tables before deleting anything.
114+ for table in selected_tables :
115+ self ._materialize (
116+ cursor , table , temp_tables [table ], where_clauses [table ], params
117+ )
123118
124- if total == 0 :
125- self .stdout .write (self .style .SUCCESS ("Nothing to prune." ))
126- return
119+ counts = {
120+ t : self ._count (cursor , temp_tables [t ]) for t in selected_tables
121+ }
122+ total = sum (counts .values ())
127123
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- )
124+ lines = [ f"Rows older than { cutoff . isoformat () } :" ]
125+ lines += [ f"* { t } : \t { counts [ t ]:>8 } " for t in selected_tables ]
126+ lines += [ "----------------------" , f"* total: \t { total :>8 } " ]
127+ lines . append (
128+ "Note: counts include children cascaded from pruned parents."
133129 )
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 ()
130+ if protect_incidents :
131+ lines .append ("Note: rows linked to an incident are kept." )
132+ self .stdout .write ("\n " .join (lines ))
133+
134+ if total == 0 :
135+ self .stdout .write (self .style .SUCCESS ("Nothing to prune." ))
136+ return
137+
138+ if dry_run :
139+ self .stdout .write (
140+ self .style .WARNING (
141+ "[DRY RUN] No rows deleted. Run without --dry-run to "
142+ "execute."
143+ )
143144 )
144- except EOFError :
145- answer = ""
146- if answer not in ("y" , "yes" ):
147- self .stdout .write ("Aborted." )
148145 return
149146
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- )
147+ if not options ["yes" ]:
148+ summary = ", " .join (f"{ counts [t ]} { t } " for t in selected_tables )
149+ try :
150+ answer = (
151+ input (f"Delete { summary } ({ total } rows total)? [y/N] " )
152+ .strip ()
153+ .lower ()
154+ )
155+ except EOFError :
156+ answer = ""
157+ if answer not in ("y" , "yes" ):
158+ self .stdout .write ("Aborted." )
159+ return
160+
161+ # Delete child-first (reverse of PRUNABLE_TABLES order): each batch
162+ # commits on its own, so a crash mid-run leaves children already gone
163+ # before their parents, never the reverse. Reordering this would risk
164+ # orphans.
165+ deleted = 0
166+ for table in reversed (selected_tables ):
167+ deleted += self ._batch_delete (
168+ cursor , table , temp_tables [table ], options ["batch_size" ]
169+ )
158170
159- self .stdout .write (self .style .SUCCESS (f"Successfully pruned { deleted } rows." ))
171+ self .stdout .write (
172+ self .style .SUCCESS (f"Successfully pruned { deleted } rows." )
173+ )
174+ finally :
175+ for temp_table in temp_tables .values ():
176+ cursor .execute (f"DROP TABLE IF EXISTS { temp_table } " )
160177
161178 def _build_where_clauses (self , origins_condition , protect_incidents ):
162179 """Build the per-table WHERE clauses, chaining the cascade so each child
@@ -200,18 +217,29 @@ def _build_where_clauses(self, origins_condition, protect_incidents):
200217 "tests" : test_where ,
201218 }
202219
203- def _count (self , cursor , table , where , params ):
204- cursor .execute (f"SELECT COUNT(*) FROM { table } WHERE { where } " , params )
220+ def _materialize (self , cursor , table , temp_table , where , params ):
221+ """Snapshot the doomed ids into a temp table so the nested predicate runs
222+ once instead of per batch."""
223+ cursor .execute (f"DROP TABLE IF EXISTS { temp_table } " )
224+ cursor .execute (
225+ f"CREATE TEMP TABLE { temp_table } AS SELECT id FROM { table } WHERE { where } " ,
226+ params ,
227+ )
228+
229+ def _count (self , cursor , temp_table ):
230+ cursor .execute (f"SELECT COUNT(*) FROM { temp_table } " )
205231 return cursor .fetchone ()[0 ]
206232
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)"
233+ def _batch_delete (self , cursor , table , temp_table , batch_size ):
234+ sql = (
235+ f"WITH batch AS ("
236+ f"DELETE FROM { temp_table } WHERE id IN "
237+ f"(SELECT id FROM { temp_table } LIMIT %(batch_size)s) RETURNING id"
238+ f") DELETE FROM { table } WHERE id IN (SELECT id FROM batch)"
211239 )
212240 deleted_total = 0
213241 while True :
214- cursor .execute (delete_sql , params )
242+ cursor .execute (sql , { "batch_size" : batch_size } )
215243 deleted = cursor .rowcount
216244 if deleted == 0 :
217245 break
0 commit comments