Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use scheduled action for resetting large record and meta tables #1543

Merged
merged 15 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';
Comment on lines +140 to +141
Copy link
Member

@bartoszgadomski bartoszgadomski Aug 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tharsheblows There's a risk of conflict if this package was loaded by another plugin. Should this be preceded with function_exists call (or something similar) to only load this dependency if it has not been loaded by another plugin earlier?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bartoszgadomski Action Scheduler works when included multiple times! It's a clever library – it loads the most recent version requested so all of the functions expected are available. Eg latest version does it like this


// 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 );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tharsheblows Suggestion: adding a docblock comment to all custom hooks would be beneficial for developers to understand how this plugin can be adjusted :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bartoszgadomski ACK yes, I totally forgot.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 6111295

}

/**
* 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
Loading