Skip to content

Commit

Permalink
Merge pull request #1543 from xwp/fix/1503-reset-large-tables
Browse files Browse the repository at this point in the history
Use scheduled action for resetting large record and meta tables
  • Loading branch information
tharsheblows authored Aug 7, 2024
2 parents a0bcd4f + 7a83911 commit 0318c8a
Show file tree
Hide file tree
Showing 8 changed files with 494 additions and 25 deletions.
214 changes: 199 additions & 15 deletions classes/class-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@
*/
class Admin {

/**
* The async deletion action for large sites.
*
* @const string
*/
const ASYNC_DELETION_ACTION = 'stream_erase_large_records_action';

/**
* Holds Instance of plugin object
*
Expand Down Expand Up @@ -142,7 +149,7 @@ public function __construct( $plugin ) {
add_filter( 'user_has_cap', array( $this, 'filter_user_caps' ), 10, 4 );
add_filter( 'role_has_cap', array( $this, 'filter_role_caps' ), 10, 3 );

if ( is_multisite() && $plugin->is_network_activated() && ! is_network_admin() ) {
if ( $this->plugin->is_multisite_network_activated() && ! is_network_admin() ) {
$options = (array) get_site_option( 'wp_stream_network', array() );
$option = isset( $options['general_site_access'] ) ? absint( $options['general_site_access'] ) : 1;

Expand Down Expand Up @@ -212,6 +219,17 @@ public function __construct( $plugin ) {
'ajax_filters',
)
);

// Async action for erasing large log tables.
add_action(
self::ASYNC_DELETION_ACTION,
array(
$this,
'erase_large_records',
),
10,
4
);
}

/**
Expand Down Expand Up @@ -623,19 +641,171 @@ public function wp_ajax_reset() {
private function erase_stream_records() {
global $wpdb;

$where = '';
// If this is a multisite and it's not network activated,
// only delete the entries from the blog which made the request.
if ( $this->plugin->is_multisite_not_network_activated() ) {

if ( is_multisite() && ! $this->plugin->is_network_activated() ) {
$where .= $wpdb->prepare( ' AND `blog_id` = %d', get_current_blog_id() );
// First check the log size.
$stream_log_size = self::get_blog_record_table_size();

// If this is a large log and we need to delete only the entries
// pertaining to an individual site, we will need to do those in batches.
if ( $this->plugin->is_large_records_table( $stream_log_size ) ) {
$this->schedule_erase_large_records( $stream_log_size );
return;
}

$wpdb->query(
$wpdb->prepare(
"DELETE `stream`, `meta`
FROM {$wpdb->stream} AS `stream`
LEFT JOIN {$wpdb->streammeta} AS `meta`
ON `meta`.`record_id` = `stream`.`ID`
WHERE `blog_id`=%d;",
get_current_blog_id()
)
);
} else {
// If we are deleting all the entries, we can truncate the tables.
$wpdb->query( "TRUNCATE {$wpdb->streammeta};" );
$wpdb->query( "TRUNCATE {$wpdb->stream};" );
// Tidy up any meta which may have been added in between the two truncations.
$this->delete_orphaned_meta();
}
}

/**
* Schedule the initial event to start erasing the logs from now.
*
* @param int $log_size The number of rows which will be affected.
* @return void
*/
private function schedule_erase_large_records( int $log_size ) {
global $wpdb;

$last_entry = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->stream} WHERE `blog_id`=%d ORDER BY ID DESC LIMIT 1",
get_current_blog_id()
)
);

// If there are no entries to erase, don't try to erase them.
if ( empty( $last_entry ) ) {
return;
}

// We are going to delete this many and this many only.
// This is to avoid the situation where rows keep getting added
// between the Action Scheduler runs and they never stop.
$args = array(
'total' => (int) $log_size,
'done' => 0,
'last_entry' => (int) $last_entry,
'blog_id' => (int) get_current_blog_id(),
);

as_enqueue_async_action( self::ASYNC_DELETION_ACTION, $args );
}

/**
* Checks if the async deletion process is running.
*
* @return bool True if the async deletion process is running, false otherwise.
*/
public static function is_running_async_deletion() {
return as_has_scheduled_action( self::ASYNC_DELETION_ACTION );
}

/**
* Erases large records from the stream table.
*
* This function deletes records from the stream table in batches, starting from a given entry ID.
* It deletes records in reverse chronological order, starting from the largest ID and going back.
* The number of records deleted in each batch is determined by the batch size, which can be filtered
* using the 'wp_stream_batch_size' hook.
*
* @param int $total The total number of records to be deleted.
* @param int $done The number of records that have already been deleted.
* @param int $last_entry The ID of the last entry that was deleted.
* @param int $blog_id The ID of the blog for which the records should be deleted.
* @return void
*/
public function erase_large_records( int $total, int $done, int $last_entry, int $blog_id ) {
global $wpdb;

$start_from = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->stream} WHERE ID < %d AND `blog_id`=%d ORDER BY ID DESC LIMIT 1",
$last_entry + 1, // A tweak to get it correct the first time through.
get_current_blog_id()
)
);

if ( empty( $start_from ) ) {
return;
}

/**
* Filters the number of records in the {$wpdb->stream} table to do at a time.
*
* @since 4.1.0
*
* @param int $batch_size The batch size, default 250000.
*/
$batch_size = apply_filters( 'wp_stream_batch_size', 250000 );

// This will tend to erase them in reverse chronological order,
// ie it will start from the largest ID and go back from there.
$wpdb->query(
"DELETE `stream`, `meta`
FROM {$wpdb->stream} AS `stream`
LEFT JOIN {$wpdb->streammeta} AS `meta`
ON `meta`.`record_id` = `stream`.`ID`
WHERE 1=1 {$where};", // @codingStandardsIgnoreLine $where already prepared
$wpdb->prepare(
"DELETE `stream`, `meta`
FROM {$wpdb->stream} AS `stream`
LEFT JOIN {$wpdb->streammeta} AS `meta`
ON `meta`.`record_id` = `stream`.`ID`
WHERE ID <= %d AND ID >= %d AND `blog_id`=%d;",
$start_from,
$start_from - $batch_size,
get_current_blog_id()
)
);

$remaining = $wpdb->get_var(
$wpdb->prepare( "SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id`=%d", $blog_id )
);

$done = $total - $remaining;

as_enqueue_async_action(
self::ASYNC_DELETION_ACTION,
array(
'total' => (int) $total,
'done' => (int) $done,
'last_entry' => (int) $start_from - $batch_size, // The last ID checked.
'blog_id' => (int) $blog_id,
)
);
}

/**
* Retrieves the size of the blog record table for a specific blog.
*
* @param int|null $blog_id The ID of the blog. If not provided, the current blog ID will be used.
* @return int The size of the blog record table.
*/
public static function get_blog_record_table_size( $blog_id = null ): int {
global $wpdb;

$blog_id = empty( $blog_id ) ? get_current_blog_id() : $blog_id;

$blog_size = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(ID) FROM {$wpdb->stream} WHERE `blog_id`=%d",
$blog_id
)
);

return (int) $blog_size;
}

/**
Expand All @@ -649,6 +819,22 @@ public function purge_schedule_setup() {
}
}

/**
* Deletes orphaned meta records from the database.
*
* Deletes meta records from the stream meta table where the corresponding
* stream record no longer exists.
*
* @global wpdb $wpdb The WordPress database object.
*/
private function delete_orphaned_meta() {
global $wpdb;

$wpdb->query(
"DELETE `meta` FROM {$wpdb->streammeta} as `meta` LEFT JOIN {$wpdb->stream} as `stream` ON `stream`.`ID`=`meta`.`record_id` WHERE `stream`.`ID` IS NULL"
);
}

/**
* Executes a scheduled purge
*
Expand All @@ -659,17 +845,15 @@ public function purge_scheduled_action() {

// Don't purge when in Network Admin unless Stream is network activated.
if (
is_multisite()
$this->plugin->is_multisite_not_network_activated()
&&
is_network_admin()
&&
! $this->plugin->is_network_activated()
) {
return;
}

$defaults = $this->plugin->settings->get_defaults();
if ( is_multisite() && $this->plugin->is_network_activated() ) {
if ( $this->plugin->is_multisite_network_activated() ) {
$options = (array) get_site_option( 'wp_stream_network', $defaults );
} else {
$options = (array) get_option( 'wp_stream', $defaults );
Expand All @@ -688,7 +872,7 @@ public function purge_scheduled_action() {
$where = $wpdb->prepare( ' AND `stream`.`created` < %s', $date->format( 'Y-m-d H:i:s' ) );

// Multisite but NOT network activated, only purge the current blog.
if ( is_multisite() && ! $this->plugin->is_network_activated() ) {
if ( $this->plugin->is_multisite_not_network_activated() ) {
$where .= $wpdb->prepare( ' AND `blog_id` = %d', get_current_blog_id() );
}

Expand Down Expand Up @@ -717,7 +901,7 @@ public function plugin_action_links( $links, $file ) {
}

// Also don't show links in Network Admin if Stream isn't network enabled.
if ( is_network_admin() && is_multisite() && ! $this->plugin->is_network_activated() ) {
if ( is_network_admin() && $this->plugin->is_multisite_not_network_activated() ) {
return $links;
}

Expand Down
88 changes: 88 additions & 0 deletions classes/class-plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ class Plugin {
*/
const WP_CLI_COMMAND = 'stream';


/**
* Used to check if it's a single site, not multisite.
*
* @const string
*/
const SINGLE_SITE = 'single';

/**
* Used to check if it's a multisite with the plugin network enabled.
*
* @const string
*/
const MULTI_NETWORK = 'multisite-network';

/**
* Used to check if it's a multisite with the plugin not network enabled.
*
* @const string
*/
const MULTI_NOT_NETWORK = 'multisite-not-network';

/**
* Holds and manages WordPress Admin configurations.
*
Expand Down Expand Up @@ -115,6 +137,9 @@ public function __construct() {

spl_autoload_register( array( $this, 'autoload' ) );

// Load Action Scheduler.
require_once $this->locations['dir'] . '/vendor/woocommerce/action-scheduler/action-scheduler.php';

// Load helper functions.
require_once $this->locations['inc_dir'] . 'functions.php';

Expand Down Expand Up @@ -336,6 +361,69 @@ public function get_client_ip_address() {
return apply_filters( 'wp_stream_client_ip_address', $this->client_ip_address );
}

/**
* Get the site type.
*
* This function determines the type of site based on whether it is a single site or a multisite.
* If it is a multisite, it also checks if it is network activated or not.
*
* @return string The site type
*/
public function get_site_type(): string {

// If it's a multisite, is it network activated or not?
if ( is_multisite() ) {
return $this->is_network_activated() ? self::MULTI_NETWORK : self::MULTI_NOT_NETWORK;
}

return self::SINGLE_SITE;
}

/**
* Should the number of records which need to be processed be considered "large"?
*
* @param int $record_number The number of rows in the {$wpdb->prefix}_stream table to be processed.
* @return bool Whether or not this should be considered large.
*/
public function is_large_records_table( int $record_number ): bool {
/**
* Filters whether or not the number of records should be considered a large table.
*
* @since 4.1.0
*
* @param bool $is_large_table Whether or not the number of records should be considered large.
* @param int $record_number The number of records being checked.
*/
return apply_filters( 'wp_stream_is_large_records_table', $record_number > 1000000, $record_number );
}

/**
* Checks if the plugin is running on a single site installation.
*
* @return bool True if the plugin is running on a single site installation, false otherwise.
*/
public function is_single_site() {
return self::SINGLE_SITE === $this->get_site_type();
}

/**
* Check if the plugin is activated on a multisite installation but not network activated.
*
* @return bool True if the plugin is activated on a multisite installation but not network activated, false otherwise.
*/
public function is_multisite_not_network_activated() {
return self::MULTI_NOT_NETWORK === $this->get_site_type();
}

/**
* Check if the plugin is activated on a multisite network.
*
* @return bool True if the plugin is network activated on a multisite, false otherwise.
*/
public function is_multisite_network_activated() {
return self::MULTI_NETWORK === $this->get_site_type();
}

/**
* Enqueue a script along with a stylesheet if it exists.
*
Expand Down
Loading

0 comments on commit 0318c8a

Please sign in to comment.