From 1d36090260093a5d2401540dc428e4229183b3a9 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 15 May 2026 04:40:44 +0000 Subject: [PATCH 1/2] Privacy: Schedule export-file cleanup on demand instead of hourly. The wp_privacy_delete_old_export_files cron was scheduled hourly on every site via init. On multisite this means an hourly cron event for every site on the network, regardless of whether any export has ever been requested. Schedule a single one-off cleanup when an export file is generated, set to run shortly after the file's configured expiration. If non-expired files remain after a cleanup pass, reschedule another one-off event for the earliest remaining expiration. The legacy recurring event is unscheduled on init for sites upgrading from an earlier version. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wp-admin/includes/privacy-tools.php | 3 + src/wp-includes/functions.php | 78 +++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/wp-admin/includes/privacy-tools.php b/src/wp-admin/includes/privacy-tools.php index 4f3379bff8909..8a24dc3d74fbc 100644 --- a/src/wp-admin/includes/privacy-tools.php +++ b/src/wp-admin/includes/privacy-tools.php @@ -548,6 +548,9 @@ function wp_privacy_generate_personal_data_export_file( $request_id ) { $zip->close(); if ( ! $error ) { + // Schedule the (one-off) cleanup of this export file. + wp_schedule_delete_personal_data_export_file(); + /** * Fires right after all personal data has been written to the export file. * diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 7d71c8c56963d..21677efa49649 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -8457,20 +8457,73 @@ function wp_privacy_exports_url() { } /** - * Schedules a `WP_Cron` job to delete expired export files. + * Unschedules the legacy hourly `WP_Cron` job that deleted expired export files. + * + * Personal data exports are an infrequent, admin-initiated action. Running an + * hourly cleanup on every site (including every site of a multisite network) + * generates a constant stream of cron events for sites that have never + * produced an export file. Cleanup is now scheduled on demand from + * {@see wp_privacy_generate_personal_data_export_file()}, so this function + * only needs to clear any pre-existing recurring schedule. * * @since 4.9.6 + * @since 7.1.0 No longer schedules a recurring hourly event. Existing recurring + * events are unscheduled; cleanup is now scheduled when an export + * file is generated. */ function wp_schedule_delete_old_privacy_export_files() { if ( wp_installing() ) { return; } - if ( ! wp_next_scheduled( 'wp_privacy_delete_old_export_files' ) ) { - wp_schedule_event( time(), 'hourly', 'wp_privacy_delete_old_export_files' ); + $next_scheduled = wp_next_scheduled( 'wp_privacy_delete_old_export_files' ); + + if ( ! $next_scheduled ) { + return; + } + + $schedule = wp_get_schedule( 'wp_privacy_delete_old_export_files' ); + + // Remove the legacy recurring event. On-demand single events are left alone. + if ( false !== $schedule ) { + wp_unschedule_event( $next_scheduled, 'wp_privacy_delete_old_export_files' ); } } +/** + * Schedules a one-off `WP_Cron` job to delete expired export files. + * + * Called after a personal data export file is generated so that the cleanup + * runs once, shortly after the file is due to expire, rather than hourly on + * every site. + * + * @since 7.1.0 + */ +function wp_schedule_delete_personal_data_export_file() { + if ( wp_installing() ) { + return; + } + + /** This filter is documented in wp-includes/functions.php */ + $expiration = apply_filters( 'wp_privacy_export_expiration', 3 * DAY_IN_SECONDS ); + + // Run shortly after the file is eligible for deletion. + $run_at = time() + (int) $expiration + MINUTE_IN_SECONDS; + + $next_scheduled = wp_next_scheduled( 'wp_privacy_delete_old_export_files' ); + + // Avoid stacking events; keep the earliest one that covers this file. + if ( $next_scheduled && $next_scheduled <= $run_at ) { + return; + } + + if ( $next_scheduled ) { + wp_unschedule_event( $next_scheduled, 'wp_privacy_delete_old_export_files' ); + } + + wp_schedule_single_event( $run_at, 'wp_privacy_delete_old_export_files' ); +} + /** * Cleans up export files older than three days old. * @@ -8481,6 +8534,7 @@ function wp_schedule_delete_old_privacy_export_files() { * layer of protection. * * @since 4.9.6 + * @since 7.1.0 Reschedules a follow-up one-off cleanup if any unexpired files remain. */ function wp_privacy_delete_old_export_files() { $exports_dir = wp_privacy_exports_dir(); @@ -8503,12 +8557,28 @@ function wp_privacy_delete_old_export_files() { */ $expiration = apply_filters( 'wp_privacy_export_expiration', 3 * DAY_IN_SECONDS ); + $now = time(); + $next_expiration_time = 0; + foreach ( (array) $export_files as $export_file ) { - $file_age_in_seconds = time() - filemtime( $export_file ); + $file_mtime = filemtime( $export_file ); + $file_age_in_seconds = $now - $file_mtime; if ( $expiration < $file_age_in_seconds ) { unlink( $export_file ); + continue; } + + // Track the earliest expiration of files that survived this pass. + $file_expires_at = $file_mtime + (int) $expiration; + if ( 0 === $next_expiration_time || $file_expires_at < $next_expiration_time ) { + $next_expiration_time = $file_expires_at; + } + } + + // If any non-expired files remain, schedule a follow-up cleanup for them. + if ( $next_expiration_time > $now && ! wp_next_scheduled( 'wp_privacy_delete_old_export_files' ) ) { + wp_schedule_single_event( $next_expiration_time + MINUTE_IN_SECONDS, 'wp_privacy_delete_old_export_files' ); } } From aa90a1bb9135593010067192b2021076174f09f9 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 15 May 2026 04:49:14 +0000 Subject: [PATCH 2/2] Privacy: Move legacy cron cleanup into the DB upgrade routine. Instead of running wp_schedule_delete_old_privacy_export_files() on every init to detect and remove the legacy recurring event, perform the unschedule once via a new upgrade_710() routine. Deprecate the original function and bump $wp_db_version. See #44370. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wp-admin/includes/upgrade.php | 35 +++++++++++++++++++++++++++++ src/wp-includes/default-filters.php | 1 - src/wp-includes/deprecated.php | 14 ++++++++++++ src/wp-includes/functions.php | 34 ---------------------------- src/wp-includes/version.php | 2 +- 5 files changed, 50 insertions(+), 36 deletions(-) diff --git a/src/wp-admin/includes/upgrade.php b/src/wp-admin/includes/upgrade.php index 914113bde00d0..ae095c3df8ee2 100644 --- a/src/wp-admin/includes/upgrade.php +++ b/src/wp-admin/includes/upgrade.php @@ -890,6 +890,10 @@ function upgrade_all() { upgrade_700(); } + if ( $wp_current_db_version < 61834 ) { + upgrade_710(); + } + maybe_disable_link_manager(); maybe_disable_automattic_widgets(); @@ -2510,6 +2514,37 @@ function upgrade_700() { } } +/** + * Executes changes made in WordPress 7.1.0. + * + * @ignore + * @since 7.1.0 + * + * @global int $wp_current_db_version The old (current) database version. + */ +function upgrade_710() { + global $wp_current_db_version; + + /* + * Unschedule the legacy hourly personal data export cleanup event. + * + * Cleanup is now scheduled as a one-off event when an export file is + * generated. See wp_schedule_delete_personal_data_export_file(). + */ + if ( $wp_current_db_version < 61834 ) { + $next_scheduled = wp_next_scheduled( 'wp_privacy_delete_old_export_files' ); + + if ( $next_scheduled ) { + $schedule = wp_get_schedule( 'wp_privacy_delete_old_export_files' ); + + // Only remove the recurring event; leave any on-demand single events alone. + if ( false !== $schedule ) { + wp_unschedule_event( $next_scheduled, 'wp_privacy_delete_old_export_files' ); + } + } + } +} + /** * Executes network-level upgrade routines. * diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 4b6d9de25fa11..a8a262cd30867 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -445,7 +445,6 @@ add_filter( 'wp_privacy_personal_data_exporters', 'wp_register_media_personal_data_exporter' ); add_filter( 'wp_privacy_personal_data_exporters', 'wp_register_user_personal_data_exporter', 1 ); add_filter( 'wp_privacy_personal_data_erasers', 'wp_register_comment_personal_data_eraser' ); -add_action( 'init', 'wp_schedule_delete_old_privacy_export_files' ); add_action( 'wp_privacy_delete_old_export_files', 'wp_privacy_delete_old_export_files' ); // Cron tasks. diff --git a/src/wp-includes/deprecated.php b/src/wp-includes/deprecated.php index 14f5c24aec914..b8486819a268f 100644 --- a/src/wp-includes/deprecated.php +++ b/src/wp-includes/deprecated.php @@ -6533,3 +6533,17 @@ function wp_sanitize_script_attributes( $attributes ) { } return $attributes_string; } + +/** + * Schedules a `WP_Cron` job to delete expired export files. + * + * This function is deprecated. Cleanup is now scheduled as a one-off event when + * a personal data export file is generated, via + * {@see wp_schedule_delete_personal_data_export_file()}. + * + * @since 4.9.6 + * @deprecated 7.1.0 + */ +function wp_schedule_delete_old_privacy_export_files() { + _deprecated_function( __FUNCTION__, '7.1.0', 'wp_schedule_delete_personal_data_export_file()' ); +} diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 21677efa49649..a9c48ce95ebc5 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -8456,40 +8456,6 @@ function wp_privacy_exports_url() { return apply_filters( 'wp_privacy_exports_url', $exports_url ); } -/** - * Unschedules the legacy hourly `WP_Cron` job that deleted expired export files. - * - * Personal data exports are an infrequent, admin-initiated action. Running an - * hourly cleanup on every site (including every site of a multisite network) - * generates a constant stream of cron events for sites that have never - * produced an export file. Cleanup is now scheduled on demand from - * {@see wp_privacy_generate_personal_data_export_file()}, so this function - * only needs to clear any pre-existing recurring schedule. - * - * @since 4.9.6 - * @since 7.1.0 No longer schedules a recurring hourly event. Existing recurring - * events are unscheduled; cleanup is now scheduled when an export - * file is generated. - */ -function wp_schedule_delete_old_privacy_export_files() { - if ( wp_installing() ) { - return; - } - - $next_scheduled = wp_next_scheduled( 'wp_privacy_delete_old_export_files' ); - - if ( ! $next_scheduled ) { - return; - } - - $schedule = wp_get_schedule( 'wp_privacy_delete_old_export_files' ); - - // Remove the legacy recurring event. On-demand single events are left alone. - if ( false !== $schedule ) { - wp_unschedule_event( $next_scheduled, 'wp_privacy_delete_old_export_files' ); - } -} - /** * Schedules a one-off `WP_Cron` job to delete expired export files. * diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 934e5d0bb5369..364e97f70ce2a 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -23,7 +23,7 @@ * * @global int $wp_db_version */ -$wp_db_version = 61833; +$wp_db_version = 61834; /** * Holds the TinyMCE version.