24h old * (orphaned from interrupted edit sessions) * * Runs before any Entity Bridge migration so subsequent ratio measurements * reflect real data, not garbage. Pure DB layer — no Hook Bus / Entity Bridge * coupling so cleanup is safe even when post entity bridge is disabled. * * @package WP_Data_Optimizer * @since 2.9.0 */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Wp_postmeta garbage cleanup (v2.9.0 Phase 0). */ class TMDO_Postmeta_Cleaner { public const TARGET_TRANSIENTS = 'transients'; public const TARGET_WP_OLD_DATE = 'wp_old_date'; public const TARGET_EDIT_LOCKS = 'edit_locks'; public const TARGET_ALL = 'all'; public const VALID_TARGETS = array( self::TARGET_TRANSIENTS, self::TARGET_WP_OLD_DATE, self::TARGET_EDIT_LOCKS, self::TARGET_ALL, ); /** * Seconds after which an _edit_lock is considered stale. * WP refreshes locks on a 15-second heartbeat; 24h is intentionally * conservative to avoid disturbing any active edit session. */ private const EDIT_LOCK_STALE_THRESHOLD = 86400; /** * Count rows that would be cleaned for the given target. * * @param string $target One of TARGET_* constants. * @return array{transients:int, wp_old_date:int, edit_locks:int, total:int} * @throws InvalidArgumentException When $target is not a valid target. */ public static function count_garbage( string $target = self::TARGET_ALL ): array { self::assert_valid_target( $target ); $counts = array( 'transients' => 0, 'wp_old_date' => 0, 'edit_locks' => 0, 'total' => 0, ); if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) { $counts['transients'] = self::count_transients(); } if ( self::target_includes( $target, self::TARGET_WP_OLD_DATE ) ) { $counts['wp_old_date'] = self::count_wp_old_date(); } if ( self::target_includes( $target, self::TARGET_EDIT_LOCKS ) ) { $counts['edit_locks'] = self::count_stale_edit_locks(); } $counts['total'] = $counts['transients'] + $counts['wp_old_date'] + $counts['edit_locks']; return $counts; } /** * Delete garbage rows for the given target. * * @param string $target One of TARGET_* constants. * @return array{transients:int, wp_old_date:int, edit_locks:int, total:int} * @throws InvalidArgumentException When $target is not a valid target. */ public static function delete_garbage( string $target = self::TARGET_ALL ): array { self::assert_valid_target( $target ); $deleted = array( 'transients' => 0, 'wp_old_date' => 0, 'edit_locks' => 0, 'total' => 0, ); if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) { $deleted['transients'] = self::delete_transients(); } if ( self::target_includes( $target, self::TARGET_WP_OLD_DATE ) ) { $deleted['wp_old_date'] = self::delete_wp_old_date(); } if ( self::target_includes( $target, self::TARGET_EDIT_LOCKS ) ) { $deleted['edit_locks'] = self::delete_stale_edit_locks(); } $deleted['total'] = $deleted['transients'] + $deleted['wp_old_date'] + $deleted['edit_locks']; return $deleted; } /** * Whether $target selects $bucket (i.e. target=all or target=bucket). * * @param string $target Selected target. * @param string $bucket Bucket constant (TARGET_TRANSIENTS / WP_OLD_DATE / EDIT_LOCKS). * @return bool */ private static function target_includes( string $target, string $bucket ): bool { return self::TARGET_ALL === $target || $bucket === $target; } /** * Validate target parameter. * * @param string $target Target to validate. * @return void * @throws InvalidArgumentException When $target is not in VALID_TARGETS. */ private static function assert_valid_target( string $target ): void { if ( in_array( $target, self::VALID_TARGETS, true ) ) { return; } // Exception messages bubble up to PHP's error handler / WP_CLI; not user output. $msg = sprintf( 'Invalid target "%s". Valid: %s', $target, implode( ', ', self::VALID_TARGETS ) ); throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal cleanup class: $wpdb->postmeta is WP-managed and never user input. PreparedSQL.InterpolatedNotPrepared fires on multi-line $wpdb->prepare() literals where the only interpolation is the trusted table name; user-controlled values use placeholders. /** * Count rows matching transient meta_key patterns in wp_postmeta. * * @return int */ private static function count_transients(): int { global $wpdb; $table = $wpdb->postmeta; return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'" ); } /** * Delete rows matching transient meta_key patterns from wp_postmeta. * * @return int Affected row count. */ private static function delete_transients(): int { global $wpdb; $table = $wpdb->postmeta; return (int) $wpdb->query( "DELETE FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'" ); } /** * Count _wp_old_date rows in wp_postmeta. * * @return int */ private static function count_wp_old_date(): int { global $wpdb; $table = $wpdb->postmeta; return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE meta_key = '_wp_old_date'" ); } /** * Delete _wp_old_date rows from wp_postmeta. * * @return int Affected row count. */ private static function delete_wp_old_date(): int { global $wpdb; $table = $wpdb->postmeta; return (int) $wpdb->query( "DELETE FROM `{$table}` WHERE meta_key = '_wp_old_date'" ); } /** * Count stale _edit_lock rows (lock_ts older than 24h) in wp_postmeta. * * @return int */ private static function count_stale_edit_locks(): int { global $wpdb; $table = $wpdb->postmeta; $cutoff = time() - self::EDIT_LOCK_STALE_THRESHOLD; // _edit_lock format is ":"; SUBSTRING_INDEX extracts the timestamp. return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE meta_key = '_edit_lock' AND CAST(SUBSTRING_INDEX(meta_value, ':', 1) AS UNSIGNED) < %d", $cutoff ) ); } /** * Delete stale _edit_lock rows (lock_ts older than 24h) from wp_postmeta. * * @return int Affected row count. */ private static function delete_stale_edit_locks(): int { global $wpdb; $table = $wpdb->postmeta; $cutoff = time() - self::EDIT_LOCK_STALE_THRESHOLD; return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE meta_key = '_edit_lock' AND CAST(SUBSTRING_INDEX(meta_value, ':', 1) AS UNSIGNED) < %d", $cutoff ) ); } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared }