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 +184 -40 2.6.11 → 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,37 +233,116 @@
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();
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);
245 +
246 + try {
247 + $queue = get_option(self::QUEUE_OPTION, []);
248 +
249 + if (empty($queue)) {
250 + self::complete_batch();
251 + return self::get_progress();
252 + }
253 +
254 + $settings = get_option('metasync_batch_optimize_settings', []);
255 +
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);
269 + }
270 +
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();
216 287 }
217 288
218 - $settings = get_option('metasync_batch_optimize_settings', []);
219 - $batch = array_splice($queue, 0, self::get_batch_size());
289 + return self::get_progress();
290 + }
220 291
221 - foreach ($batch as $attachment_id) {
222 - // Re-check status in case cancel was triggered mid-batch
223 - $current = get_option(self::PROGRESS_OPTION, []);
224 - if (($current['status'] ?? '') !== 'running') {
225 - break;
226 - }
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);
227 302
228 - self::convert_single($attachment_id, $settings, $progress);
303 + if ($existing && (time() - (int) $existing) < self::LOCK_STALE_AFTER) {
304 + return false;
229 305 }
230 306
231 - update_option(self::QUEUE_OPTION, $queue, false);
232 - update_option(self::PROGRESS_OPTION, $progress, false);
307 + if ($existing) {
308 + delete_option(self::LOCK_OPTION);
309 + }
233 310
234 - if (empty($queue)) {
235 - self::complete_batch();
236 - return self::get_progress();
311 + return (bool) add_option(self::LOCK_OPTION, time(), '', false);
312 + }
313 +
314 + private static function release_lock(): void {
315 + delete_option(self::LOCK_OPTION);
316 + }
317 +
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;
237 340 }
238 341
239 - return $progress;
342 + $fresh['processed'] = $progress['processed'];
343 + $fresh['failed'] = $progress['failed'];
344 + update_option(self::PROGRESS_OPTION, $fresh, false);
240 345 }
241 346
242 347 /**
243 348 * Process batch tick via WP Cron (fallback when browser tab is closed).
@@ -250,36 +355,63 @@
250 355 self::complete_batch();
251 356 return;
252 357 }
253 358
254 - $queue = get_option(self::QUEUE_OPTION, []);
255 -
256 - if (empty($queue)) {
257 - self::complete_batch();
359 + if (!self::acquire_lock()) {
360 + // An AJAX tick or another cron process owns the batch.
258 361 return;
259 362 }
260 363
261 - $settings = get_option('metasync_batch_optimize_settings', []);
262 - $start = time();
263 - $time_limit = self::get_safe_time_limit();
364 + Metasync_Image_Converter::set_cache_purge_suppressed(true);
264 365
265 - // Process images until time limit or queue empty.
266 - // Batch size is re-computed each iteration so it adapts as memory fills up.
267 - while (!empty($queue) && (time() - $start) < $time_limit) {
268 - $batch = array_splice($queue, 0, self::get_batch_size());
366 + try {
367 + $queue = get_option(self::QUEUE_OPTION, []);
269 368
270 - foreach ($batch as $attachment_id) {
271 - self::convert_single($attachment_id, $settings, $progress);
369 + if (empty($queue)) {
370 + self::complete_batch();
371 + return;
272 372 }
273 373
274 - // Save progress after each batch (in case of crash)
275 - update_option(self::PROGRESS_OPTION, $progress, false);
276 - }
374 + $settings = get_option('metasync_batch_optimize_settings', []);
375 + $start = time();
376 + $time_limit = self::get_safe_time_limit();
277 377
278 - update_option(self::QUEUE_OPTION, $queue, false);
378 + // Request WordPress's image processing memory limit (typically 256MB) before heavy work
379 + wp_raise_memory_limit('image');
279 380
280 - if (empty($queue)) {
281 - self::complete_batch();
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);
399 + }
400 +
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);
406 +
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();
282 414 }
283 415 }
284 416
285 417 /**
@@ -295,9 +427,9 @@
295 427 }
296 428 } catch (\Throwable $e) {
297 429 $progress['processed']++;
298 430 $progress['failed']++;
299 - 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
300 432 }
301 433 }
302 434
303 435 /**
@@ -304,16 +436,28 @@
304 436 * Mark batch as completed and clean up.
305 437 */
306 438 private static function complete_batch(): void {
307 439 $progress = self::get_progress();
440 + $finalized = false;
308 441 if ($progress['status'] === 'running') {
309 442 $progress['status'] = 'completed';
310 443 update_option(self::PROGRESS_OPTION, $progress, false);
444 + $finalized = true;
311 445 }
312 446
313 447 delete_option(self::QUEUE_OPTION);
314 448 delete_option('metasync_batch_optimize_settings');
449 + delete_option(self::BASELINE_STATS_OPTION);
450 + self::flush_stats_cache();
451 + self::release_lock();
315 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 + }
316 460 }
317 461
318 462 /**
319 463 * Query all JPEG/PNG attachment IDs that have not been converted.
@@ -334,9 +478,9 @@
334 478 $ids = [];
335 479 $last_id = 0;
336 480
337 481 do {
338 - $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
339 483 $wpdb->prepare(
340 484 "SELECT p.ID
341 485 FROM {$wpdb->posts} p
342 486 LEFT JOIN {$wpdb->postmeta} pm