PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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
← All changes | media-optimization/class-media-batch-optimizer.php +182 -45 2.6.16 → 2.7.1 View file →
@@ -16,8 +16,23 @@
16 16 class Metasync_Media_Batch_Optimizer {
17 17
18 18 private const QUEUE_OPTION = 'metasync_batch_optimize_queue';
19 19 private const PROGRESS_OPTION = 'metasync_batch_optimize_progress';
20 + private const LOCK_OPTION = 'metasync_batch_optimize_lock';
21 +
22 + /** Transient holding the cached media-library stats (shared with the list table). */
23 + public const STATS_TRANSIENT = 'metasync_media_stats';
24 +
25 + /** Start-of-run stats snapshot; live batch stats are derived from it. */
26 + public const BASELINE_STATS_OPTION = 'metasync_batch_baseline_stats';
27 +
28 + /**
29 + * Age (seconds) after which a batch lock is considered abandoned by a
30 + * crashed process and may be stolen. Ticks are capped well below this
31 + * (CRON_TIME_LIMIT for cron; one batch for AJAX), so a live holder is
32 + * never stolen.
33 + */
34 + private const LOCK_STALE_AFTER = 120;
20 35 private const CRON_HOOK = 'metasync_media_batch_optimize_cron';
21 36 private const DEFAULT_BATCH_SIZE = 10;
22 37 private const MIN_BATCH_SIZE = 2;
23 38 private const MAX_FILTER_BATCH_SIZE = 50;
@@ -137,8 +152,16 @@
137 152 }
138 153
139 154 update_option(self::QUEUE_OPTION, $ids, false);
140 155
156 + // Snapshot the stats BEFORE the status flips to running: while
157 + // running, get_stats() derives live numbers from this baseline and
158 + // the batch progress instead of scanning the library every tick.
159 + self::flush_stats_cache();
160 + if (self::class_is_available('Metasync_Media_Library_List_Table')) {
161 + update_option(self::BASELINE_STATS_OPTION, Metasync_Media_Library_List_Table::get_stats(), false);
162 + }
163 +
141 164 $progress = [
142 165 'total' => count($ids),
143 166 'processed' => 0,
144 167 'failed' => 0,
@@ -166,8 +189,11 @@
166 189 $progress['status'] = 'cancelled';
167 190 update_option(self::PROGRESS_OPTION, $progress, false);
168 191
169 192 delete_option(self::QUEUE_OPTION);
193 + delete_option(self::BASELINE_STATS_OPTION);
194 + self::flush_stats_cache();
195 + self::release_lock();
170 196 wp_clear_scheduled_hook(self::CRON_HOOK);
171 197 }
172 198
173 199 /**
@@ -207,44 +233,119 @@
207 233 if ($progress['status'] !== 'running') {
208 234 return $progress;
209 235 }
210 236
211 - $queue = get_option(self::QUEUE_OPTION, []);
237 + if (!self::acquire_lock()) {
238 + // Another tick (cron or browser tab) owns the batch right now.
239 + return $progress;
240 + }
212 241
213 - if (empty($queue)) {
214 - self::complete_batch();
215 - return self::get_progress();
216 - }
242 + // One purge at completion covers the whole run; per-image purges
243 + // inside a batch would hammer the purge pipeline hundreds of times.
244 + Metasync_Image_Converter::set_cache_purge_suppressed(true);
217 245
218 - $settings = get_option('metasync_batch_optimize_settings', []);
246 + try {
247 + $queue = get_option(self::QUEUE_OPTION, []);
219 248
220 - // Request WordPress's image processing memory limit (typically 256MB) before heavy work
221 - wp_raise_memory_limit('image');
249 + if (empty($queue)) {
250 + self::complete_batch();
251 + return self::get_progress();
252 + }
222 253
223 - $batch = array_splice($queue, 0, self::get_batch_size());
254 + $settings = get_option('metasync_batch_optimize_settings', []);
224 255
225 - foreach ($batch as $attachment_id) {
226 - // Re-check status in case cancel was triggered mid-batch
227 - $current = get_option(self::PROGRESS_OPTION, []);
228 - if (($current['status'] ?? '') !== 'running') {
229 - break;
256 + // Request WordPress's image processing memory limit (typically 256MB) before heavy work
257 + wp_raise_memory_limit('image');
258 +
259 + $batch = array_splice($queue, 0, self::get_batch_size());
260 +
261 + foreach ($batch as $attachment_id) {
262 + // Re-check status in case cancel was triggered mid-batch
263 + $current = get_option(self::PROGRESS_OPTION, []);
264 + if (($current['status'] ?? '') !== 'running') {
265 + break;
266 + }
267 +
268 + self::convert_single($attachment_id, $settings, $progress);
230 269 }
231 270
232 - self::convert_single($attachment_id, $settings, $progress);
271 + // Re-check before persisting: a cancel that landed mid-tick
272 + // deletes the queue option, and writing this stale copy back
273 + // would resurrect the cancelled batch.
274 + $status_now = get_option(self::PROGRESS_OPTION, []);
275 + if (($status_now['status'] ?? '') === 'running') {
276 + update_option(self::QUEUE_OPTION, $queue, false);
277 + self::merge_progress_counters($progress);
278 +
279 + if (empty($queue)) {
280 + self::complete_batch();
281 + return self::get_progress();
282 + }
283 + }
284 + } finally {
285 + Metasync_Image_Converter::set_cache_purge_suppressed(false);
286 + self::release_lock();
233 287 }
234 288
235 - update_option(self::QUEUE_OPTION, $queue, false);
236 - update_option(self::PROGRESS_OPTION, $progress, false);
289 + return self::get_progress();
290 + }
237 291
238 - if (empty($queue)) {
239 - self::complete_batch();
240 - return self::get_progress();
292 + /**
293 + * Atomically acquire the batch lock.
294 + *
295 + * add_option() is an INSERT guarded by a unique key, so of two
296 + * concurrent ticks (cron + any open browser tabs) exactly one wins;
297 + * the others see false and skip. A lock older than LOCK_STALE_AFTER
298 + * belongs to a crashed process and is stolen.
299 + */
300 + private static function acquire_lock(): bool {
301 + $existing = get_option(self::LOCK_OPTION);
302 +
303 + if ($existing && (time() - (int) $existing) < self::LOCK_STALE_AFTER) {
304 + return false;
241 305 }
242 306
243 - return $progress;
307 + if ($existing) {
308 + delete_option(self::LOCK_OPTION);
309 + }
310 +
311 + return (bool) add_option(self::LOCK_OPTION, time(), '', false);
244 312 }
245 313
314 + private static function release_lock(): void {
315 + delete_option(self::LOCK_OPTION);
316 + }
317 +
246 318 /**
319 + * Drop the cached media-library stats so the next read recomputes from
320 + * the database. Called after every conversion/revert and on attachment
321 + * deletion, so the stats card can never show stale numbers.
322 + */
323 + public static function flush_stats_cache(): void {
324 + delete_transient(self::STATS_TRANSIENT);
325 + }
326 +
327 + private static function class_is_available(string $class): bool {
328 + return class_exists($class);
329 + }
330 +
331 + /**
332 + * Persist processed/failed counters without clobbering a concurrent
333 + * status change (cancel/complete) — only the counters are ours, the
334 + * status field is owned by whoever changed it.
335 + */
336 + private static function merge_progress_counters(array $progress): void {
337 + $fresh = get_option(self::PROGRESS_OPTION, []);
338 + if (!is_array($fresh) || ($fresh['status'] ?? '') !== 'running') {
339 + return;
340 + }
341 +
342 + $fresh['processed'] = $progress['processed'];
343 + $fresh['failed'] = $progress['failed'];
344 + update_option(self::PROGRESS_OPTION, $fresh, false);
345 + }
346 +
347 + /**
247 348 * Process batch tick via WP Cron (fallback when browser tab is closed).
248 349 * Runs a time-limited loop to process as many images as possible within CRON_TIME_LIMIT seconds.
249 350 */
250 351 public static function process_batch_tick(): void {
@@ -254,39 +355,63 @@
254 355 self::complete_batch();
255 356 return;
256 357 }
257 358
258 - $queue = get_option(self::QUEUE_OPTION, []);
259 -
260 - if (empty($queue)) {
261 - self::complete_batch();
359 + if (!self::acquire_lock()) {
360 + // An AJAX tick or another cron process owns the batch.
262 361 return;
263 362 }
264 363
265 - $settings = get_option('metasync_batch_optimize_settings', []);
266 - $start = time();
267 - $time_limit = self::get_safe_time_limit();
364 + Metasync_Image_Converter::set_cache_purge_suppressed(true);
268 365
269 - // Request WordPress's image processing memory limit (typically 256MB) before heavy work
270 - wp_raise_memory_limit('image');
366 + try {
367 + $queue = get_option(self::QUEUE_OPTION, []);
271 368
272 - // Process images until time limit or queue empty.
273 - // Batch size is re-computed each iteration so it adapts as memory fills up.
274 - while (!empty($queue) && (time() - $start) < $time_limit) {
275 - $batch = array_splice($queue, 0, self::get_batch_size());
369 + if (empty($queue)) {
370 + self::complete_batch();
371 + return;
372 + }
276 373
277 - foreach ($batch as $attachment_id) {
278 - self::convert_single($attachment_id, $settings, $progress);
374 + $settings = get_option('metasync_batch_optimize_settings', []);
375 + $start = time();
376 + $time_limit = self::get_safe_time_limit();
377 +
378 + // Request WordPress's image processing memory limit (typically 256MB) before heavy work
379 + wp_raise_memory_limit('image');
380 +
381 + // Process images until time limit or queue empty.
382 + // Batch size is re-computed each iteration so it adapts as memory fills up.
383 + while (!empty($queue) && (time() - $start) < $time_limit) {
384 + // A cancel landing mid-run stops the loop at the next batch.
385 + $current = get_option(self::PROGRESS_OPTION, []);
386 + if (($current['status'] ?? '') !== 'running') {
387 + break;
388 + }
389 +
390 + $batch = array_splice($queue, 0, self::get_batch_size());
391 +
392 + foreach ($batch as $attachment_id) {
393 + self::convert_single($attachment_id, $settings, $progress);
394 + }
395 +
396 + // Save counters after each batch (in case of crash) without
397 + // resurrecting a concurrent cancel/complete.
398 + self::merge_progress_counters($progress);
279 399 }
280 400
281 - // Save progress after each batch (in case of crash)
282 - update_option(self::PROGRESS_OPTION, $progress, false);
283 - }
401 + // Re-check before the final queue write: a cancelled batch must
402 + // not be resurrected by this stale copy.
403 + $status_now = get_option(self::PROGRESS_OPTION, []);
404 + if (($status_now['status'] ?? '') === 'running') {
405 + update_option(self::QUEUE_OPTION, $queue, false);
284 406
285 - update_option(self::QUEUE_OPTION, $queue, false);
286 -
287 - if (empty($queue)) {
288 - self::complete_batch();
407 + if (empty($queue)) {
408 + self::complete_batch();
409 + }
410 + }
411 + } finally {
412 + Metasync_Image_Converter::set_cache_purge_suppressed(false);
413 + self::release_lock();
289 414 }
290 415 }
291 416
292 417 /**
@@ -302,9 +427,9 @@
302 427 }
303 428 } catch (\Throwable $e) {
304 429 $progress['processed']++;
305 430 $progress['failed']++;
306 - error_log('[MetaSync Media Opt] Batch conversion error for attachment ' . $attachment_id . ': ' . $e->getMessage());
431 + error_log('[MetaSync Media Opt] Batch conversion error for attachment ' . $attachment_id . ': ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
307 432 }
308 433 }
309 434
310 435 /**
@@ -311,16 +436,28 @@
311 436 * Mark batch as completed and clean up.
312 437 */
313 438 private static function complete_batch(): void {
314 439 $progress = self::get_progress();
440 + $finalized = false;
315 441 if ($progress['status'] === 'running') {
316 442 $progress['status'] = 'completed';
317 443 update_option(self::PROGRESS_OPTION, $progress, false);
444 + $finalized = true;
318 445 }
319 446
320 447 delete_option(self::QUEUE_OPTION);
321 448 delete_option('metasync_batch_optimize_settings');
449 + delete_option(self::BASELINE_STATS_OPTION);
450 + self::flush_stats_cache();
451 + self::release_lock();
322 452 wp_clear_scheduled_hook(self::CRON_HOOK);
453 +
454 + // Every conversion in the run was purge-suppressed; this single
455 + // purge invalidates all pages that referenced any converted image.
456 + if ($finalized) {
457 + Metasync_Image_Converter::set_cache_purge_suppressed(false);
458 + Metasync_Image_Converter::purge_page_caches();
459 + }
323 460 }
324 461
325 462 /**
326 463 * Query all JPEG/PNG attachment IDs that have not been converted.
@@ -341,9 +478,9 @@
341 478 $ids = [];
342 479 $last_id = 0;
343 480
344 481 do {
345 - $batch = $wpdb->get_col(
482 + $batch = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- batch picker for the one-shot optimization queue — postmeta filter has no WordPress API
346 483 $wpdb->prepare(
347 484 "SELECT p.ID
348 485 FROM {$wpdb->posts} p
349 486 LEFT JOIN {$wpdb->postmeta} pm