prefix . $key); $suppressed = $wpdb->suppress_errors(); $result = $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 0)', $name)); $wpdb->suppress_errors($suppressed); if ((string) $result === '1') return $name; if ((string) $result === '0') return false; // Real contention, never bypass it. // Some managed databases do not implement named locks. Use the unique // option_name index as an atomic mutex, not get_option()/update_option(). $name = '_lbraid_lock_' . md5($wpdb->prefix . $key); $value = (time() + 1800) . ':' . wp_generate_uuid4(); $old = $wpdb->get_var($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name=%s", $name)); if ($old && (int) $old < time()) { $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name=%s AND option_value=%s", $name, $old)); } $inserted = $wpdb->query($wpdb->prepare("INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", $name, $value)); return $inserted === 1 ? ['name' => $name, 'value' => $value] : false; } public static function owns_lock($token) { global $wpdb; if (!is_array($token)) return (bool) $token; return (int) $token['value'] >= time() && $wpdb->get_var($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name=%s", $token['name'])) === $token['value']; } public static function unlock($key, $token) { global $wpdb; if (is_array($token)) $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name=%s AND option_value=%s", $token['name'], $token['value'])); elseif ($token) $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $token)); } private static function schedule($delay = 5) { if (!wp_next_scheduled('lbraid_job_tick')) wp_schedule_single_event(time() + $delay, 'lbraid_job_tick'); } public function ajax() { check_ajax_referer('lbraid_nonce', 'nonce'); if (!current_user_can('manage_options')) wp_send_json_error(__( 'Insufficient permissions.', 'linkbraid' ), 403); $action = sanitize_key($_POST['operation'] ?? 'status'); if ($action === 'status') { $job = get_option(self::OPTION, null); if ($job && $job['status'] === 'running') { self::schedule(); spawn_cron(); } wp_send_json_success($job); } // Browser-driven fallback: when WP-Cron never fires (loopback blocked, // DISABLE_WP_CRON, firewall), the open admin tab advances the queue by // one item per request. tick() takes the queue lock itself, so a live // cron and the fallback can never process the same item twice. if ($action === 'tick') { $job = get_option(self::OPTION, null); if ($job && $job['status'] === 'running') { self::tick(); $job = get_option(self::OPTION, null); } wp_send_json_success($job); } $lock = self::lock('queue'); if (!$lock) wp_send_json_error(__( 'A post is being processed. Try again in a moment.', 'linkbraid' )); $error = null; try { $job = get_option(self::OPTION, null); if ($action === 'start') { if ($job && $job['status'] === 'running') $error = __( 'A job is already running.', 'linkbraid' ); else { $type = sanitize_key($_POST['type'] ?? 'analyze'); $allowed = lbraid_has_pro_code() ? ['analyze', 'index', 'gsc', 'scan', 'broken', 'autobraid'] : ['scan', 'broken', 'autobraid']; if (!in_array($type, $allowed, true)) $error = __( 'Invalid job type.', 'linkbraid' ); elseif (in_array($type, ['analyze', 'index', 'gsc'], true) && !lbraid_premium()) $error = __( 'This job type requires an active Linkbraid license. Activate it under Linkbraid → Account.', 'linkbraid' ); elseif ($type === 'broken' && !($ids = Linkbraid_Broken::prepare_queue())) $error = __( 'No URLs to check. Run a link scan first, or wait a day after the previous check.', 'linkbraid' ); else { if ($type !== 'broken') $ids = get_posts(['post_type' => lbraid_post_types(), 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC']); $job = ['id' => wp_generate_uuid4(), 'type' => $type, 'owner' => get_current_user_id(), 'status' => 'running', 'ids' => $ids, 'total' => count($ids), 'cursor' => 0, 'ok' => 0, 'empty' => 0, 'failed' => 0, 'attempt' => 0, 'errors' => [], 'current' => '', 'updated' => current_time('mysql')]; } } } elseif ($job && $action === 'pause') $job['status'] = 'paused'; elseif ($job && $action === 'resume') { $job['status'] = 'running'; $job['owner'] = get_current_user_id(); } elseif ($job && $action === 'retry' && $job['status'] !== 'running') { $ids = array_map('intval', array_keys($job['errors'])); $job = array_merge($job, ['ids' => $ids, 'total' => count($ids), 'cursor' => 0, 'ok' => 0, 'empty' => 0, 'failed' => 0, 'errors' => [], 'attempt' => 0, 'status' => 'running', 'owner' => get_current_user_id()]); } else $error = __( 'Invalid operation.', 'linkbraid' ); if (!$error) { update_option(self::OPTION, $job, false); if ($job['status'] === 'running') self::schedule(); } } finally { self::unlock('queue', $lock); } if ($error) wp_send_json_error($error); spawn_cron(); wp_send_json_success($job); } public static function tick() { $lock = self::lock('queue'); if (!$lock) { self::schedule(30); return; } $previous_user = get_current_user_id(); try { $job = get_option(self::OPTION, null); if (!$job || $job['status'] !== 'running') return; wp_set_current_user($job['owner']); if (!current_user_can('manage_options')) { $job['status'] = 'paused'; update_option(self::OPTION, $job, false); return; } if ($job['cursor'] >= $job['total']) { $job['status'] = 'complete'; update_option(self::OPTION, $job, false); return; } // Schedule recovery before external I/O. Fatal errors/timeouts cannot lose the job. self::schedule(60); $id = (int) $job['ids'][$job['cursor']]; $job['current'] = $job['type'] === 'broken' ? Linkbraid_Broken::label($id) : get_the_title($id); $job['attempt']++; update_option(self::OPTION, $job, false); try { if ($job['attempt'] > 2) $result = ['error' => __( 'Retry limit exceeded (timeout or interrupted request).', 'linkbraid' )]; elseif ($job['type'] === 'broken') $result = Linkbraid_Broken::check($id); elseif (!current_user_can('edit_post', $id) || get_post_status($id) !== 'publish') $result = ['error' => __( 'Post deleted, unpublished or unavailable.', 'linkbraid' )]; // Ignored content is skipped silently for suggestion jobs — that is intent, not an error. elseif (in_array($job['type'], ['analyze', 'autobraid'], true) && in_array((int) $id, lbraid_ignored_posts(), true)) $result = ['success' => true, 'count' => 0]; elseif ($job['type'] === 'scan') $result = Linkbraid_Database::refresh_existing_links($id) ? ['success' => true, 'count' => 1] : ['error' => __( 'The link scan could not be saved.', 'linkbraid' )]; elseif ($job['type'] === 'autobraid') $result = Linkbraid_Autobraid::scan_post($id); elseif ($job['type'] === 'index') $result = Linkbraid_Embeddings::index_post($id) ? ['success' => true, 'count' => 1] : ['error' => Linkbraid_Embeddings::$last_error ?: __( 'No embedding was created. Check the AI provider key and the content support.', 'linkbraid' )]; elseif ($job['type'] === 'gsc') { $gsc = new Linkbraid_GSC(); $queries = $gsc->get_page_queries(get_permalink($id), 90, 20); if ($gsc->last_error) $result = ['error' => $gsc->last_error]; else { Linkbraid_Database::save_gsc_queries($id, $queries); $result = ['success' => true, 'count' => count($queries)]; } } else $result = Linkbraid_LLM_Analyzer::analyze_post($id); /* translators: %s: dynamic value inserted at runtime. */ } catch (Throwable $e) { $result = ['error' => sprintf( __( 'Processing error: %s', 'linkbraid' ), $e->getMessage() )]; } if (isset($result['error']) && $job['attempt'] < 2) { /* translators: %s: dynamic value inserted at runtime. */ $job['current'] = sprintf( __( '%s — retrying after an error', 'linkbraid' ), $job['current'] ); } else { if (isset($result['error'])) { $job['failed']++; $job['errors'][$id] = mb_substr($result['error'], 0, 500); } elseif (empty($result['count'])) $job['empty']++; else $job['ok']++; $job['cursor']++; $job['attempt'] = 0; } if (!self::owns_lock($lock)) return; $job['updated'] = current_time('mysql'); if ($job['cursor'] >= $job['total']) { $job['status'] = 'complete'; if ($job['type'] === 'gsc' && !$job['failed']) update_option('lbraid_gsc_last_sync', current_time('mysql')); } update_option(self::OPTION, $job, false); wp_clear_scheduled_hook('lbraid_job_tick'); if ($job['status'] === 'running') self::schedule($job['attempt'] ? 30 : 5); } finally { wp_set_current_user($previous_user); self::unlock('queue', $lock); } } }