PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.26
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.26
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 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / media-optimization / class-media-batch-optimizer.php

class-media-batch-optimizer.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.26, at media-optimization/class-media-batch-optimizer.php

510 lines 17.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Metasync_Media_Batch_Optimizer
4 * AJAX-driven batch optimizer for converting existing media library images.
5 * Uses browser-driven AJAX chaining for speed, with WP Cron as fallback.
6 *
7 * @package Search Atlas SEO
8 * @copyright Copyright (C) 2021-2025, Search Atlas Group - support@searchatlas.com
9 * @since 2.6.0
10 */
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 class Metasync_Media_Batch_Optimizer {
17
18 private const QUEUE_OPTION = 'metasync_batch_optimize_queue';
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;
35 private const CRON_HOOK = 'metasync_media_batch_optimize_cron';
36 private const DEFAULT_BATCH_SIZE = 10;
37 private const MIN_BATCH_SIZE = 2;
38 private const MAX_FILTER_BATCH_SIZE = 50;
39 private const CRON_TIME_LIMIT = 30; // seconds per cron tick
40 private const TIME_SAFETY_MARGIN = 5; // seconds reserved for saving progress
41 private const QUERY_PAGE_SIZE = 1000;
42
43 /**
44 * Memory per image estimate in bytes (30 MB).
45 * Used to calculate how many images can be processed safely.
46 */
47 private const MEMORY_PER_IMAGE = 30 * 1024 * 1024;
48
49 /**
50 * Calculate adaptive batch size based on available memory.
51 *
52 * Uses PHP memory_limit and current usage to determine a safe batch size.
53 * Filterable via 'metasync_batch_optimize_size' for manual override.
54 *
55 * @return int Batch size (clamped between MIN_BATCH_SIZE and DEFAULT_BATCH_SIZE).
56 */
57 public static function get_batch_size(): int {
58 $filtered = apply_filters('metasync_batch_optimize_size', 0);
59 if ($filtered > 0) {
60 return max(self::MIN_BATCH_SIZE, min(self::MAX_FILTER_BATCH_SIZE, (int) $filtered));
61 }
62
63 return static::calculate_batch_size(
64 (string) ini_get('memory_limit'),
65 memory_get_usage(true)
66 );
67 }
68
69 /**
70 * Calculate a safe time limit for cron processing.
71 *
72 * Respects PHP's max_execution_time so the process can save progress
73 * before being killed. Leaves TIME_SAFETY_MARGIN seconds for cleanup.
74 *
75 * @param int $max_execution_time PHP max_execution_time (0 = unlimited). Accepts parameter for testability.
76 * @return int Safe time limit in seconds (minimum 1).
77 */
78 protected static function get_safe_time_limit(int $max_execution_time = -1): int {
79 if ($max_execution_time < 0) {
80 $max_execution_time = (int) ini_get('max_execution_time');
81 }
82
83 // 0 means no limit — use the configured constant.
84 if ($max_execution_time === 0) {
85 return self::CRON_TIME_LIMIT;
86 }
87
88 $safe = $max_execution_time - self::TIME_SAFETY_MARGIN;
89
90 return max(1, min(self::CRON_TIME_LIMIT, $safe));
91 }
92
93 /**
94 * Pure calculation of batch size from memory parameters.
95 *
96 * @param string $memory_limit PHP memory_limit value (e.g. '128M', '-1').
97 * @param int $memory_usage Current memory usage in bytes.
98 * @return int Batch size (clamped between MIN_BATCH_SIZE and DEFAULT_BATCH_SIZE).
99 */
100 protected static function calculate_batch_size(string $memory_limit, int $memory_usage): int {
101 // Unlimited or unreadable memory — use default.
102 if ($memory_limit === '-1' || $memory_limit === '') {
103 return self::DEFAULT_BATCH_SIZE;
104 }
105
106 $memory_limit = trim($memory_limit);
107 if ($memory_limit === '0') {
108 return self::MIN_BATCH_SIZE;
109 }
110
111 $limit_bytes = (int) $memory_limit;
112 $unit = strtolower(substr($memory_limit, -1));
113 $limit_bytes = match ($unit) {
114 'g' => $limit_bytes * 1024 * 1024 * 1024,
115 'm' => $limit_bytes * 1024 * 1024,
116 'k' => $limit_bytes * 1024,
117 default => $limit_bytes,
118 };
119
120 $available = $limit_bytes - $memory_usage;
121
122 if ($available <= 0) {
123 return self::MIN_BATCH_SIZE;
124 }
125
126 $safe_count = (int) floor($available / self::MEMORY_PER_IMAGE);
127
128 return max(self::MIN_BATCH_SIZE, min(self::DEFAULT_BATCH_SIZE, $safe_count));
129 }
130
131 /**
132 * Start a new batch optimization run.
133 *
134 * @param array $settings Media optimization settings.
135 * @return array Progress data.
136 */
137 public static function start_batch(array $settings): array {
138 if (self::is_running()) {
139 return self::get_progress();
140 }
141
142 $ids = self::query_unoptimized_ids();
143
144 if (empty($ids)) {
145 return [
146 'total' => 0,
147 'processed' => 0,
148 'failed' => 0,
149 'status' => 'completed',
150 'started_at' => current_time('mysql'),
151 ];
152 }
153
154 update_option(self::QUEUE_OPTION, $ids, false);
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
164 $progress = [
165 'total' => count($ids),
166 'processed' => 0,
167 'failed' => 0,
168 'status' => 'running',
169 'started_at' => current_time('mysql'),
170 ];
171 update_option(self::PROGRESS_OPTION, $progress, false);
172
173 // Store settings for cron fallback to use
174 update_option('metasync_batch_optimize_settings', $settings, false);
175
176 // Schedule cron as fallback (runs if browser tab is closed)
177 if (!wp_next_scheduled(self::CRON_HOOK)) {
178 wp_schedule_event(time() + 120, 'metasync_every_2_minutes', self::CRON_HOOK);
179 }
180
181 return self::get_progress();
182 }
183
184 /**
185 * Cancel a running batch optimization.
186 */
187 public static function cancel_batch(): void {
188 $progress = self::get_progress();
189 $progress['status'] = 'cancelled';
190 update_option(self::PROGRESS_OPTION, $progress, false);
191
192 delete_option(self::QUEUE_OPTION);
193 delete_option(self::BASELINE_STATS_OPTION);
194 self::flush_stats_cache();
195 self::release_lock();
196 wp_clear_scheduled_hook(self::CRON_HOOK);
197 }
198
199 /**
200 * Get current batch progress.
201 *
202 * @return array Progress data.
203 */
204 public static function get_progress(): array {
205 $default = [
206 'total' => 0,
207 'processed' => 0,
208 'failed' => 0,
209 'status' => 'idle',
210 'started_at' => '',
211 ];
212
213 return wp_parse_args(get_option(self::PROGRESS_OPTION, []), $default);
214 }
215
216 /**
217 * Check if a batch is currently running.
218 */
219 public static function is_running(): bool {
220 $progress = self::get_progress();
221 return $progress['status'] === 'running';
222 }
223
224 /**
225 * Process one batch via AJAX (browser-driven chaining).
226 * Processes one adaptive batch of images and returns updated progress immediately.
227 *
228 * @return array Updated progress data.
229 */
230 public static function process_ajax_tick(): array {
231 $progress = self::get_progress();
232
233 if ($progress['status'] !== 'running') {
234 return $progress;
235 }
236
237 if (!self::acquire_lock()) {
238 // Another tick (cron or browser tab) owns the batch right now.
239 return $progress;
240 }
241
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();
287 }
288
289 return self::get_progress();
290 }
291
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;
305 }
306
307 if ($existing) {
308 delete_option(self::LOCK_OPTION);
309 }
310
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;
340 }
341
342 $fresh['processed'] = $progress['processed'];
343 $fresh['failed'] = $progress['failed'];
344 update_option(self::PROGRESS_OPTION, $fresh, false);
345 }
346
347 /**
348 * Process batch tick via WP Cron (fallback when browser tab is closed).
349 * Runs a time-limited loop to process as many images as possible within CRON_TIME_LIMIT seconds.
350 */
351 public static function process_batch_tick(): void {
352 $progress = self::get_progress();
353
354 if ($progress['status'] !== 'running') {
355 self::complete_batch();
356 return;
357 }
358
359 if (!self::acquire_lock()) {
360 // An AJAX tick or another cron process owns the batch.
361 return;
362 }
363
364 Metasync_Image_Converter::set_cache_purge_suppressed(true);
365
366 try {
367 $queue = get_option(self::QUEUE_OPTION, []);
368
369 if (empty($queue)) {
370 self::complete_batch();
371 return;
372 }
373
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);
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();
414 }
415 }
416
417 /**
418 * Convert a single attachment with error handling.
419 * Catches fatal errors (e.g. memory_limit) per-image so the batch continues.
420 */
421 private static function convert_single(int $attachment_id, array $settings, array &$progress): void {
422 try {
423 $success = Metasync_Image_Converter::convert_attachment($attachment_id, $settings);
424 $progress['processed']++;
425 if (!$success) {
426 $progress['failed']++;
427 }
428 } catch (\Throwable $e) {
429 $progress['processed']++;
430 $progress['failed']++;
431 error_log('[MetaSync Media Opt] Batch conversion error for attachment ' . $attachment_id . ': ' . $e->getMessage());
432 }
433 }
434
435 /**
436 * Mark batch as completed and clean up.
437 */
438 private static function complete_batch(): void {
439 $progress = self::get_progress();
440 $finalized = false;
441 if ($progress['status'] === 'running') {
442 $progress['status'] = 'completed';
443 update_option(self::PROGRESS_OPTION, $progress, false);
444 $finalized = true;
445 }
446
447 delete_option(self::QUEUE_OPTION);
448 delete_option('metasync_batch_optimize_settings');
449 delete_option(self::BASELINE_STATS_OPTION);
450 self::flush_stats_cache();
451 self::release_lock();
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 }
460 }
461
462 /**
463 * Query all JPEG/PNG attachment IDs that have not been converted.
464 *
465 * Uses $wpdb->get_col() with keyset (cursor) pagination instead of
466 * WP_Query/get_posts with posts_per_page=-1, avoiding memory spikes
467 * on sites with large media libraries (50k+ images).
468 *
469 * Keyset pagination (WHERE p.ID > last_id) is used instead of
470 * LIMIT/OFFSET to maintain constant query performance regardless
471 * of page depth and immunity to concurrent inserts/deletes.
472 *
473 * @return int[] Attachment IDs.
474 */
475 private static function query_unoptimized_ids(): array {
476 global $wpdb;
477
478 $ids = [];
479 $last_id = 0;
480
481 do {
482 $batch = $wpdb->get_col(
483 $wpdb->prepare(
484 "SELECT p.ID
485 FROM {$wpdb->posts} p
486 LEFT JOIN {$wpdb->postmeta} pm
487 ON pm.post_id = p.ID AND pm.meta_key = '_metasync_converted_format'
488 WHERE p.post_type = 'attachment'
489 AND p.post_status = 'inherit'
490 AND p.post_mime_type IN ('image/jpeg', 'image/png')
491 AND pm.meta_id IS NULL
492 AND p.ID > %d
493 ORDER BY p.ID ASC
494 LIMIT %d",
495 $last_id,
496 self::QUERY_PAGE_SIZE
497 )
498 );
499
500 if (!empty($batch)) {
501 $int_batch = array_map('intval', $batch);
502 array_push($ids, ...$int_batch);
503 $last_id = end($int_batch);
504 }
505 } while (!empty($batch) && count($batch) === self::QUERY_PAGE_SIZE);
506
507 return $ids;
508 }
509 }
510