__( 'Action Scheduler Cleanup', 'ai-eshop-optimizer' ), 'description' => __( 'Active maintenance for Action Scheduler — purges stale runs, defragments the queue, and prevents the slow accumulation of completed jobs that drags every WooCommerce cron tick once the table crosses a million rows.', 'ai-eshop-optimizer' ), 'bullets' => array( __( 'Chunked purge — never times out, never locks the queue.', 'ai-eshop-optimizer' ), __( 'AJAX-flood protection that throttles runaway action handlers before they take the site down.', 'ai-eshop-optimizer' ), __( 'Drop-in maintenance you can leave running — no manual intervention needed.', 'ai-eshop-optimizer' ), ), ); } public function register_hooks() { add_action( 'wp_ajax_' . self::AJAX_ACTION, array( $this, 'ajax_cleanup_step' ) ); add_action( 'admin_notices', array( $this, 'maybe_emit_bypass_notice' ) ); } private function read_bypass_setting() { $val = get_option( self::BYPASS_OPT, null ); if ( $val !== null ) { return (bool) $val; } return (bool) get_option( self::BYPASS_OPT_LEGACY, false ); } private function form_nonce() { return str_replace( ' id="aieo_dmm_as_cleanup_form_nonce"', '', wp_nonce_field( self::FORM_NONCE, 'aieo_dmm_as_cleanup_form_nonce', true, false ) ); } public function enqueue_admin_assets() { $css = 'assets/css/aieo-as-cleanup-admin.css'; wp_enqueue_style( 'aieo-as-cleanup-admin', AIEO_URI . $css, array(), file_exists( AIEO_DIR . $css ) ? (string) filemtime( AIEO_DIR . $css ) : '1' ); } public function maybe_emit_bypass_notice() { if ( ! current_user_can( 'manage_options' ) ) { return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen ) { return; } $allowed = array( 'dashboard', 'plugins' ); if ( ! in_array( $screen->id, $allowed, true ) ) { return; } if ( ! $this->read_bypass_setting() ) { return; } printf( '
%s %s
' . esc_html( $finished_line . ' ' . $pending_line ) . '
' . esc_html( $tidy_line . ' ' . $final_line ) . '
', array( 'confirm' => __( 'Run Deep Clean', 'ai-eshop-optimizer' ) ) ), 'purge_finished' => \Rooster\Admin\States::confirm( 'aieo-as-confirm-purge_finished', 'replace', __( 'Purge finished actions?', 'ai-eshop-optimizer' ), '' . esc_html( $finished_line . ' ' . $tidy_line ) . '
' . esc_html( $final_line ) . '
', array( 'confirm' => __( 'Purge', 'ai-eshop-optimizer' ) ) ), 'deduplicate' => \Rooster\Admin\States::confirm( 'aieo-as-confirm-deduplicate', 'replace', __( 'Deduplicate pending actions?', 'ai-eshop-optimizer' ), '' . esc_html( $pending_line . ' ' . $final_line ) . '
', array( 'confirm' => __( 'Deduplicate', 'ai-eshop-optimizer' ) ) ), 'nuclear' => \Rooster\Admin\States::confirm( 'aieo-as-confirm-nuclear', 'replace', __( 'Run the NUCLEAR cleanup?', 'ai-eshop-optimizer' ), '' . esc_html__( 'Deletes every action of a blocked hook, pending ones included.', 'ai-eshop-optimizer' ) . '
' . esc_html( $finished_line . ' ' . $pending_line . ' ' . $tidy_line . ' ' . $final_line ) . '
', array( 'confirm' => __( 'Wipe', 'ai-eshop-optimizer' ) ) ), ); $recommended = \Rooster\Admin\Ui::notice( esc_html__( 'One-click comprehensive cleanup with real-time progress. Purges ALL completed/failed/canceled actions, deduplicates ALL pending hooks (keeps 1 newest per job), cleans orphaned logs, stale claims, and empty groups.', 'ai-eshop-optimizer' ), 'info', array( 'title' => __( 'Recommended: Deep Clean', 'ai-eshop-optimizer' ), 'inline' => true, 'detail' => '' . esc_html__( 'Processes 50,000 actions per batch. Safe for tables with millions of rows.', 'ai-eshop-optimizer' ) . '
', 'actions' => \Rooster\Admin\Ui::button( __( 'Deep Clean (All-in-One)', 'ai-eshop-optimizer' ), array( 'variant' => 'primary', 'attrs' => array( 'data-aieo-as-flow' => 'deep_clean' ) + \Rooster\Admin\States::confirm_attrs( 'aieo-as-confirm-deep_clean' ) ) ), ) ); echo $recommended; echo rooster_admin_card_section_html( __( 'Individual Actions', 'ai-eshop-optimizer' ), array( 'id' => 'aieo-section-as-cleanup-individual-actions' ) ); $individual = array( 'purge_finished' => array( __( 'Purge Finished Actions (all completed/failed/canceled)', 'ai-eshop-optimizer' ), 'outline' ), 'deduplicate' => array( __( 'Deduplicate ALL Pending Hooks (keep 1 newest per job)', 'ai-eshop-optimizer' ), 'outline' ), 'nuclear' => array( __( 'NUCLEAR: Wipe Blocked Hooks + All Finished', 'ai-eshop-optimizer' ), 'destructive' ), ); foreach ( $individual as $flow_key => $flow_btn ) { $btn = \Rooster\Admin\Ui::button( $flow_btn[0], array( 'variant' => $flow_btn[1], 'attrs' => array( 'data-aieo-as-flow' => $flow_key ) + \Rooster\Admin\States::confirm_attrs( 'aieo-as-confirm-' . $flow_key ) ) ); echo '' . $btn . '
'; } echo implode( '', $dialogs ); echo $close; $stop = \Rooster\Admin\Ui::button( __( 'Stop', 'ai-eshop-optimizer' ), array( 'variant' => 'destructive', 'attrs' => array( 'id' => 'aieo-as-stop-btn' ) ) ); list( $open, $close ) = AIEO_DMM_UI::card( array( 'anchor' => 'as-cleanup-cleanup-in-progress', 'icon' => 'timer', 'title' => __( 'Cleanup in Progress', 'ai-eshop-optimizer' ), 'tools' => '0m 00s ' . $stop,
'col' => 1,
'movable' => false,
'attrs' => array( 'hidden' => true ),
) );
echo $open;
echo \Rooster\Admin\Ui::progress( 0, 100, array( 'id' => 'aieo-as-progress', 'label' => __( 'Cleanup in Progress', 'ai-eshop-optimizer' ), 'show_value' => true ) );
echo '';
echo "# Delete failed/canceled actions\n";
echo "wp db query \"DELETE FROM {$wpdb->prefix}actionscheduler_actions WHERE status IN ('failed','canceled') LIMIT 50000\"\n\n";
echo "# Delete old completed actions\n";
echo "wp db query \"DELETE FROM {$wpdb->prefix}actionscheduler_actions WHERE status = 'complete' LIMIT 50000\"\n\n";
echo "# Clean orphaned logs\n";
echo "wp db query \"DELETE FROM {$wpdb->prefix}actionscheduler_logs WHERE action_id NOT IN (SELECT action_id FROM {$wpdb->prefix}actionscheduler_actions) LIMIT 100000\"";
echo '';
echo $close;
rooster_admin_band_close();
$this->print_inline_js( $nonce );
}
private function print_inline_js( $nonce ) {
$ajax_url = admin_url( 'admin-ajax.php' );
?>
prefix . 'actionscheduler_actions';
$logs = $wpdb->prefix . 'actionscheduler_logs';
$claims = $wpdb->prefix . 'actionscheduler_claims';
$groups = $wpdb->prefix . 'actionscheduler_groups';
$batch_size = 50000;
$step = isset( $_POST['step'] ) ? sanitize_text_field( wp_unslash( $_POST['step'] ) ) : '';
switch ( $step ) {
case 'stats':
$statuses = array();
$rows = $wpdb->get_results( "SELECT status, COUNT(*) as cnt FROM {$table} GROUP BY status", ARRAY_A );
foreach ( $rows as $r ) {
$statuses[ $r['status'] ] = (int) $r['cnt'];
}
$total = array_sum( $statuses );
$log_count = 0; $claim_count = 0; $group_count = 0;
if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $logs ) ) === $logs ) {
$log_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$logs}" );
}
if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $claims ) ) === $claims ) {
$claim_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$claims}" );
}
if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $groups ) ) === $groups ) {
$group_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$groups}" );
}
$pending_hooks = $wpdb->get_results(
"SELECT hook, COUNT(*) as cnt, COUNT(DISTINCT args) as distinct_args
FROM {$table} WHERE status = 'pending'
GROUP BY hook ORDER BY cnt DESC LIMIT 20",
ARRAY_A
);
wp_send_json_success( array(
'statuses' => $statuses,
'total' => $total,
'logs' => $log_count,
'claims' => $claim_count,
'groups' => $group_count,
'pending_hooks' => $pending_hooks,
) );
break;
case 'purge':
$status = isset( $_POST['status'] ) ? sanitize_text_field( wp_unslash( $_POST['status'] ) ) : 'complete';
if ( ! in_array( $status, array( 'complete', 'failed', 'canceled' ), true ) ) {
wp_send_json_error( 'Invalid status' );
}
$deleted = (int) $wpdb->query( $wpdb->prepare(
"DELETE FROM {$table} WHERE status = %s LIMIT %d",
$status,
$batch_size
) );
wp_send_json_success( array(
'deleted' => $deleted,
'done' => ( $deleted < $batch_size ),
) );
break;
case 'deduplicate':
$dupes = $wpdb->get_results(
"SELECT hook, args, COUNT(*) as cnt, MAX(action_id) as keep_id
FROM {$table}
WHERE status = 'pending'
GROUP BY hook, args
HAVING cnt > 1"
);
$total_deleted = 0;
$hooks_cleaned = 0;
foreach ( $dupes as $dup ) {
$del = (int) $wpdb->query( $wpdb->prepare(
"DELETE FROM {$table}
WHERE hook = %s AND args = %s AND status = 'pending' AND action_id != %d",
$dup->hook, $dup->args, $dup->keep_id
) );
if ( $del > 0 ) {
$total_deleted += $del;
$hooks_cleaned++;
}
}
wp_send_json_success( array(
'deleted' => $total_deleted,
'hooks_cleaned' => $hooks_cleaned,
) );
break;
case 'deduplicate_singletons':
$singleton_hooks = defined( 'AIEO_AS_SINGLETON_HOOKS' )
? AIEO_AS_SINGLETON_HOOKS
: ( defined( 'OM_AS_SINGLETON_HOOKS' ) ? OM_AS_SINGLETON_HOOKS : array() );
if ( empty( $singleton_hooks ) ) {
wp_send_json_success( array( 'deleted' => 0, 'hooks_cleaned' => 0 ) );
break;
}
$total_deleted = 0;
$hooks_cleaned = 0;
foreach ( $singleton_hooks as $hook ) {
$keep_id = $wpdb->get_var( $wpdb->prepare(
"SELECT MAX(action_id) FROM {$table} WHERE hook = %s AND status = 'pending'",
$hook
) );
if ( ! $keep_id ) { continue; }
$del = (int) $wpdb->query( $wpdb->prepare(
"DELETE FROM {$table} WHERE hook = %s AND status = 'pending' AND action_id != %d",
$hook, $keep_id
) );
if ( $del > 0 ) {
$total_deleted += $del;
$hooks_cleaned++;
}
}
wp_send_json_success( array(
'deleted' => $total_deleted,
'hooks_cleaned' => $hooks_cleaned,
) );
break;
case 'orphan_logs':
if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $logs ) ) !== $logs ) {
wp_send_json_success( array( 'deleted' => 0, 'done' => true ) );
break;
}
$log_batch = 100000;
$deleted = (int) $wpdb->query(
"DELETE FROM {$logs}
WHERE action_id NOT IN (SELECT action_id FROM {$table})
LIMIT {$log_batch}"
);
wp_send_json_success( array(
'deleted' => $deleted,
'done' => ( $deleted < $log_batch ),
) );
break;
case 'truncate_claims':
$count = 0;
if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $claims ) ) === $claims ) {
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$claims}" );
if ( $count > 0 ) {
$wpdb->query( "TRUNCATE TABLE {$claims}" );
}
}
wp_send_json_success( array( 'deleted' => $count ) );
break;
case 'clean_groups':
$deleted = 0;
if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $groups ) ) === $groups ) {
$doomed = $wpdb->get_results(
"SELECT g.group_id, g.slug FROM {$groups} g
LEFT JOIN {$table} a ON g.group_id = a.group_id
WHERE a.action_id IS NULL",
ARRAY_A
);
if ( $doomed ) {
$ids = implode( ',', array_map( 'intval', array_column( $doomed, 'group_id' ) ) );
$deleted = (int) $wpdb->query(
"DELETE g FROM {$groups} g
LEFT JOIN {$table} a ON g.group_id = a.group_id
WHERE g.group_id IN ({$ids}) AND a.action_id IS NULL"
);
if ( function_exists( 'aieo_as_dedup_forget_groups' ) ) {
aieo_as_dedup_forget_groups( array_column( $doomed, 'slug', 'group_id' ) );
}
}
}
wp_send_json_success( array( 'deleted' => $deleted ) );
break;
case 'nuclear_hooks':
$blocked_hooks = defined( 'AIEO_AS_BLOCKED_HOOKS' )
? AIEO_AS_BLOCKED_HOOKS
: ( defined( 'OM_AS_BLOCKED_HOOKS' ) ? OM_AS_BLOCKED_HOOKS : array() );
$blocked_patterns = defined( 'AIEO_AS_BLOCKED_PATTERNS' )
? AIEO_AS_BLOCKED_PATTERNS
: ( defined( 'OM_AS_BLOCKED_PATTERNS' ) ? OM_AS_BLOCKED_PATTERNS : array() );
$total = 0;
$all_done = true;
foreach ( $blocked_hooks as $hook ) {
$del = (int) $wpdb->query( $wpdb->prepare(
"DELETE FROM {$table} WHERE hook = %s LIMIT %d",
$hook, $batch_size
) );
$total += $del;
if ( $del >= $batch_size ) { $all_done = false; }
}
foreach ( $blocked_patterns as $pattern ) {
$safe = $wpdb->esc_like( $pattern ) . '%';
$del = (int) $wpdb->query( $wpdb->prepare(
"DELETE FROM {$table} WHERE hook LIKE %s LIMIT %d",
$safe, $batch_size
) );
$total += $del;
if ( $del >= $batch_size ) { $all_done = false; }
}
wp_send_json_success( array(
'deleted' => $total,
'done' => $all_done,
) );
break;
default:
wp_send_json_error( 'Unknown step: ' . $step );
}
}
}