PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / otto / class-metasync-otto-job-status.php

class-metasync-otto-job-status.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at otto/class-metasync-otto-job-status.php

275 lines 9.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Bounded per-URL status store for the OTTO crawl-notify pipeline.
4 *
5 * The crawl-notify webhook acknowledges *scheduling*, not execution, so the
6 * only way to answer "what actually happened to this URL" is to record it as
7 * the background jobs run. This store keeps one entry per URL (keyed by md5),
8 * a bounded overflow queue for webhook batches that exceed the inline cap,
9 * and helpers the batched cache job uses to wait for stragglers.
10 *
11 * Storage is option-backed and deliberately capped so a chatty crawler can
12 * never grow it without bound: at most MAX_ENTRIES status rows and at most
13 * MAX_PENDING queued URLs, with terminal rows pruned after TERMINAL_TTL.
14 *
15 * @package Search Atlas SEO
16 */
17
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 final class Metasync_Otto_Job_Status {
23
24 /** Per-URL lifecycle states. */
25 const STATE_ACCEPTED = 'accepted';
26 const STATE_RETRYING = 'retrying';
27 const STATE_COMPLETED = 'completed';
28 const STATE_FAILED = 'failed';
29
30 /** Processing outcomes returned by the SEO sync. */
31 const OUTCOME_SUCCESS = 'success';
32 const OUTCOME_NO_CHANGE = 'no_change';
33 const OUTCOME_RETRYABLE = 'retryable';
34 const OUTCOME_PERMANENT = 'permanent';
35 const OUTCOME_DEFERRED = 'deferred';
36
37 const OPTION = 'metasync_otto_job_status';
38 const PENDING_OPTION = 'metasync_otto_pending_urls';
39
40 /** Must cover the peak in-flight set — MAX_PENDING queued URLs plus the
41 * webhook's inline batch cap (METASYNC_MAX_JOBS_PER_BATCH, default 25)
42 * = 525 — so prune() can never evict a URL whose work is still live;
43 * terminal history is evicted first when the cap is hit. */
44 const MAX_ENTRIES = 525;
45 const MAX_PENDING = 500;
46 const TERMINAL_TTL = 172800; // 48 hours.
47
48 /** States that mean a URL still has live work in flight. */
49 const ACTIVE_STATES = [self::STATE_ACCEPTED, self::STATE_RETRYING];
50
51 /**
52 * Record a state transition for a URL.
53 *
54 * @param string $url Normalized absolute URL.
55 * @param string $state One of the STATE_* constants.
56 * @param string $stage Pipeline stage that produced the transition.
57 * @param int $attempts Attempt number (0 = first try).
58 * @param string $reason Short machine-readable reason.
59 * @param array $extra Optional extra fields merged into the entry.
60 * @return bool
61 */
62 public static function record($url, $state, $stage = '', $attempts = 0, $reason = '', $extra = []) {
63 if (!self::is_nonempty_string($url)) {
64 return false;
65 }
66
67 $store = get_option(self::OPTION, []);
68 if (!is_array($store)) {
69 $store = [];
70 }
71
72 $key = md5($url);
73 $entry = [
74 'url' => $url,
75 'state' => $state,
76 'stage' => $stage,
77 'attempts' => (int) $attempts,
78 'reason' => (string) $reason,
79 'updated' => time(),
80 'first_seen' => isset($store[$key]['first_seen']) ? $store[$key]['first_seen'] : time(),
81 ];
82 if (!empty($extra)) {
83 $entry = array_merge($entry, $extra);
84 }
85
86 $store[$key] = $entry;
87 $store = self::prune($store);
88
89 update_option(self::OPTION, $store, false);
90 return true;
91 }
92
93 /**
94 * Get the recorded entry for a URL (null when unknown).
95 *
96 * @param string $url
97 * @return array|null
98 */
99 public static function get($url) {
100 $store = get_option(self::OPTION, []);
101 if (!is_array($store)) {
102 return null;
103 }
104 $key = md5((string) $url);
105 return isset($store[$key]) && is_array($store[$key]) ? $store[$key] : null;
106 }
107
108 /**
109 * Whether a URL has live (non-terminal, fresh) work in flight.
110 *
111 * @param string $url
112 * @return bool
113 */
114 public static function has_active($url) {
115 $entry = self::get($url);
116 if ($entry === null) {
117 return false;
118 }
119 if (!in_array($entry['state'], self::ACTIVE_STATES, true)) {
120 return false;
121 }
122 // Active entries older than 15 minutes are considered stale so a
123 // crashed worker cannot wedge a URL out of future scheduling forever.
124 return (time() - (int) $entry['updated']) < 900;
125 }
126
127 /**
128 * State tallies plus pending-queue depth, for Site Health and debugging.
129 *
130 * @return array{accepted:int,retrying:int,completed:int,failed:int,pending:int}
131 */
132 public static function counts() {
133 $store = get_option(self::OPTION, []);
134 $counts = [
135 'accepted' => 0,
136 'retrying' => 0,
137 'completed' => 0,
138 'failed' => 0,
139 'pending' => 0,
140 ];
141 if (is_array($store)) {
142 foreach ($store as $entry) {
143 if (is_array($entry) && isset($counts[$entry['state']])) {
144 $counts[$entry['state']]++;
145 }
146 }
147 }
148 $counts['pending'] = self::pending_count();
149 return $counts;
150 }
151
152 /**
153 * Number of URLs waiting in the overflow queue.
154 *
155 * @return int
156 */
157 public static function pending_count() {
158 $pending = get_option(self::PENDING_OPTION, []);
159 return is_array($pending) ? count($pending) : 0;
160 }
161
162 /**
163 * Durably queue a URL that could not be scheduled inline. Returns false
164 * when the URL is already queued or the queue is at its cap.
165 *
166 * @param string $url
167 * @return bool
168 */
169 public static function enqueue_pending($url) {
170 $pending = get_option(self::PENDING_OPTION, []);
171 if (!is_array($pending)) {
172 $pending = [];
173 }
174 if (in_array($url, $pending, true)) {
175 return false;
176 }
177 if (count($pending) >= self::MAX_PENDING) {
178 return false;
179 }
180 $pending[] = $url;
181 update_option(self::PENDING_OPTION, $pending, false);
182 return true;
183 }
184
185 /**
186 * Take (and remove) up to $max URLs from the head of the overflow queue.
187 *
188 * @param int $max
189 * @return array List of URLs taken.
190 */
191 public static function take_pending($max = 25) {
192 $pending = get_option(self::PENDING_OPTION, []);
193 if (!is_array($pending) || $pending === []) {
194 return [];
195 }
196 $taken = array_slice($pending, 0, $max);
197 $rest = array_slice($pending, $max);
198 update_option(self::PENDING_OPTION, array_values($rest), false);
199 return $taken;
200 }
201
202 /**
203 * Re-queue a URL for processing by scheduling a fresh job.
204 *
205 * @param string $url
206 * @return bool
207 */
208 public static function replay($url) {
209 if (!self::is_nonempty_string($url)) {
210 return false;
211 }
212 self::record($url, self::STATE_ACCEPTED, 'replay', 0, '');
213 if (function_exists('metasync_otto_schedule_single_event')) {
214 return (bool) metasync_otto_schedule_single_event(time() + 5, 'metasync_process_otto_crawl_url_job', [$url]);
215 }
216 if (function_exists('wp_schedule_single_event')) {
217 return wp_schedule_single_event(time() + 5, 'metasync_process_otto_crawl_url_job', [$url]) === true;
218 }
219 return false;
220 }
221
222 /**
223 * Enforce the size and retention bounds. Evicts oldest-updated terminal
224 * entries first, then oldest-updated anything.
225 *
226 * @param array $store
227 * @return array
228 */
229 private static function prune($store) {
230 $now = time();
231
232 // Retention: drop terminal entries past their TTL.
233 foreach ($store as $key => $entry) {
234 if (is_array($entry)
235 && !in_array($entry['state'], self::ACTIVE_STATES, true)
236 && ($now - (int) $entry['updated']) > self::TERMINAL_TTL) {
237 unset($store[$key]);
238 }
239 }
240
241 // Size cap: sort into keep order (the slice keeps the head), evicting
242 // oldest-updated terminal entries first, then oldest-updated anything.
243 // Active rows are live work in flight and outrank terminal rows for
244 // retention; within the same class, newer updates outrank older ones.
245 if (count($store) > self::MAX_ENTRIES) {
246 uasort($store, function ($a, $b) {
247 $ta = is_array($a) ? (int) $a['updated'] : 0;
248 $tb = is_array($b) ? (int) $b['updated'] : 0;
249 // Active entries keep before terminal ones; terminal evict first.
250 $sa = is_array($a) && !in_array($a['state'], self::ACTIVE_STATES, true) ? 1 : 0;
251 $sb = is_array($b) && !in_array($b['state'], self::ACTIVE_STATES, true) ? 1 : 0;
252 if ($sa !== $sb) {
253 return $sa - $sb;
254 }
255 return $tb <=> $ta;
256 });
257 $store = array_slice($store, 0, self::MAX_ENTRIES, true);
258 }
259
260 return $store;
261 }
262
263 /**
264 * Runtime guard for the string-only public API. Callers come from cron
265 * payloads and webhook data, so a docblock contract is not enough; the
266 * untyped parameter keeps the check from being statically redundant.
267 *
268 * @param mixed $value
269 * @return bool
270 */
271 private static function is_nonempty_string($value) {
272 return is_string($value) && $value !== '';
273 }
274 }
275