callback private $state_cache = null; // cached state array private static $lock_duration = 10 * 60; // static lock duration (seconds) private function __construct() { $this->meta_key = WPMCS_TOKEN . '_bg_runner_meta'; $this->control_transient_key = WPMCS_TOKEN . '_bg_runner_control'; // NEW: dynamic control key $this->action_hook = WPMCS_TOKEN . '_bg_runner_cron'; add_filter('cron_schedules', [$this, 'add_cron_schedules']); add_action($this->action_hook, [$this, 'run_all']); // Ensure cron always exists if (!wp_next_scheduled($this->action_hook)) { wp_schedule_event(time(), 'every_minute', $this->action_hook); } } public static function instance() { if (!self::$instance) self::$instance = new self(); return self::$instance; } public function set_callback(string $type, callable $callback) { $this->callback_map[$type] = $callback; } public function start(string $type, int $iterations) { $state = $this->get_state(); $state[$type]['status'] = 'running'; $state[$type]['iterations_total'] = $iterations; $state[$type]['iterations_done'] = 0; $state[$type]['failed_count'] = 0; $this->save_state($state); // reset control flags $control = $this->get_control(); $control[$type] = $this->default_control_state(); $this->save_control($control); if (!wp_next_scheduled($this->action_hook)) { wp_schedule_event(time(), 'every_minute', $this->action_hook); } } public function pause(string $type) { $control = $this->get_control(); $control[$type]['pause_requested'] = true; $this->save_control($control); } public function stop(string $type) { $control = $this->get_control(); $control[$type]['stop_requested'] = true; $this->save_control($control); } public function resume(string $type) { $control = $this->get_control(); $control[$type]['pause_requested'] = false; $control[$type]['stop_requested'] = false; $this->save_control($control); $state = $this->get_state(); if (in_array($state[$type]['status'] ?? 'stopped', ['paused','stopped'], true)) { $state[$type]['status'] = 'running'; $this->save_state($state); if (!wp_next_scheduled($this->action_hook)) { wp_schedule_event(time(), 'every_minute', $this->action_hook); } } } public function status(string $type) { $state = $this->get_state(); return $this->format_status($type, $state); } public function all_statuses() { $state = $this->get_state(); $statuses = []; foreach ($state as $type => $s) { $statuses[$type] = $this->format_status($type, $state); } return $statuses; } private function format_status(string $type, array &$state) { $s = $state[$type] ?? $this->default_type_state(); $control = $this->get_control(); $c = $control[$type] ?? $this->default_control_state(); $total = $s['iterations_total'] ?? 0; $done = $s['iterations_done'] ?? 0; $failed_count = $s['failed_count'] ?? 0; $remaining = max(0, $total - $done); $percentage = $total > 0 ? round(($done / $total) * 100, 2) : 0; $status = [ 'total' => $total, 'processed' => $done, 'failed' => $failed_count, 'remaining' => $remaining, 'percentage' => $percentage, 'status' => $s['status'], 'last_run' => $s['last_run'] ?? 0, 'pause_requested' => $c['pause_requested'] ?? false, 'stop_requested' => $c['stop_requested'] ?? false, ]; if (!empty($s['completed'])) { $status['percentage'] = 100; $status['remaining'] = 0; $status['status'] = 'completed'; // reset type state after reporting completed $state[$type] = $this->default_type_state(); $this->save_state($state); } if( $status['status'] === 'stopped' && $s['iterations_done'] < $s['iterations_total'] ) { // reset type state after reporting completed $state[$type] = $this->default_type_state(); $this->save_state($state); } return $status; } public function run_all() { $state = $this->get_state(); foreach ($state as $type => $s) { // refresh state for each type to pick up changes made during processing of other types $latest_state = $this->get_state(true); $s = $latest_state[$type] ?? $s; if (($s['status'] ?? 'stopped') !== 'running') { $latest_control = $this->get_control(); $c = $latest_control[$type] ?? $this->default_control_state(); if($c['stop_requested'] ?? false) { $s['status'] = 'stopped'; $latest_state[$type] = $s; $latest_control[$type]['stop_requested'] = false; $this->save_state($latest_state); $this->save_control($latest_control); } continue; }; if (!isset($this->callback_map[$type])) continue; if ($this->is_locked($s)) continue; $this->lock($latest_state, $type); $this->save_state($latest_state); $this->process_iterations($type, $this->callback_map[$type]); // Fetch state again to ensure we have the latest $latest_state = $this->get_state(true); $this->unlock($latest_state, $type); $this->save_state($latest_state); } // Unschedule cron if all are stopped/completed $all_idle = true; foreach ($latest_state as $s) { if (($s['status'] ?? 'stopped') === 'running') { $all_idle = false; break; } } if ($all_idle && wp_next_scheduled($this->action_hook)) { wp_clear_scheduled_hook($this->action_hook); } } private function process_iterations(string $type, callable $callback) { $s = $this->get_state_counts($type, true); $max_exec = ini_get('max_execution_time') ? (int) ini_get('max_execution_time') : 30; $max_mem = ini_get('memory_limit') ? $this->return_bytes(ini_get('memory_limit')) : 128*1024*1024; $memory_safe = $max_mem * 0.65; $time_safe = $max_exec * 0.65; $start_time = time(); while ($s['iterations_done'] < $s['iterations_total']) { // force refresh so pause/stop requests are seen immediately $latest_control = get_transient($this->control_transient_key) ?: []; $latest_c = $latest_control[$type] ?? $this->default_control_state(); // stop if paused or stopped if (!empty($latest_c['pause_requested']) || !empty($latest_c['stop_requested'])) { break; } if ((memory_get_usage(true) > $memory_safe) || (time() - $start_time > $time_safe)) { break; } try { $already_done = $s['iterations_done']; $result = call_user_func($callback, $already_done, $s['failed_count'], $s['iterations_total']); if ($result !== true) { $s['failed_count']++; } } catch (\Exception $e) { $s['failed_count']++; } $s['iterations_done']++; // Update state after each iteration to ensure progress is saved $this->update_state_counts($type, $s, true, false); gc_collect_cycles(); } // Get latest state again $latest_state = $this->get_state(true); $latest_s = $latest_state[$type]; $latest_control = $this->get_control(); $latest_c = $latest_control[$type] ?? $this->default_control_state(); $is_completed = ($latest_s['iterations_done'] ?? 0) >= ($latest_s['iterations_total'] ?? 0); // Update state if completed, paused or stopped if ($is_completed || ($latest_c['stop_requested'] ?? false) || ($latest_c['pause_requested'] ?? false)) { $latest_s['status'] = $is_completed || ($latest_c['stop_requested'] ?? false) ? 'stopped' : 'paused'; $latest_s['completed'] = $is_completed; $latest_state[$type] = $latest_s; $this->save_state($latest_state); $latest_control[$type]['pause_requested'] = false; $latest_control[$type]['stop_requested'] = false; $this->save_control($latest_control); } } private function lock(array &$state, string $type) { $state[$type]['lock_until'] = time() + self::$lock_duration; } private function unlock(array &$state, string $type) { $state[$type]['lock_until'] = 0; $state[$type]['last_run'] = time(); } private function is_locked(array $s) { return ($s['lock_until'] ?? 0) && time() < $s['lock_until']; } private function return_bytes($val) { $val = trim($val); $last = strtolower($val[strlen($val)-1]); $num = (int)$val; switch($last) { case 'g': $num *= 1024*1024*1024; break; case 'm': $num *= 1024*1024; break; case 'k': $num *= 1024; break; } return $num; } public function add_cron_schedules($schedules) { $schedules['every_minute'] = [ 'interval' => 60, 'display' => 'Every Minute' ]; return $schedules; } /** * Get only the count-related fields of the state for a given type. * * @param string $type The type key in the state array. * @param bool $force Reload state from transient/option instead of cache. * * @return array { * @type int $iterations_total * @type int $iterations_done * @type int $failed_count * } */ private function get_state_counts($type, $force = false) { $state = $this->get_state($force); if (!isset($state[$type])) { return [ 'iterations_total' => 0, 'iterations_done' => 0, 'failed_count' => 0, ]; } return [ 'iterations_total' => (int) ($state[$type]['iterations_total'] ?? 0), 'iterations_done' => (int) ($state[$type]['iterations_done'] ?? 0), 'failed_count' => (int) ($state[$type]['failed_count'] ?? 0), ]; } /** * Update only the count-related fields for a given type. * * @param string $type The type key in the state array. * @param array $counts { * @type int $iterations_total * @type int $iterations_done * @type int $failed_count * } * @param bool $update_transient Whether to update transient. * @param bool $update_options Whether to update options. */ private function update_state_counts($type, array $counts, $update_transient = true, $update_options = true) { $state = $this->get_state( true ); // full state array if (!isset($state[$type])) { $state[$type] = $this->default_type_state(); } // update only the count fields if (isset($counts['iterations_total'])) { $state[$type]['iterations_total'] = (int) $counts['iterations_total']; } if (isset($counts['iterations_done'])) { $state[$type]['iterations_done'] = (int) $counts['iterations_done']; } if (isset($counts['failed_count'])) { $state[$type]['failed_count'] = (int) $counts['failed_count']; } $this->save_state($state, $update_transient, $update_options); } /** * Get the state of the bg runner. * * If $force is true, the state will be reloaded from the transient or option. * If $force is false and the state cache already exists, the cached state will be returned. * * If the state is not available from the transient, it will be loaded from the option. * The state will then be cached and set as a transient for one week. * * @param bool $force Reload the state from the transient or option. * * @return array The state of the bg runner. */ private function get_state($force = false) { // if not forcing and cache already exists, return cached if (!$force && $this->state_cache !== null) { return $this->state_cache; } // try transient first $state = get_transient($this->transient_key); if ($state !== false) { if ($force) { // update cache with the fresh transient $this->state_cache = $state; } return $state; } // fallback to option if transient missing $state = get_option($this->meta_key, []); $this->state_cache = $state; set_transient($this->transient_key, $state, WEEK_IN_SECONDS); return $state; } private function save_state($state, $update_transient = true, $update_options = true) { if ($this->state_cache === null || $this->state_cache !== $state) { $this->state_cache = $state; if ($update_transient) set_transient($this->transient_key, $state, WEEK_IN_SECONDS); if ($update_options) update_option($this->meta_key, $state, false); } } private function default_type_state() { return [ 'lock_until' => 0, 'status' => 'stopped', 'iterations_total' => 0, 'iterations_done' => 0, 'failed_count' => 0, 'completed' => false, 'last_run' => 0, ]; } private function get_control() { return get_transient($this->control_transient_key) ?: []; } private function save_control($control) { set_transient($this->control_transient_key, $control, WEEK_IN_SECONDS); } private function default_control_state() { return [ 'pause_requested' => false, 'stop_requested' => false, ]; } }