10 min → reset) * - AJAX status endpoint for progress polling * - Auto-cleanup of old jobs (7 days) * * @package ForgeQueue * @version 1.0.0 */ if (!defined('ABSPATH')) exit; // Register cron interval — always, even if class already loaded by another plugin if (!has_filter('cron_schedules', 'forge_queue_cron_interval')) { function forge_queue_cron_interval($schedules) { if (!isset($schedules['forge_every_minute'])) { $schedules['forge_every_minute'] = [ 'interval' => 60, 'display' => 'Every Minute (Forge Queue)', ]; } return $schedules; } add_filter('cron_schedules', 'forge_queue_cron_interval'); } if (!class_exists('Forge_Queue')) { // The original guard lived here; keeping the declaration wrapped makes // it conditional at *runtime*, so PHP 8.3 / OpCache can't hoist the // class past a sibling's prior include. See docs for context. class Forge_Queue { const VERSION = '1.0.0'; // 1.0.1 — the DDL below was corrected twice (the table name came out from // behind the conditional-create clause dbDelta cannot parse, and the three // int columns took their display width). maybe_create_table returns on a // version match, so an install stamped 1.0.0 by the release that shipped // the broken DDL would never have run the fixed one. const DB_VERSION = '1.0.1'; // Everything below spells the table out as {$wpdb->prefix}forge_queue — // queries, insert/update and the schema statements alike — so that // what is handed to $wpdb is a literal a reader can check on sight. The // constant names the same table for code outside this class. const TABLE = 'forge_queue'; const CRON_HOOK = 'forge_queue_process'; const CLEANUP_HOOK = 'forge_queue_cleanup'; private static $instance = null; private static $handlers = []; private $concurrency = 5; public static function instance() { if (self::$instance === null) self::$instance = new self(); return self::$instance; } private function __construct() { $this->maybe_create_table(); $this->schedule_cron(); add_action(self::CRON_HOOK, [$this, 'process']); add_action(self::CLEANUP_HOOK, [$this, 'cleanup']); add_action('wp_ajax_forge_queue_status', [$this, 'ajax_status']); // Inline fallback for WP-Cron-less hosts (MAMP, Local WP, Docker) runs // inside ajax_status on status polls — see the process call there. } // ─── Registration ─────────────────────────────────────── /** * Register a job handler. * * @param string $job_type Unique job type identifier. * @param callable $callback Handler function. Receives (array $payload). Return true or array on success, WP_Error on failure. */ public static function register($job_type, $callback) { self::$handlers[$job_type] = $callback; } // ─── Dispatch ─────────────────────────────────────────── /** * Add a job to the queue. * * @param string $job_type Job type (must be registered). * @param array $payload Job data (stored as JSON). * @param string $plugin Plugin slug (langforge, rankforge, formforge, fieldforge). * @param array $opts Optional: delay (seconds), priority (lower = first), max_attempts. * @return int|false Job ID or false on failure. */ public static function dispatch($job_type, $payload, $plugin, $opts = []) { global $wpdb; $delay = intval($opts['delay'] ?? 0); $scheduled = $delay > 0 ? gmdate('Y-m-d H:i:s', time() + $delay) : current_time('mysql', true); $inserted = $wpdb->insert($wpdb->prefix . 'forge_queue', [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended. 'plugin' => sanitize_key($plugin), 'job_type' => sanitize_key($job_type), 'payload' => wp_json_encode($payload), 'status' => 'pending', 'priority' => intval($opts['priority'] ?? 10), 'attempts' => 0, 'max_attempts' => intval($opts['max_attempts'] ?? 3), 'scheduled_at' => $scheduled, 'created_at' => current_time('mysql', true), ]); if ($inserted) { // Nudge WP-Cron so jobs start even on local/MAMP environments // where no external HTTP hits trigger cron. spawn_cron(); } return $inserted ? $wpdb->insert_id : false; } /** * Dispatch multiple jobs at once. * * @param array $jobs Array of [job_type, payload, plugin, opts]. * @return int Number of jobs dispatched. */ public static function dispatch_batch($jobs) { $count = 0; foreach ($jobs as $job) { $result = self::dispatch( $job['job_type'] ?? $job[0], $job['payload'] ?? $job[1] ?? [], $job['plugin'] ?? $job[2] ?? '', $job['opts'] ?? $job[3] ?? [] ); if ($result) $count++; } return $count; } // ─── Processor ────────────────────────────────────────── /** * Process pending jobs. Called by WP Cron every minute. * Runs in a loop until no more pending jobs or time limit reached. */ public function process() { global $wpdb; $start_time = time(); $max_runtime = 50; // seconds — stay under PHP max_execution_time (usually 60) // Reset stale jobs (processing for > 10 minutes). A job that hangs // mid-run (hard timeout / fatal) never reaches the exception path that // increments attempts, so increment it HERE — otherwise a job that // hangs every run would be requeued to 'pending' forever. Once it // reaches max_attempts we mark it 'failed' instead of requeueing. $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended. "UPDATE {$wpdb->prefix}forge_queue SET status = CASE WHEN attempts + 1 >= max_attempts THEN 'failed' ELSE 'pending' END, result = CASE WHEN attempts + 1 >= max_attempts THEN 'Failed: job stalled (timeout) and reached max_attempts' ELSE result END, completed_at = CASE WHEN attempts + 1 >= max_attempts THEN %s ELSE completed_at END, attempts = attempts + 1 WHERE status = 'processing' AND started_at < %s", current_time('mysql', true), gmdate('Y-m-d H:i:s', time() - 600) )); // Process in a loop until time runs out or queue is empty while ((time() - $start_time) < $max_runtime) { // Check concurrency $running = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended. "SELECT COUNT(*) FROM {$wpdb->prefix}forge_queue WHERE status = 'processing'" ); $slots = $this->concurrency - $running; if ($slots <= 0) break; // Claim jobs $batch_token = wp_generate_uuid4(); $affected = $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended. "UPDATE {$wpdb->prefix}forge_queue SET status = 'processing', started_at = %s, result = %s WHERE status = 'pending' AND scheduled_at <= %s ORDER BY priority ASC, id ASC LIMIT %d", current_time('mysql', true), $batch_token, current_time('mysql', true), $slots )); if (!$affected) break; // No more pending jobs // Fetch claimed jobs $jobs = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended. "SELECT * FROM {$wpdb->prefix}forge_queue WHERE status = 'processing' AND result = %s", $batch_token )); foreach ($jobs as $job) { $this->run_job($job); } } } /** * Execute a single job. */ private function run_job($job) { global $wpdb; $handler = self::$handlers[$job->job_type] ?? null; if (!$handler) { $wpdb->update($wpdb->prefix . 'forge_queue', [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended. 'status' => 'failed', 'result' => 'No handler registered for: ' . $job->job_type, 'completed_at' => current_time('mysql', true), ], ['id' => $job->id]); return; } $payload = json_decode($job->payload, true) ?: []; try { $result = call_user_func($handler, $payload); if (is_wp_error($result)) { throw new \Exception($result->get_error_message()); } // Success $wpdb->update($wpdb->prefix . 'forge_queue', [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended. 'status' => 'completed', 'result' => is_array($result) ? wp_json_encode($result) : 'ok', 'attempts' => $job->attempts + 1, 'completed_at' => current_time('mysql', true), ], ['id' => $job->id]); } catch (\Throwable $e) { $attempts = $job->attempts + 1; if ($attempts >= $job->max_attempts) { // Failed permanently $wpdb->update($wpdb->prefix . 'forge_queue', [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended. 'status' => 'failed', 'result' => $e->getMessage(), 'attempts' => $attempts, 'completed_at' => current_time('mysql', true), ], ['id' => $job->id]); } else { // Retry with exponential backoff: 1m, 5m, 25m $delay = pow(5, $attempts - 1) * 60; $wpdb->update($wpdb->prefix . 'forge_queue', [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended. 'status' => 'pending', 'result' => 'Retry ' . $attempts . ': ' . $e->getMessage(), 'attempts' => $attempts, 'scheduled_at' => gmdate('Y-m-d H:i:s', time() + $delay), 'started_at' => null, ], ['id' => $job->id]); } } } // ─── Status ───────────────────────────────────────────── /** * AJAX: Get queue status for a plugin/job_type. */ public function ajax_status() { check_ajax_referer('forge_queue_nonce', 'nonce'); if (!current_user_can('manage_options')) wp_send_json_error(); global $wpdb; // The query string wins over the body when it carries the field at all. $plugin = ''; if (isset($_GET['plugin'])) { $plugin = sanitize_key(wp_unslash($_GET['plugin'])); } elseif (isset($_POST['plugin'])) { $plugin = sanitize_key(wp_unslash($_POST['plugin'])); } $job_type = ''; if (isset($_GET['job_type'])) { $job_type = sanitize_key(wp_unslash($_GET['job_type'])); } elseif (isset($_POST['job_type'])) { $job_type = sanitize_key(wp_unslash($_POST['job_type'])); } // Inline processing: if there are pending jobs, process them now. // This is the fallback for environments where WP-Cron loopback fails // (MAMP, Local WP, Docker without cron, etc.). $pending = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended. "SELECT COUNT(*) FROM {$wpdb->prefix}forge_queue WHERE status = 'pending'" ); if ($pending > 0) { $this->process(); } // One literal query whatever was asked for: an empty filter switches // its own condition off (`'' = ''`), so nothing is ever added to the // text and each value is bound twice. $row = $wpdb->get_row($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended. "SELECT COUNT(*) as total, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed, SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing, SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending FROM {$wpdb->prefix}forge_queue WHERE ( %s = '' OR plugin = %s ) AND ( %s = '' OR job_type = %s )", $plugin, $plugin, $job_type, $job_type )); $total = (int) ($row->total ?? 0); $completed = (int) ($row->completed ?? 0); $failed = (int) ($row->failed ?? 0); wp_send_json_success([ 'total' => $total, 'completed' => $completed, 'failed' => $failed, 'processing' => (int) ($row->processing ?? 0), 'pending' => (int) ($row->pending ?? 0), 'percent' => $total > 0 ? round(($completed + $failed) / $total * 100) : 0, 'done' => $total > 0 && ($completed + $failed) >= $total, ]); } // ─── Utilities ────────────────────────────────────────── /** * Get a single job by ID. */ public static function get($id) { global $wpdb; return $wpdb->get_row($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; one-time/admin read "SELECT * FROM {$wpdb->prefix}forge_queue WHERE id = %d", $id )); } /** * Cancel all pending jobs for a plugin/job_type. */ public static function cancel($plugin, $job_type = '') { global $wpdb; // One literal DELETE: without a job type the last condition is `'' = ''`. $job_type = $job_type ? (string) $job_type : ''; return $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended. "DELETE FROM {$wpdb->prefix}forge_queue WHERE plugin = %s AND status = 'pending' AND ( %s = '' OR job_type = %s )", $plugin, $job_type, $job_type )); } /** * Set concurrency limit. */ public function set_concurrency($n) { $this->concurrency = max(1, min(20, intval($n))); } /** * Cleanup old completed/failed jobs. */ public function cleanup() { global $wpdb; $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended. "DELETE FROM {$wpdb->prefix}forge_queue WHERE status IN ('completed', 'failed') AND completed_at < %s", gmdate('Y-m-d H:i:s', time() - 7 * DAY_IN_SECONDS) )); } // ─── Uninstall ────────────────────────────────────────── /** * Last one out drops the shared queue. * * The table, its two cron hooks and its DB-version option belong to the * whole suite — that is what the `plugin` column is for — so a product * removes them only when no other Forge product is left on this site, * because a neighbour's pending jobs live in the same rows. An install * counter kept in an option was the rejected alternative: it drifts the * first time a plugin is deleted over FTP, an uninstall is interrupted or * a site is restored from a dump, and a drifted counter either drops a * live queue or never drops at all — the line-up of neighbours is a fact * that can be re-read at the moment of deletion, a counter is only a * memory of one. * * The line-up is Forge_Suite_Registry::neighbour_on_site's to read, the * one rule every Forge uninstall.php applies (the account binding, the * telemetry hook): a neighbour's Free plugin or add-on, in its avakode.com * or its wordpress.org folder, on disk or active. This helper used to keep * a second copy of that rule that looked at one Free file per product, and * so dropped a queue that held a neighbour's rows. Only a plain false — the * registry saw the whole site and this was the last product — lets the * queue go; true (a neighbour) and null (cannot tell) both leave it standing. * * @param string $slug Registry slug of the product being uninstalled; it * and its own add-on are left out of the count because * WordPress still lists a plugin while its uninstall.php * runs. * @return bool Whether the shared queue was removed. */ public static function uninstall_shared_queue($slug) { // Only WordPress deleting a plugin may reach this; anything else that // called it would take the whole suite's queue down with it. if (!defined('WP_UNINSTALL_PLUGIN')) return false; if (!class_exists('Forge_Suite_Registry') && file_exists(__DIR__ . '/class-forge-suite-registry.php')) { require_once __DIR__ . '/class-forge-suite-registry.php'; } // No registry — or an older copy that a sibling loaded first and that // predates neighbour_on_site, which makes that sibling a neighbour — // is no way to know who else is on this site, and leaving the queue // standing is the harmless answer. if (!class_exists('Forge_Suite_Registry') || !method_exists('Forge_Suite_Registry', 'neighbour_on_site')) return false; // `!== false`, not `!`: null ("cannot tell": no plugin API, a registry // that names nobody but this product) must leave the queue standing too. if (false !== Forge_Suite_Registry::neighbour_on_site($slug)) return false; wp_clear_scheduled_hook(self::CRON_HOOK); wp_clear_scheduled_hook(self::CLEANUP_HOOK); delete_option('forge_queue_db_version'); global $wpdb; $wpdb->query("DROP TABLE IF EXISTS `{$wpdb->prefix}forge_queue`"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- uninstall of the last suite plugin drops the suite's own table. return true; } // ─── Uninstall: everything the suite keeps on a site ──── /** The eight Forge products: their licence rows, updater caches and Pro-tip snoozes carry the slug. */ const SUITE_PRODUCTS = ['langforge', 'rankforge', 'formforge', 'fieldforge', 'guardforge', 'flowforge', 'velocityforge', 'draftforge']; /** * What the suite's shared classes write into a site's database, by exact * name (never a `forge_%` pattern: other plugins use that prefix too), in * two groups by who writes it. 'addon' is the Pro add-ons' classes: the * account binding, the Pro stamp and the Connect error list (Forge_Wporg_Disabled_Connect), * the seven statistics options with their hourly event (Forge_Telemetry), the * credits widget's caches. 'free' is the Free plugins' classes: the * ever-connected flag, the health history and the feedback timestamp (the * dashboard and feedback widget), the queue version, table and events (this * class), the deactivation lock, the feedback and Pro-tip user meta. A new * option, transient, event or meta key in a shared class goes on this list, * or it outlives the last Forge plugin. * * 'prefixes' are the transients whose name goes on with a product slug or a * user id (`forge_credits__`, `forge_deact_lock_`): the * only names removed by prefix, and the prefixes are whole words. */ const SUITE_KEYS = [ 'addon' => [ 'options' => [ 'forge_account_binding', 'forge_license_last_pro_at', 'forge_connect_sync_errors', 'forge_telemetry_queue', 'forge_telemetry_consent', 'forge_telemetry_error_context', 'forge_install_id', 'forge_telemetry_notice_dismissed', 'forge_telemetry_last_heartbeat', 'forge_telemetry_dedup', ], 'cron' => ['forge_telemetry_flush'], 'transients' => ['forge_credits_pricing'], 'prefixes' => ['forge_credits_'], 'user_meta' => [], ], 'free' => [ 'options' => [ 'forge_user_ever_connected', 'forge_suite_health_history', 'forge_feedback_first_seen', 'forge_queue_db_version', ], 'cron' => ['forge_queue_process', 'forge_queue_cleanup'], 'transients' => [], 'prefixes' => ['forge_deact_lock_'], 'user_meta' => ['forge_feedback_dismissed_until', 'forge_feedback_submitted_at'], ], ]; /** * Run $task on every site of the network, or once on a single site. * * The plugin files are the network's, the options are each site's. Each site * is switched to and restored in a try/finally, so one that throws cannot * leave the next one under the wrong prefix. The task gets the site id (0 = * a single site, no switch). * * @param callable $task function (int $site_id): void */ public static function for_each_site($task) { $sites = (function_exists('is_multisite') && is_multisite() && function_exists('get_sites')) ? (array) get_sites(['fields' => 'ids', 'number' => 0]) : []; foreach ($sites ?: [0] as $site) { if ($site) switch_to_blog((int) $site); try { $task((int) $site); } finally { if ($site) restore_current_blog(); } } } /** * What a product's uninstall.php settles on the CURRENT site (call it from * inside for_each_site). * * The Pro stamp always goes: a neighbour that still holds a licence re-sets * it on its next check, one that does not must not inherit up to a day of * grace. When another Forge product is still here ($alone false) the rest * is that product's and stays, except this product's own entry in the * Connect error list (an emptied list goes). When this is the last one * ($alone true) everything in SUITE_KEYS goes, every product's licence row * (the dashboard writes them for products that are not installed too) and, * for the Free group, the queue table. * * @param string $slug Registry slug of the product being uninstalled. * @param bool $alone Whether Forge_Suite_Registry::neighbour_on_site said false. * @param bool $with_free False for a Pro add-on whose own Free plugin is still * installed: the Free group is that plugin's and stays. */ public static function settle_site_on_uninstall($slug, $alone, $with_free = true) { if (!defined('WP_UNINSTALL_PLUGIN')) return; delete_option('forge_license_last_pro_at'); if (!$alone) { $errors = get_option('forge_connect_sync_errors', []); if (is_array($errors) && isset($errors[$slug])) { unset($errors[$slug]); if ($errors) { update_option('forge_connect_sync_errors', $errors, false); } else { delete_option('forge_connect_sync_errors'); } } return; } global $wpdb; foreach (self::uninstall_groups($with_free) as $group) { foreach ($group['options'] as $option) { delete_option($option); } foreach ($group['cron'] as $hook) { wp_clear_scheduled_hook($hook); } foreach ($group['transients'] as $transient) { delete_transient($transient); } foreach ($group['prefixes'] as $prefix) { $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- uninstall: one prepared DELETE of the suite's own transients, whose names end in a slug or a user id, by whole-word prefix through esc_like $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", '_transient_' . $wpdb->esc_like($prefix) . '%', '_transient_timeout_' . $wpdb->esc_like($prefix) . '%' ) ); } } foreach (self::SUITE_PRODUCTS as $product) { delete_option($product . '_license_data'); } // The table, and the same events and version again, behind the registry's own check. if ($with_free) { self::uninstall_shared_queue($slug); } } /** * What a product's uninstall.php settles once for the whole network: the * updater's site transients, the licence rows a network may hold, and the * suite's user meta (one table for all sites). Only the last product does. * * @param bool $alone Whether Forge_Suite_Registry::neighbour_on_site said false. * @param bool $with_free See settle_site_on_uninstall. */ public static function settle_network_on_uninstall($alone, $with_free = true) { if (!defined('WP_UNINSTALL_PLUGIN') || !$alone) return; foreach (self::SUITE_PRODUCTS as $product) { delete_site_transient('forge_updater_' . $product); delete_site_option($product . '_license_data'); } if (!$with_free) return; foreach (self::SUITE_PRODUCTS as $product) { delete_metadata('user', 0, 'forge_pro_tip_dismissed_until_' . $product, '', true); } foreach (self::SUITE_KEYS['free']['user_meta'] as $meta_key) { delete_metadata('user', 0, $meta_key, '', true); } } /** @return array the SUITE_KEYS groups a product removes. */ private static function uninstall_groups($with_free) { return $with_free ? array_values(self::SUITE_KEYS) : [self::SUITE_KEYS['addon']]; } // ─── Database ─────────────────────────────────────────── private function maybe_create_table() { if (get_option('forge_queue_db_version') === self::DB_VERSION) return; global $wpdb; $charset = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$wpdb->prefix}forge_queue ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, plugin varchar(20) NOT NULL, job_type varchar(50) NOT NULL, payload longtext NOT NULL, status varchar(20) NOT NULL DEFAULT 'pending', priority int(11) NOT NULL DEFAULT 10, attempts int(11) NOT NULL DEFAULT 0, max_attempts int(11) NOT NULL DEFAULT 3, result text, scheduled_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, started_at datetime DEFAULT NULL, completed_at datetime DEFAULT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_status_scheduled (status, scheduled_at), KEY idx_plugin_status (plugin, status) ) $charset;"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta($sql); update_option('forge_queue_db_version', self::DB_VERSION); } // ─── Cron Scheduling ──────────────────────────────────── private function schedule_cron() { if (!wp_next_scheduled(self::CRON_HOOK)) { wp_schedule_event(time(), 'forge_every_minute', self::CRON_HOOK); } if (!wp_next_scheduled(self::CLEANUP_HOOK)) { wp_schedule_event(time(), 'daily', self::CLEANUP_HOOK); } } public static function register_cron_interval($schedules) { if (!isset($schedules['forge_every_minute'])) { $schedules['forge_every_minute'] = [ 'interval' => 60, 'display' => 'Every Minute (Forge Queue)', ]; } return $schedules; } } } // end: if (!class_exists('Forge_Queue')) // Note: cron interval registered at top of file, before class definition