From 2b6114d1a05c462eb6db9e56468839174231d50f Mon Sep 17 00:00:00 2001 From: Simon Costea Date: Tue, 13 Oct 2026 07:25:53 +0300 Subject: [PATCH 1/2] Clean up the user meta of deleted users daily Older versions of "Delete subscribers" removed only the users row and the role row of each subscriber, so sites that used it still have the rest of those users' meta, including their names and session tokens. The daily cleanup task now also deletes the meta of users that no longer exist. It only deletes users that were already missing at its previous run, at least 12 hours earlier: a database import loads wp_usermeta before wp_users, so while one is running every user seems to be missing. The "Delete subscribers" button uses the same function and still deletes immediately. Its notice no longer reports success when deleting the subscribers failed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../includes/class-bcc-login-cleanup.php | 78 ++++++++++++++++++- .../includes/class-bcc-login-settings.php | 29 ++++--- 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/plugins/bcc-login/includes/class-bcc-login-cleanup.php b/plugins/bcc-login/includes/class-bcc-login-cleanup.php index 54dfe18..d1be4c2 100644 --- a/plugins/bcc-login/includes/class-bcc-login-cleanup.php +++ b/plugins/bcc-login/includes/class-bcc-login-cleanup.php @@ -1,13 +1,16 @@ get_col( + "SELECT DISTINCT m.user_id + FROM {$wpdb->usermeta} m + LEFT JOIN {$wpdb->users} u ON u.ID = m.user_id + WHERE u.ID IS NULL AND m.user_id > 0" + ); + + if ( $wpdb->last_error ) { + return false; + } + + $user_ids = array_map( 'intval', $user_ids ); + + if ( $only_missing_before ) { + $before = get_option( self::MISSING_USERS_OPTION ); + update_option( self::MISSING_USERS_OPTION, array( 'time' => time(), 'user_ids' => $user_ids ), false ); + + $missing_before = array(); + if ( isset( $before['time'], $before['user_ids'] ) && $before['time'] <= time() - 12 * HOUR_IN_SECONDS ) { + $missing_before = array_map( 'intval', (array) $before['user_ids'] ); + } + + $user_ids = array_intersect( $user_ids, $missing_before ); + } + + $deleted = 0; + foreach ( array_chunk( $user_ids, 500 ) as $chunk ) { + // Unlike one DELETE joining the whole table, this only locks these users' meta rows (and briefly the index + // gaps next to them and in wp_users) for one short statement per batch. The join checks again that each + // user is still missing. + $result = $wpdb->query( + "DELETE m + FROM {$wpdb->usermeta} m + LEFT JOIN {$wpdb->users} u ON u.ID = m.user_id + WHERE u.ID IS NULL AND m.user_id IN (" . implode( ',', $chunk ) . ")" + ); + + if ( false === $result ) { + return false; + } + + $deleted += $result; + + // With a persistent object cache, a deleted user could otherwise still be loaded from the cache. + foreach ( $chunk as $user_id ) { + wp_cache_delete( $user_id, 'users' ); + wp_cache_delete( $user_id, 'user_meta' ); + } + } + + return $deleted; + } + /** * Deletes the tokens and claims that the old OpenID Connect Generic plugin stored as user meta. * diff --git a/plugins/bcc-login/includes/class-bcc-login-settings.php b/plugins/bcc-login/includes/class-bcc-login-settings.php index 7baf398..aec0151 100644 --- a/plugins/bcc-login/includes/class-bcc-login-settings.php +++ b/plugins/bcc-login/includes/class-bcc-login-settings.php @@ -684,23 +684,26 @@ function delete_subscribers_handler() { meta_key = '{$wpdb->prefix}capabilities' AND meta_value LIKE 'a:1:{s:10:\"subscriber\";b:1;}'"; - $wpdb->query($sql); + $subscribers_deleted = $wpdb->query($sql); + + if ( false === $subscribers_deleted ) { + add_settings_error( + 'subscribers_deleted', + 'subscribers_not_deleted', + __( 'The subscribers could not be deleted.', 'bcc-login' ), + 'error' + ); + } // The join above only removes the capabilities meta row of each subscriber, // so remove all remaining meta (e.g. openid-connect-generic-*) of users that no longer exist. - // The users tables are shared by all sites of a multisite network, so these aren't prefixed per site. - $sql = "DELETE {$wpdb->usermeta} - FROM {$wpdb->usermeta} - LEFT JOIN {$wpdb->users} ON {$wpdb->usermeta}.user_id = {$wpdb->users}.ID - WHERE {$wpdb->users}.ID IS NULL"; - - $orphaned_meta_deleted = $wpdb->query($sql); + $orphaned_meta_deleted = BCC_Login_Cleanup::delete_orphaned_user_meta(); if ( false === $orphaned_meta_deleted ) { add_settings_error( 'subscribers_deleted', 'orphaned_user_meta_not_deleted', - __( 'The user meta of deleted users could not be deleted.', 'bcc-login' ), + __( 'Not all user meta of deleted users could be deleted.', 'bcc-login' ), 'error' ); } @@ -731,8 +734,12 @@ function delete_subscribers_handler() { // but skips the database when an object cache is in use, so force it here. delete_expired_transients( true ); - // Only report the counts of the steps that succeeded, the others have an error notice. - $messages = array( __( 'All subscribers were successfully deleted.' ) ); + // Only report the steps that succeeded, the others have an error notice. + $messages = array(); + + if ( false !== $subscribers_deleted ) { + $messages[] = __( 'All subscribers were successfully deleted.' ); + } if ( false !== $orphaned_meta_deleted ) { $messages[] = sprintf( __( 'Removed %d user meta rows of deleted users.', 'bcc-login' ), $orphaned_meta_deleted ); From 647f12e13b5a1502006b47180fc601e9974a1d0a Mon Sep 17 00:00:00 2001 From: Simon Costea Date: Tue, 13 Oct 2026 07:41:48 +0300 Subject: [PATCH 2/2] Address review: save check and cache of deleted subscribers - The daily cleanup only deletes once its list of missing users is saved, so the next run never compares against an older list. - "Delete subscribers" removes the deleted subscribers from the object cache directly. The leftover cleanup only finds users that still have meta, which isn't the case if the role was their only meta row. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../includes/class-bcc-login-cleanup.php | 29 +++++++++++++++---- .../includes/class-bcc-login-settings.php | 9 ++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/plugins/bcc-login/includes/class-bcc-login-cleanup.php b/plugins/bcc-login/includes/class-bcc-login-cleanup.php index d1be4c2..e876899 100644 --- a/plugins/bcc-login/includes/class-bcc-login-cleanup.php +++ b/plugins/bcc-login/includes/class-bcc-login-cleanup.php @@ -71,7 +71,14 @@ static function delete_orphaned_user_meta( $only_missing_before = false ) { if ( $only_missing_before ) { $before = get_option( self::MISSING_USERS_OPTION ); - update_option( self::MISSING_USERS_OPTION, array( 'time' => time(), 'user_ids' => $user_ids ), false ); + $now = array( 'time' => time(), 'user_ids' => $user_ids ); + update_option( self::MISSING_USERS_OPTION, $now, false ); + + // Only continue once this list is saved, so the next run doesn't compare against an older one. + // update_option() also returns false when the value didn't change, so read it back instead. + if ( get_option( self::MISSING_USERS_OPTION ) !== $now ) { + return false; + } $missing_before = array(); if ( isset( $before['time'], $before['user_ids'] ) && $before['time'] <= time() - 12 * HOUR_IN_SECONDS ) { @@ -99,16 +106,26 @@ static function delete_orphaned_user_meta( $only_missing_before = false ) { $deleted += $result; - // With a persistent object cache, a deleted user could otherwise still be loaded from the cache. - foreach ( $chunk as $user_id ) { - wp_cache_delete( $user_id, 'users' ); - wp_cache_delete( $user_id, 'user_meta' ); - } + self::clean_deleted_users_cache( $chunk ); } return $deleted; } + /** + * Removes deleted users from the object cache. With a persistent object cache, they could otherwise + * still be loaded from it. Clearing the 'users' group by id is enough for lookups by login, email or + * slug as well, as those only map to the id and then load the user by it. + * + * @param int[] $user_ids + */ + static function clean_deleted_users_cache( $user_ids ) { + foreach ( $user_ids as $user_id ) { + wp_cache_delete( $user_id, 'users' ); + wp_cache_delete( $user_id, 'user_meta' ); + } + } + /** * Deletes the tokens and claims that the old OpenID Connect Generic plugin stored as user meta. * diff --git a/plugins/bcc-login/includes/class-bcc-login-settings.php b/plugins/bcc-login/includes/class-bcc-login-settings.php index aec0151..9905483 100644 --- a/plugins/bcc-login/includes/class-bcc-login-settings.php +++ b/plugins/bcc-login/includes/class-bcc-login-settings.php @@ -677,6 +677,13 @@ function delete_subscribers_handler() { ) { global $wpdb; + // Remember who gets deleted, to remove them from the object cache afterwards. The cleanup below only + // finds users that still have meta left, which isn't the case if the role was their only meta. + $subscriber_ids = array_map( 'intval', $wpdb->get_col( + "SELECT user_id FROM {$wpdb->prefix}usermeta + WHERE meta_key = '{$wpdb->prefix}capabilities' AND meta_value LIKE 'a:1:{s:10:\"subscriber\";b:1;}'" + ) ); + $sql = "DELETE {$wpdb->prefix}users, {$wpdb->prefix}usermeta FROM {$wpdb->prefix}users INNER JOIN {$wpdb->prefix}usermeta ON {$wpdb->prefix}users.ID = {$wpdb->prefix}usermeta.user_id @@ -686,6 +693,8 @@ function delete_subscribers_handler() { $subscribers_deleted = $wpdb->query($sql); + BCC_Login_Cleanup::clean_deleted_users_cache( $subscriber_ids ); + if ( false === $subscribers_deleted ) { add_settings_error( 'subscribers_deleted',