posts} WHERE 1=0"; } // Custom-tables AS storage (the modern path). Only the ACTIONS table is // ever expensive — leave `actionscheduler_groups` reads alone (blanking // get_group_id() makes save_action() mint a duplicate group per action). // // Match the FROM/JOIN TARGET, not the mere presence of the name: a query // that lists `{prefix}actionscheduler_actions` as one value in an `IN (…)` // list is not a read of that table. Shared with // 0-aieo-action-scheduler-core.php; the fallback repeats the rule for the // case where the core file is absent. $reads_actions = function_exists( 'aieo_as_query_reads_actions_table' ) ? aieo_as_query_reads_actions_table( $query ) : ( strpos( $query, 'actionscheduler_actions' ) !== false && stripos( $query, 'information_schema' ) === false && preg_match( '/\b(?:FROM|JOIN|UPDATE|INTO)\s+`?\w*actionscheduler_actions`?/i', $query ) ); if ( $reads_actions ) { $normalized = trim( preg_replace( '/\s+/', ' ', $query ) ); // Bounded reads MUST run: the `LIMIT 0, 1` existence probe, the // `WHERE action_id = N` hydration fetch that follows it, COUNT guards, // and claim fetches. Blocking ANY of them makes the caller's // "already scheduled?" check answer "no" and schedule a DUPLICATE — // once per heartbeat, forever. That is what produced ~30,000 surplus // pending actions across the fleet. // // Shared with 0-aieo-action-scheduler-core.php on purpose. Fall back to // a local copy of the rule only if the core file is absent. if ( function_exists( 'aieo_as_query_is_safe' ) ) { if ( aieo_as_query_is_safe( $normalized ) ) { return $query; } } elseif ( preg_match( '/\bLIMIT\s+(?:\d+\s*,\s*)?1\b/i', $normalized ) || preg_match( '/^SELECT\s+(?:COUNT|MIN|MAX)\s*\(/i', $normalized ) || preg_match( '/\b(?:action_id|group_id)\s*=\s*\d+/i', $normalized ) || preg_match( '/\bclaim_id\s*=\s*[1-9]\d*/i', $normalized ) ) { return $query; } // Only the expensive multi-row list plan reaches here. CAP it rather // than blanking it: the 90s delays this file exists to prevent were // proportional to the number of rows the 9-query plan walked, so a // 10-row cap removes the cost just as effectively — while an empty // result set would tell any caller that guards on a LIST read (rather // than on as_has_scheduled_action()) that its job is not scheduled, and // mint yet another duplicate. Same rule in all three AS mu-plugins. if ( function_exists( 'aieo_as_apply_row_cap' ) ) { $max = defined( 'AIEO_AS_MAX_ROWS_FRONTEND' ) ? AIEO_AS_MAX_ROWS_FRONTEND : 10; return aieo_as_apply_row_cap( $query, $normalized, $max ); } return "SELECT 1 FROM {$wpdb->posts} WHERE 1=0"; } return $query; } /** * Fallback for WP_Query-style reads against the scheduled-action post type * (anything that slips past the raw-SQL filter above). */ add_filter( 'pre_get_posts', 'aieo_as_short_circuit_query_ajax', 1 ); function aieo_as_short_circuit_query_ajax( $query ) { if ( ! isset( $query->query['post_type'] ) ) { return $query; } if ( $query->query['post_type'] === 'scheduled-action' ) { $query->set( 'post__in', array( 0 ) ); } return $query; } // Disable AS's own queue runner on AJAX — cron/CLI are the only valid // contexts for processing queued actions. add_filter( 'action_scheduler_queue_runner_concurrent_batches', '__return_zero', 999 ); add_filter( 'action_scheduler_run_schedule', '__return_false', 999 ); add_filter( 'action_scheduler_allow_async_request_runner', '__return_false', 999 );