$wpdb->prefix . 'actionscheduler_actions', 'logs' => $wpdb->prefix . 'actionscheduler_logs', 'groups' => $wpdb->prefix . 'actionscheduler_groups', ); } /** * Why Action Scheduler's tables can't be read here, or '' when they can. * * @since 1.0.0 * @return string */ public static function unavailable(): string { if ( ! class_exists( '\ActionScheduler' ) || ! \ActionScheduler::is_initialized() ) { return __( 'Action Scheduler isn’t loaded yet. It comes with WooCommerce.', 'admin-powerups-for-woocommerce' ); } if ( ! \ActionScheduler::store() instanceof \ActionScheduler_DBStore ) { return __( 'Action Scheduler isn’t using its own database tables on this site (it may still be moving its data, or another plugin replaced its storage). This monitor reads those tables, so it waits until then. Use Tools → Scheduled Actions meanwhile.', 'admin-powerups-for-woocommerce' ); } return ''; } /** * Whether the actions table has an index (older Action Scheduler versions lack some). * * @since 1.0.0 * @param string $name Index name. * @return bool */ public static function has_index( string $name ): bool { global $wpdb; $quiet = $wpdb->suppress_errors( true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- schema check, callers cache it. $rows = $wpdb->get_results( $wpdb->prepare( 'SHOW INDEX FROM %i WHERE Key_name = %s', self::tables()['actions'], $name ) ); $wpdb->suppress_errors( $quiet ); return ! empty( $rows ); } /** * How long Action Scheduler keeps finished actions (its own cleaner's retention period, which * the Scheduled actions power-up's setting feeds while it is on). At least one day. * * @since 1.0.0 * @return int Seconds. */ public static function retention_seconds(): int { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Action Scheduler's own filter, read the same way its cleaner does. return max( DAY_IN_SECONDS, absint( apply_filters( 'action_scheduler_retention_period', MONTH_IN_SECONDS ) ) ); } /** * How many actions of a status are older than the cutoff: one indexed COUNT. Finished and * failed actions are dated by their last attempt, canceled ones by their scheduled date. * MySQL tends to pick the (status, scheduled date) index for the last-attempt range, which on * a large table is about 20 times slower, so the (status, last attempt) index is named when it * exists. * * @since 1.0.0 * @param string $status complete | failed | canceled. * @param \DateTime $cutoff Cutoff (UTC). * @return int */ public static function count_old( string $status, \DateTime $cutoff ): int { global $wpdb; $table = self::tables()['actions']; $date = gmdate( 'Y-m-d H:i:s', $cutoff->getTimestamp() ); // phpcs:disable WordPress.DB.DirectDatabaseQuery -- one COUNT per purge step, must be live. if ( 'canceled' === $status ) { return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE status = %s AND scheduled_date_gmt <= %s', $table, $status, $date ) ); } static $indexed = array(); if ( ! isset( $indexed[ $table ] ) ) { $indexed[ $table ] = self::has_index( 'status_last_attempt_gmt' ); } if ( $indexed[ $table ] ) { return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i USE INDEX (status_last_attempt_gmt) WHERE status = %s AND last_attempt_gmt <= %s', $table, $status, $date ) ); } return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE status = %s AND last_attempt_gmt <= %s', $table, $status, $date ) ); // phpcs:enable } /** * The date column an action of this status is aged by: canceled actions by their scheduled * date (canceling never sets a last attempt), the rest by their last attempt. * * @since 1.0.0 * @param string $status complete | failed | canceled. * @return string */ public static function date_column( string $status ): string { return 'canceled' === $status ? 'scheduled_date_gmt' : 'last_attempt_gmt'; } /** * Delete one bounded step of actions of a status older than the cutoff. * * Finished and failed actions are chosen by their last attempt, canceled ones by their * scheduled date: canceling never sets a last attempt, so Action Scheduler's own cleaner * (which filters on it) would treat a canceled action that never ran as infinitely old. * Both queries use the status indexes. With Action Scheduler's own tables, each chunk of IDs * is deleted with one `IN (…)` statement that checks the status and date again (so an action * that was retried or claimed meanwhile, and anything pending or running, stays), then their * logs with another; `action_scheduler_deleted_action` still fires for every deleted ID. * With another store, each ID goes through the store. Any status other than failed or * canceled means complete. Safe to repeat and to run beside Action Scheduler's cleaner. * Clears the monitor's summary cache when it deleted something. * * @since 1.0.0 * @param string $status complete | failed | canceled. * @param \DateTime $cutoff Actions at or before this time (UTC) go. * @param int $batch IDs per query (bulk deletes use at most CHUNK). * @param float $budget Seconds to keep going (at least one query always runs). * @param string $source Who purged, passed to `apwc_scheduled_actions_purged` * (`monitor`, `database-cleanup`, …). * @return array{deleted: int, left: int, done: bool} */ public static function purge_step( string $status, \DateTime $cutoff, int $batch, float $budget, string $source = 'monitor' ): array { $status = in_array( $status, array( 'failed', 'canceled' ), true ) ? $status : 'complete'; $store = \ActionScheduler::store(); $bulk = $store instanceof \ActionScheduler_DBStore; $batch = $bulk ? max( 1, min( self::CHUNK, $batch ) ) : max( 1, $batch ); $when = as_get_datetime_object( $cutoff->getTimestamp() ); $query = 'canceled' === $status ? array( 'status' => 'canceled', 'date' => $when, 'date_compare' => '<=', ) : array( 'status' => $status, 'modified' => $when, 'modified_compare' => '<=', ); $query += array( 'per_page' => $batch, 'orderby' => 'none', ); $start = microtime( true ); $deleted = 0; do { $ids = array_values( array_filter( array_map( 'intval', (array) $store->query_actions( $query ) ) ) ); if ( $bulk ) { $gone = self::delete_chunk( $ids, $status, $when ); if ( null === $gone ) { break; // Database error: stop here, the count below still reports what's left. } $deleted += $gone; } else { foreach ( $ids as $action_id ) { try { $store->delete_action( $action_id ); ++$deleted; } catch ( \InvalidArgumentException $e ) { unset( $e ); // Deleted by another process (Action Scheduler's cleaner) meanwhile. } } } $got = count( $ids ); } while ( $got >= $batch && microtime( true ) - $start < $budget ); $left = self::count_old( $status, $when ); if ( $deleted ) { /** * Fires after a purge step deleted old scheduled actions. * * @since 1.0.0 * @param string $status Status purged (complete, failed or canceled). * @param int $count Actions deleted in this step. * @param string $source Who purged: `monitor` (Scheduled actions) or `database-cleanup`. */ do_action( 'apwc_scheduled_actions_purged', $status, $deleted, $source ); delete_transient( self::SUMMARY ); } return array( 'deleted' => $deleted, 'left' => $left, 'done' => 0 === $left, ); } /** * Delete a chunk of actions from Action Scheduler's tables in two statements. * * The DELETE repeats the status and date conditions, so only actions that still qualify go * (pending, running and retried actions never match). Logs are deleted only for actions that * are gone afterwards, then `action_scheduler_deleted_action` fires once per gone ID with * Action Scheduler's own per-action log clean-up unhooked meanwhile (the logs are already * deleted; it would add two queries per action). * * @param int[] $ids Action IDs. * @param string $status complete | failed | canceled. * @param \DateTime $cutoff Cutoff (UTC). * @return int|null Actions deleted, or null on a database error. */ private static function delete_chunk( array $ids, string $status, \DateTime $cutoff ): ?int { global $wpdb; if ( ! $ids ) { return 0; } $tables = self::tables(); $in = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); $date = gmdate( 'Y-m-d H:i:s', $cutoff->getTimestamp() ); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- bounded bulk delete; $in holds %d placeholders only. $affected = $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE action_id IN ( {$in} ) AND status = %s AND %i <= %s", array_merge( array( $tables['actions'] ), $ids, array( $status, self::date_column( $status ), $date ) ) ) ); if ( false === $affected ) { return null; } $left = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT action_id FROM %i WHERE action_id IN ( {$in} )", array_merge( array( $tables['actions'] ), $ids ) ) ) ); $gone = array_values( array_diff( $ids, $left ) ); if ( $gone ) { $in_gone = implode( ',', array_fill( 0, count( $gone ), '%d' ) ); do { // Bounded: an action with a huge log is finished over a few statements. $logs = $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE action_id IN ( {$in_gone} ) LIMIT %d", array_merge( array( $tables['logs'] ), $gone, array( 10000 ) ) ) ); } while ( 10000 === $logs ); } // phpcs:enable $logger = \ActionScheduler::logger(); $callback = array( $logger, 'clear_deleted_action_logs' ); $priority = has_action( 'action_scheduler_deleted_action', $callback ); if ( false !== $priority ) { remove_action( 'action_scheduler_deleted_action', $callback, $priority ); } try { foreach ( $gone as $action_id ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Action Scheduler's own action, fired as its store does. do_action( 'action_scheduler_deleted_action', $action_id ); } } finally { if ( false !== $priority ) { add_action( 'action_scheduler_deleted_action', $callback, $priority, 1 ); } } // Rows another process deleted meanwhile are not counted (the IDs still fire, harmlessly). return (int) $affected; } }