PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 All 36 releases
← All changes | includes/class-database-cleaner.php +400 -65 1.2.4 → 1.4.2 View file →
@@ -9,15 +9,23 @@
9 9 * the dashboard can show "this is what cleanup would remove"
10 10 * BEFORE the user clicks Clean. Cheap enough to run every panel
11 11 * mount.
12 12 * - clean( $types ) is destructive — accepts a whitelist of type
13 - * keys, runs the DELETE / OPTIMIZE for each, returns the
14 - * post-cleanup counts so the UI can refresh.
13 + * keys, removes what each one matches, returns the post-cleanup
14 + * counts so the UI can refresh.
15 15 *
16 - * All queries go through $wpdb->prepare or are string-literal SQL on
17 - * static table-name interpolations (sanitized via $wpdb->prefix).
18 - * Never trusts user input directly in SQL.
16 + * Posts and comments are deleted through wp_delete_post() and
17 + * wp_delete_comment(), in bounded batches, never by raw DELETE. A raw
18 + * DELETE skipped every deletion hook, left postmeta and term
19 + * relationships behind, and held one unbounded lock on wp_posts (#449,
20 + * #237). Rows WordPress has no deletion API for — expired transients,
21 + * orphan meta, orphan term relationships — are still raw SQL.
19 22 *
23 + * All SQL comes from the hardcoded template registry in types(). The
24 + * only values interpolated are $wpdb->prefix, integers cast with %d (the
25 + * batch cursor and LIMIT), and the names of registered taxonomies passed
26 + * through esc_sql(). Never trusts user input directly in SQL.
27 + *
20 28 * @package XSpeed
21 29 */
22 30
23 31 declare(strict_types=1);
@@ -30,48 +38,91 @@
30 38
31 39 public const CRON_HOOK = 'xspeed_database_cleanup';
32 40
33 41 /**
34 - * Cleanup types. Each entry:
35 - * label — human description
36 - * scan_sql_tpl — SELECT for counting candidates (sprintf
37 - * placeholder for table prefix)
38 - * clean_sql_tpl — DELETE for the same set
42 + * Where the scheduled clean stopped when its time budget ran out.
39 43 *
44 + * Shape: { types_hash, type, after_id }. Only cron_tick() reads or writes
45 + * it. A manual, REST, CLI or MCP clean never does: those delete what the
46 + * caller just saw, in the same request, and must not leave work behind
47 + * for a later tick to finish. An MCP clean in particular is authorised by
48 + * a confirm_token bound to one scan, and a resumed run on a later tick
49 + * would delete rows that became eligible after that confirmation.
50 + */
51 + public const CURSOR_OPTION = 'xspeed_db_clean_cursor';
52 +
53 + /** Rows fetched per SELECT for the types deleted one at a time. */
54 + private const BATCH = 100;
55 +
56 + /** Seconds an interactive clean (REST, CLI, MCP) may spend deleting. */
57 + private const BUDGET_INTERACTIVE = 20;
58 +
59 + /** Seconds one scheduled tick may spend deleting. */
60 + private const BUDGET_CRON = 10;
61 +
62 + /**
63 + * Cleanup types, in the order clean() runs them. Each entry:
64 + * label — human description
65 + * scan_sql_tpl — SELECT COUNT(*) of candidates. %1$s is the table
66 + * prefix, %2$s the post-taxonomy IN list.
67 + * select_ids_tpl — for types deleted through the WordPress API: one
68 + * page of candidate ids. %2$d is the id to start
69 + * after, %3$d the page size.
70 + * delete — 'post' or 'comment': which API deletes an id.
71 + * clean_sql_tpl — for raw types: the DELETE for the same set.
72 + *
73 + * The slugs are stored in users' `included_types` setting. Renaming one
74 + * strands that stored value: clean() skips unknown slugs, so a renamed
75 + * slug would turn a scheduled clean into a silent no-op. Add, never
76 + * rename.
77 + *
78 + * Order matters. The orphan sweeps run after the post and comment
79 + * deletes, and the resume cursor walks this list.
80 + *
40 81 * Auto-drafts use a 7-day grace window so in-progress drafts aren't
41 82 * destroyed mid-edit. Expired transients use the option_value
42 83 * column for both `_transient_timeout_*` and `_site_transient_timeout_*`.
43 84 *
44 - * @return array<string,array{label:string,scan_sql_tpl:string,clean_sql_tpl:string}>
85 + * @return array<string,array<string,string>>
45 86 */
46 87 private static function types(): array {
47 88 return array(
48 - 'post_revisions' => array(
49 - 'label' => 'Post Revisions',
50 - 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$sposts WHERE post_type = 'revision'",
51 - 'clean_sql_tpl' => "DELETE FROM %1\$sposts WHERE post_type = 'revision'",
89 + 'post_revisions' => array(
90 + 'label' => 'Post Revisions',
91 + 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$sposts WHERE post_type = 'revision'",
92 + 'select_ids_tpl' => "SELECT ID FROM %1\$sposts WHERE post_type = 'revision' AND ID > %2\$d ORDER BY ID ASC LIMIT %3\$d",
93 + 'delete' => 'post',
52 94 ),
53 - 'auto_drafts' => array(
54 - 'label' => 'Auto-Drafts (older than 7 days)',
55 - 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$sposts WHERE post_status = 'auto-draft' AND post_modified < DATE_SUB( NOW(), INTERVAL 7 DAY )",
56 - 'clean_sql_tpl' => "DELETE FROM %1\$sposts WHERE post_status = 'auto-draft' AND post_modified < DATE_SUB( NOW(), INTERVAL 7 DAY )",
95 + 'auto_drafts' => array(
96 + 'label' => 'Auto-Drafts (older than 7 days)',
97 + 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$sposts WHERE post_status = 'auto-draft' AND post_modified < DATE_SUB( NOW(), INTERVAL 7 DAY )",
98 + 'select_ids_tpl' => "SELECT ID FROM %1\$sposts WHERE post_status = 'auto-draft' AND post_modified < DATE_SUB( NOW(), INTERVAL 7 DAY ) AND ID > %2\$d ORDER BY ID ASC LIMIT %3\$d",
99 + 'delete' => 'post',
57 100 ),
58 - 'trashed_posts' => array(
59 - 'label' => 'Trashed Posts',
60 - 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$sposts WHERE post_status = 'trash'",
61 - 'clean_sql_tpl' => "DELETE FROM %1\$sposts WHERE post_status = 'trash'",
101 + // A trashed ATTACHMENT (only possible with MEDIA_TRASH on) loses
102 + // its files from uploads/ here, because wp_delete_post() hands
103 + // attachments to wp_delete_attachment(). That is what core's own
104 + // "Empty Trash" does with the same row. The raw DELETE this
105 + // replaced removed the row and stranded the files.
106 + 'trashed_posts' => array(
107 + 'label' => 'Trashed Posts',
108 + 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$sposts WHERE post_status = 'trash'",
109 + 'select_ids_tpl' => "SELECT ID FROM %1\$sposts WHERE post_status = 'trash' AND ID > %2\$d ORDER BY ID ASC LIMIT %3\$d",
110 + 'delete' => 'post',
62 111 ),
63 - 'spam_comments' => array(
64 - 'label' => 'Spam Comments',
65 - 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$scomments WHERE comment_approved = 'spam'",
66 - 'clean_sql_tpl' => "DELETE FROM %1\$scomments WHERE comment_approved = 'spam'",
112 + 'spam_comments' => array(
113 + 'label' => 'Spam Comments',
114 + 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$scomments WHERE comment_approved = 'spam'",
115 + 'select_ids_tpl' => "SELECT comment_ID FROM %1\$scomments WHERE comment_approved = 'spam' AND comment_ID > %2\$d ORDER BY comment_ID ASC LIMIT %3\$d",
116 + 'delete' => 'comment',
67 117 ),
68 - 'trashed_comments' => array(
69 - 'label' => 'Trashed Comments',
70 - 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$scomments WHERE comment_approved = 'trash'",
71 - 'clean_sql_tpl' => "DELETE FROM %1\$scomments WHERE comment_approved = 'trash'",
118 + 'trashed_comments' => array(
119 + 'label' => 'Trashed Comments',
120 + 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$scomments WHERE comment_approved = 'trash'",
121 + 'select_ids_tpl' => "SELECT comment_ID FROM %1\$scomments WHERE comment_approved = 'trash' AND comment_ID > %2\$d ORDER BY comment_ID ASC LIMIT %3\$d",
122 + 'delete' => 'comment',
72 123 ),
73 - 'expired_transients' => array(
124 + 'expired_transients' => array(
74 125 'label' => 'Expired Transients',
75 126 // Scan counts one row per EXPIRED transient — the timeout
76 127 // row — across BOTH the normal (_transient_timeout_*) and the
77 128 // site/network (_site_transient_timeout_*) families. The
@@ -86,18 +137,29 @@
86 137 // "clean" never drove the scan count to zero. REPLACE maps
87 138 // both families to their value-key name. (FBS-82149 Bug 2.)
88 139 'clean_sql_tpl' => "DELETE a, b FROM %1\$soptions a LEFT JOIN %1\$soptions b ON b.option_name = REPLACE( REPLACE( a.option_name, '_site_transient_timeout_', '_site_transient_' ), '_transient_timeout_', '_transient_' ) WHERE ( a.option_name LIKE '_transient_timeout_%%' OR a.option_name LIKE '_site_transient_timeout_%%' ) AND CAST( a.option_value AS UNSIGNED ) < UNIX_TIMESTAMP()",
89 140 ),
90 - 'orphan_postmeta' => array(
141 + 'orphan_postmeta' => array(
91 142 'label' => 'Orphan Post Meta',
92 143 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$spostmeta pm LEFT JOIN %1\$sposts p ON p.ID = pm.post_id WHERE p.ID IS NULL",
93 144 'clean_sql_tpl' => "DELETE pm FROM %1\$spostmeta pm LEFT JOIN %1\$sposts p ON p.ID = pm.post_id WHERE p.ID IS NULL",
94 145 ),
95 - 'orphan_commentmeta' => array(
146 + 'orphan_commentmeta' => array(
96 147 'label' => 'Orphan Comment Meta',
97 148 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$scommentmeta cm LEFT JOIN %1\$scomments c ON c.comment_ID = cm.comment_id WHERE c.comment_ID IS NULL",
98 149 'clean_sql_tpl' => "DELETE cm FROM %1\$scommentmeta cm LEFT JOIN %1\$scomments c ON c.comment_ID = cm.comment_id WHERE c.comment_ID IS NULL",
99 150 ),
151 + // A term relationship's object_id is a post id only when the
152 + // taxonomy belongs to post types. link_category points at
153 + // wp_links, and plugins attach taxonomies to users (BuddyPress
154 + // member types, for one), so "no such post" is not "orphan" for
155 + // those. The sweep is limited to taxonomies registered for post
156 + // types only; an unregistered taxonomy is left alone.
157 + 'orphan_term_relationships' => array(
158 + 'label' => 'Orphan Term Relationships',
159 + 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$sterm_relationships tr INNER JOIN %1\$sterm_taxonomy tt ON tt.term_taxonomy_id = tr.term_taxonomy_id LEFT JOIN %1\$sposts p ON p.ID = tr.object_id WHERE p.ID IS NULL AND tt.taxonomy IN ( %2\$s )",
160 + 'clean_sql_tpl' => "DELETE tr FROM %1\$sterm_relationships tr INNER JOIN %1\$sterm_taxonomy tt ON tt.term_taxonomy_id = tr.term_taxonomy_id LEFT JOIN %1\$sposts p ON p.ID = tr.object_id WHERE p.ID IS NULL AND tt.taxonomy IN ( %2\$s )",
161 + ),
100 162 );
101 163 }
102 164
103 165 /**
@@ -126,53 +188,106 @@
126 188 * @param array{scan_sql_tpl:string} $spec
127 189 * @param \wpdb $wpdb
128 190 */
129 191 private static function count_for( array $spec, $wpdb ): int {
130 - $sql = sprintf( $spec['scan_sql_tpl'], $wpdb->prefix );
131 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL is built from a hardcoded template registry (types()); only $wpdb->prefix is interpolated. Read-only count; not cached so the user always sees current row counts.
192 + $sql = self::fill( $spec['scan_sql_tpl'], $wpdb );
193 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL is built from a hardcoded template registry (types()); only $wpdb->prefix and esc_sql()'d registered taxonomy names are interpolated. Read-only count; not cached so the user always sees current row counts.
132 194 return (int) $wpdb->get_var( $sql );
133 195 }
134 196
135 197 /**
136 198 * Destructive cleanup for the supplied list of type slugs. Anything
137 - * not in the static types() table is silently skipped — never run
138 - * arbitrary SQL because someone POSTed an unexpected slug.
199 + * not in the static types() table is skipped and named in `skipped` —
200 + * never run arbitrary SQL because someone POSTed an unexpected slug.
139 201 *
140 - * Returns the new scan() shape so the UI can refresh in one
141 - * round trip, plus a per-type `affected` count of rows removed.
202 + * Types run in registry order, whatever order the caller listed them.
203 + * Posts and comments go one id at a time through the WordPress API, a
204 + * page of ids per SELECT, until nothing matches or the time budget is
205 + * spent. At least one id is always attempted, so a zero budget still
206 + * makes progress.
142 207 *
143 - * @param string[] $types
208 + * Options:
209 + * batch ids per SELECT (default 100)
210 + * budget_seconds time allowed (default 20, or 10 with resume)
211 + * resume start from, and save to, the scheduled-clean cursor.
212 + * Only cron_tick() passes this.
213 + *
214 + * Returns:
215 + * cleaned per type, items removed
216 + * remaining per requested type, what still matches afterwards
217 + * partial true when the budget ran out before every type finished
218 + * skipped requested slugs that are not cleanup types
219 + * scan the full scan() after the clean, so the UI can refresh
220 + *
221 + * @param string[] $types
222 + * @param \wpdb|null $wpdb_in Injectable for tests; defaults to global.
223 + * @param array $opts
144 224 */
145 - public static function clean( array $types, $wpdb_in = null ): array {
225 + public static function clean( array $types, $wpdb_in = null, array $opts = array() ): array {
146 226 $wpdb = self::wpdb( $wpdb_in );
147 227 $registry = self::types();
228 + $resume = ! empty( $opts['resume'] );
229 + $batch = max( 1, (int) ( $opts['batch'] ?? self::BATCH ) );
230 + $budget = (float) ( $opts['budget_seconds'] ?? ( $resume ? self::BUDGET_CRON : self::BUDGET_INTERACTIVE ) );
231 + $deadline = microtime( true ) + max( 0.0, $budget );
232 +
233 + $requested = array_values( array_unique( array_map( 'strval', array_filter( $types, 'is_scalar' ) ) ) );
234 + $keys = array_values( array_intersect( array_keys( $registry ), $requested ) );
235 + $skipped = array_values( array_diff( $requested, $keys ) );
236 +
237 + $start = 0;
238 + $after = 0;
239 + $hash = self::types_hash( $keys );
240 + if ( $resume ) {
241 + list( $start, $after ) = self::read_cursor( $hash, $keys );
242 + }
243 +
148 244 $results = array();
149 245 $total = 0;
246 + $partial = false;
247 + $stop_at = null;
248 + $progress = false;
150 249
151 - foreach ( $types as $key ) {
152 - if ( ! isset( $registry[ $key ] ) ) {
153 - continue;
250 + for ( $i = $start, $n = count( $keys ); $i < $n; $i++ ) {
251 + $key = $keys[ $i ];
252 + $spec = $registry[ $key ];
253 +
254 + if ( $progress && microtime( true ) >= $deadline ) {
255 + $partial = true;
256 + $stop_at = array( $key, 0 );
257 + break;
154 258 }
155 - // Report `affected` in the SAME unit the scan counts + the UI
156 - // button shows: the reduction in the scan count (logical items),
157 - // not raw rows deleted. Without this the paired transient DELETE
158 - // (timeout + value = 2 rows per transient) reported "2" while the
159 - // button said "(1)". Measure before/after so every type — paired
160 - // or single-row — reports a consistent, user-meaningful count.
161 - // (FBS-82149 Bug 3.)
162 - $before = self::count_for( $registry[ $key ], $wpdb );
163 259
164 - $sql = sprintf( $registry[ $key ]['clean_sql_tpl'], $wpdb->prefix );
165 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL built from a hardcoded template registry (types()); only $wpdb->prefix is interpolated. Destructive cleanup explicitly bypasses the object cache.
166 - $wpdb->query( $sql );
260 + if ( isset( $spec['select_ids_tpl'] ) ) {
261 + list( $removed, $stopped_after ) = self::delete_batched( $spec, $wpdb, $after, $batch, $deadline, $progress );
262 + } else {
263 + $removed = self::delete_raw( $spec, $wpdb );
264 + $stopped_after = null;
265 + $progress = true;
266 + }
167 267
168 - $after = self::count_for( $registry[ $key ], $wpdb );
169 - $affected = max( 0, $before - $after );
268 + $results[ $key ] = $removed;
269 + $total += $removed;
170 270
171 - $results[ $key ] = $affected;
172 - $total += $affected;
271 + if ( null !== $stopped_after ) {
272 + $partial = true;
273 + $stop_at = array( $key, $stopped_after );
274 + break;
275 + }
276 + $after = 0;
173 277 }
174 278
279 + if ( $resume ) {
280 + if ( null !== $stop_at ) {
281 + self::write_cursor( $hash, $stop_at[0], $stop_at[1] );
282 + } else {
283 + // Last type finished: the cycle wraps, and the next tick
284 + // starts again from the first type and id 0. Rows that became
285 + // eligible behind the cursor are picked up then.
286 + self::reset_cursor();
287 + }
288 + }
289 +
175 290 if ( $total > 0 && class_exists( '\\XSpeed\\Activity_Log' ) ) {
176 291 Activity_Log::record(
177 292 'database_cleaned',
178 293 sprintf( 'Database cleanup removed %d row%s across %d table(s).', $total, 1 === $total ? '' : 's', count( $results ) ),
@@ -179,15 +294,181 @@
179 294 Activity_Log::INFO
180 295 );
181 296 }
182 297
298 + $scan = self::scan( $wpdb );
299 + $remaining = array();
300 + foreach ( $keys as $key ) {
301 + $remaining[ $key ] = (int) ( $scan[ $key ]['count'] ?? 0 );
302 + }
303 +
183 304 return array(
184 - 'cleaned' => $results,
185 - 'scan' => self::scan( $wpdb_in ),
305 + 'cleaned' => $results,
306 + 'remaining' => $remaining,
307 + 'partial' => $partial,
308 + 'skipped' => $skipped,
309 + 'scan' => $scan,
186 310 );
187 311 }
188 312
189 313 /**
314 + * Delete one type's matches through the WordPress API, a page at a time.
315 + *
316 + * wp_delete_post( $id, true ) fires before_delete_post / delete_post /
317 + * deleted_post, removes the post's meta, term relationships, comments and
318 + * revisions, and moves its attachments and same-type children up to its
319 + * parent. detach_children() then clears the parent of any other child. For a
320 + * trashed attachment it also deletes the files. wp_delete_comment( $id,
321 + * true ) does the same for a comment and its meta. Each fires every
322 + * other plugin's hooks too, which is the point, and is why the run is
323 + * bounded by a time budget rather than left to finish.
324 + *
325 + * xSpeed's own purge on before_delete_post already ignores revisions,
326 + * auto-drafts and trashed posts (Cache::post_change_is_cacheable_content),
327 + * so emptying these does not sweep the page cache per row.
328 + *
329 + * The cursor moves past every id attempted, deleted or not. A row a
330 + * plugin refuses to delete (pre_delete_post) is stepped over rather than
331 + * fetched forever.
332 + *
333 + * @param array $spec Registry entry with select_ids_tpl + delete.
334 + * @param \wpdb $wpdb
335 + * @param int $after Start after this id.
336 + * @param int $batch Ids per SELECT.
337 + * @param float $deadline microtime() at which to stop.
338 + * @param bool $progress Set once any id has been attempted.
339 + * @return array{0:int,1:int|null} Removed count, and the last id attempted
340 + * when the budget stopped the run (null
341 + * when the type finished).
342 + */
343 + private static function delete_batched( array $spec, $wpdb, int $after, int $batch, float $deadline, bool &$progress ): array {
344 + $removed = 0;
345 + while ( true ) {
346 + $sql = sprintf( $spec['select_ids_tpl'], $wpdb->prefix, $after, $batch );
347 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL built from a hardcoded template registry (types()); only $wpdb->prefix and two integers formatted with %d are interpolated. Candidate ids must be read live.
348 + $ids = $wpdb->get_col( $sql );
349 + if ( ! is_array( $ids ) || array() === $ids ) {
350 + return array( $removed, null );
351 + }
352 +
353 + foreach ( $ids as $id ) {
354 + $id = (int) $id;
355 + $after = $id;
356 + if ( 'comment' === $spec['delete'] ) {
357 + $ok = wp_delete_comment( $id, true );
358 + } else {
359 + $ok = wp_delete_post( $id, true );
360 + if ( $ok ) {
361 + self::detach_children( $id, $wpdb );
362 + }
363 + }
364 + if ( $ok ) {
365 + ++$removed;
366 + }
367 + $progress = true;
368 + if ( microtime( true ) >= $deadline ) {
369 + return array( $removed, $after );
370 + }
371 + }
372 +
373 + if ( count( $ids ) < $batch ) {
374 + return array( $removed, null );
375 + }
376 + }
377 + }
378 +
379 + /**
380 + * Point the children a deleted post left behind at no parent.
381 + *
382 + * wp_delete_post() moves the deleted post's attachments, and its children
383 + * of the same hierarchical post type, up to its own parent. Plugins that
384 + * own their children (WooCommerce variations) delete them on
385 + * before_delete_post. A child of any other post type keeps a post_parent
386 + * naming a post that no longer exists: a course's lessons, for example
387 + * (#237).
388 + *
389 + * Those are set to 0, core's "no parent". They are not deleted: they may
390 + * be published, and a scheduled cleaner must not remove live content. Not
391 + * moved to the grandparent either, which core does only within one post
392 + * type. For a lesson it would name a course the lesson was never under.
393 + *
394 + * @param int $id The deleted post.
395 + * @param \wpdb $wpdb
396 + * @return int Children detached.
397 + */
398 + private static function detach_children( int $id, $wpdb ): int {
399 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the children of a post deleted a moment ago; nothing caches that.
400 + $children = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_parent = %d", $id ) );
401 + if ( ! is_array( $children ) || array() === $children ) {
402 + return 0;
403 + }
404 +
405 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the same column core's wp_delete_post() rewrites for same-type children; caches are cleared below.
406 + $wpdb->update( $wpdb->posts, array( 'post_parent' => 0 ), array( 'post_parent' => $id ) );
407 + foreach ( $children as $child ) {
408 + clean_post_cache( (int) $child );
409 + }
410 +
411 + return count( $children );
412 + }
413 +
414 + /**
415 + * Run a raw type's DELETE and report the drop in its scan count, the unit
416 + * the UI shows. Without before/after, the paired transient DELETE
417 + * (timeout + value = 2 rows per transient) reported "2" while the button
418 + * said "(1)". (FBS-82149 Bug 3.)
419 + *
420 + * @param array $spec Registry entry with scan_sql_tpl + clean_sql_tpl.
421 + * @param \wpdb $wpdb
422 + */
423 + private static function delete_raw( array $spec, $wpdb ): int {
424 + $before = self::count_for( $spec, $wpdb );
425 + if ( 0 === $before ) {
426 + return 0;
427 + }
428 +
429 + $sql = self::fill( $spec['clean_sql_tpl'], $wpdb );
430 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL built from a hardcoded template registry (types()); only $wpdb->prefix and esc_sql()'d registered taxonomy names are interpolated. Destructive cleanup explicitly bypasses the object cache.
431 + $wpdb->query( $sql );
432 +
433 + return max( 0, $before - self::count_for( $spec, $wpdb ) );
434 + }
435 +
436 + /** Fill a count or raw-delete template: prefix, then the taxonomy list. */
437 + private static function fill( string $tpl, $wpdb ): string {
438 + if ( false === strpos( $tpl, '%2$s' ) ) {
439 + return sprintf( $tpl, $wpdb->prefix );
440 + }
441 + return sprintf( $tpl, $wpdb->prefix, self::post_taxonomy_list() );
442 + }
443 +
444 + /**
445 + * Quoted, escaped names of the taxonomies whose objects are all posts.
446 + *
447 + * A taxonomy qualifies only if every object type it is registered for is
448 + * a registered post type. When none do, the list is '' and the orphan
449 + * term relationship sweep matches nothing.
450 + */
451 + private static function post_taxonomy_list(): string {
452 + $names = array();
453 + if ( function_exists( 'get_taxonomies' ) && function_exists( 'post_type_exists' ) ) {
454 + foreach ( (array) get_taxonomies( array(), 'objects' ) as $name => $taxonomy ) {
455 + $object_types = is_object( $taxonomy ) && isset( $taxonomy->object_type ) ? (array) $taxonomy->object_type : array();
456 + if ( array() === $object_types ) {
457 + continue;
458 + }
459 + foreach ( $object_types as $object_type ) {
460 + if ( ! post_type_exists( (string) $object_type ) ) {
461 + continue 2;
462 + }
463 + }
464 + $names[] = "'" . esc_sql( (string) $name ) . "'";
465 + }
466 + }
467 + return array() === $names ? "''" : implode( ', ', $names );
468 + }
469 +
470 + /**
190 471 * OPTIMIZE TABLE on every WordPress core table (and any with the
191 472 * site's prefix). Returns the table names + per-table status.
192 473 */
193 474 public static function optimize_tables( $wpdb_in = null ): array {
@@ -228,10 +509,10 @@
228 509 }
229 510
230 511 /**
231 512 * Cron handler — pulls the configured included_types from settings
232 - * and runs clean() on them. Idempotent; if nothing's eligible, the
233 - * underlying DELETE runs but affects 0 rows.
513 + * and runs clean() on them within one tick's budget, resuming where the
514 + * previous tick stopped.
234 515 */
235 516 public static function cron_tick(): void {
236 517 $opts = Settings_Manager::get( 'database' );
237 518 $types = is_array( $opts['included_types'] ?? null ) ? $opts['included_types'] : array();
@@ -237,9 +518,63 @@
237 518 $types = is_array( $opts['included_types'] ?? null ) ? $opts['included_types'] : array();
238 519 if ( empty( $types ) ) {
239 520 return;
240 521 }
241 - self::clean( $types );
522 + self::clean( $types, null, array( 'resume' => true ) );
523 + }
524 +
525 + /**
526 + * Forget where the scheduled clean stopped. Called whenever the module's
527 + * settings are written, so a cursor never resumes into a category the
528 + * user has just turned off.
529 + */
530 + public static function reset_cursor(): void {
531 + delete_option( self::CURSOR_OPTION );
532 + }
533 +
534 + /** Identity of a type selection, so a cursor is only honoured for the set it was written for. */
535 + private static function types_hash( array $keys ): string {
536 + sort( $keys );
537 + return sha1( implode( ',', $keys ) );
538 + }
539 +
540 + /**
541 + * Where to resume: [ index into $keys, id to start after ].
542 + *
543 + * A cursor written for a different selection, or naming a type no longer
544 + * in it, is discarded and the run starts from the top.
545 + *
546 + * @param string $hash types_hash() of $keys.
547 + * @param string[] $keys Types this run will clean, in registry order.
548 + * @return array{0:int,1:int}
549 + */
550 + private static function read_cursor( string $hash, array $keys ): array {
551 + $stored = get_option( self::CURSOR_OPTION, array() );
552 + if ( ! is_array( $stored ) || array() === $stored ) {
553 + return array( 0, 0 );
554 + }
555 +
556 + $index = isset( $stored['type'] ) ? array_search( (string) $stored['type'], $keys, true ) : false;
557 + if ( ( $stored['types_hash'] ?? '' ) !== $hash || false === $index ) {
558 + self::reset_cursor();
559 + return array( 0, 0 );
560 + }
561 +
562 + return array( (int) $index, max( 0, (int) ( $stored['after_id'] ?? 0 ) ) );
563 + }
564 +
565 + private static function write_cursor( string $hash, string $type, int $after_id ): void {
566 + $value = array(
567 + 'types_hash' => $hash,
568 + 'type' => $type,
569 + 'after_id' => $after_id,
570 + );
571 + // autoload=no: read once per scheduled tick, never on a page view.
572 + if ( false === get_option( self::CURSOR_OPTION, false ) ) {
573 + add_option( self::CURSOR_OPTION, $value, '', 'no' );
574 + return;
575 + }
576 + update_option( self::CURSOR_OPTION, $value );
242 577 }
243 578
244 579 private static function wpdb( $injected ) {
245 580 if ( null !== $injected ) {