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
← All changes | media-optimization/class-media-batch-optimizer.php +317 -54 2.6.9 → 2.7.2 View file →
@@ -16,13 +16,120 @@
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 - private const BATCH_SIZE = 10;
22 - private const CRON_TIME_LIMIT = 30; // seconds per cron tick
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;
23 42
24 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 + /**
25 132 * Start a new batch optimization run.
26 133 *
27 134 * @param array $settings Media optimization settings.
28 135 * @return array Progress data.
@@ -45,8 +152,16 @@
45 152 }
46 153
47 154 update_option(self::QUEUE_OPTION, $ids, false);
48 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 +
49 164 $progress = [
50 165 'total' => count($ids),
51 166 'processed' => 0,
52 167 'failed' => 0,
@@ -74,8 +189,11 @@
74 189 $progress['status'] = 'cancelled';
75 190 update_option(self::PROGRESS_OPTION, $progress, false);
76 191
77 192 delete_option(self::QUEUE_OPTION);
193 + delete_option(self::BASELINE_STATS_OPTION);
194 + self::flush_stats_cache();
195 + self::release_lock();
78 196 wp_clear_scheduled_hook(self::CRON_HOOK);
79 197 }
80 198
81 199 /**
@@ -104,9 +222,9 @@
104 222 }
105 223
106 224 /**
107 225 * Process one batch via AJAX (browser-driven chaining).
108 - * Processes BATCH_SIZE images and returns updated progress immediately.
226 + * Processes one adaptive batch of images and returns updated progress immediately.
109 227 *
110 228 * @return array Updated progress data.
111 229 */
112 230 public static function process_ajax_tick(): array {
@@ -115,37 +233,116 @@
115 233 if ($progress['status'] !== 'running') {
116 234 return $progress;
117 235 }
118 236
119 - $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 + }
120 241
121 - if (empty($queue)) {
122 - self::complete_batch();
123 - 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();
124 287 }
125 288
126 - $settings = get_option('metasync_batch_optimize_settings', []);
127 - $batch = array_splice($queue, 0, self::BATCH_SIZE);
289 + return self::get_progress();
290 + }
128 291
129 - foreach ($batch as $attachment_id) {
130 - // Re-check status in case cancel was triggered mid-batch
131 - $current = get_option(self::PROGRESS_OPTION, []);
132 - if (($current['status'] ?? '') !== 'running') {
133 - break;
134 - }
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);
135 302
136 - self::convert_single($attachment_id, $settings, $progress);
303 + if ($existing && (time() - (int) $existing) < self::LOCK_STALE_AFTER) {
304 + return false;
137 305 }
138 306
139 - update_option(self::QUEUE_OPTION, $queue, false);
140 - update_option(self::PROGRESS_OPTION, $progress, false);
307 + if ($existing) {
308 + delete_option(self::LOCK_OPTION);
309 + }
141 310
142 - if (empty($queue)) {
143 - self::complete_batch();
144 - 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;
145 340 }
146 341
147 - return $progress;
342 + $fresh['processed'] = $progress['processed'];
343 + $fresh['failed'] = $progress['failed'];
344 + update_option(self::PROGRESS_OPTION, $fresh, false);
148 345 }
149 346
150 347 /**
151 348 * Process batch tick via WP Cron (fallback when browser tab is closed).
@@ -158,34 +355,63 @@
158 355 self::complete_batch();
159 356 return;
160 357 }
161 358
162 - $queue = get_option(self::QUEUE_OPTION, []);
163 -
164 - if (empty($queue)) {
165 - self::complete_batch();
359 + if (!self::acquire_lock()) {
360 + // An AJAX tick or another cron process owns the batch.
166 361 return;
167 362 }
168 363
169 - $settings = get_option('metasync_batch_optimize_settings', []);
170 - $start = time();
364 + Metasync_Image_Converter::set_cache_purge_suppressed(true);
171 365
172 - // Process images until time limit or queue empty
173 - while (!empty($queue) && (time() - $start) < self::CRON_TIME_LIMIT) {
174 - $batch = array_splice($queue, 0, self::BATCH_SIZE);
366 + try {
367 + $queue = get_option(self::QUEUE_OPTION, []);
175 368
176 - foreach ($batch as $attachment_id) {
177 - self::convert_single($attachment_id, $settings, $progress);
369 + if (empty($queue)) {
370 + self::complete_batch();
371 + return;
178 372 }
179 373
180 - // Save progress after each batch (in case of crash)
181 - update_option(self::PROGRESS_OPTION, $progress, false);
182 - }
374 + $settings = get_option('metasync_batch_optimize_settings', []);
375 + $start = time();
376 + $time_limit = self::get_safe_time_limit();
183 377
184 - 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');
185 380
186 - if (empty($queue)) {
187 - 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();
188 414 }
189 415 }
190 416
191 417 /**
@@ -201,9 +427,9 @@
201 427 }
202 428 } catch (\Throwable $e) {
203 429 $progress['processed']++;
204 430 $progress['failed']++;
205 - 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
206 432 }
207 433 }
208 434
209 435 /**
@@ -210,37 +436,74 @@
210 436 * Mark batch as completed and clean up.
211 437 */
212 438 private static function complete_batch(): void {
213 439 $progress = self::get_progress();
440 + $finalized = false;
214 441 if ($progress['status'] === 'running') {
215 442 $progress['status'] = 'completed';
216 443 update_option(self::PROGRESS_OPTION, $progress, false);
444 + $finalized = true;
217 445 }
218 446
219 447 delete_option(self::QUEUE_OPTION);
220 448 delete_option('metasync_batch_optimize_settings');
449 + delete_option(self::BASELINE_STATS_OPTION);
450 + self::flush_stats_cache();
451 + self::release_lock();
221 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 + }
222 460 }
223 461
224 462 /**
225 463 * Query all JPEG/PNG attachment IDs that have not been converted.
226 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 + *
227 473 * @return int[] Attachment IDs.
228 474 */
229 475 private static function query_unoptimized_ids(): array {
230 - $args = [
231 - 'post_type' => 'attachment',
232 - 'post_status' => 'inherit',
233 - 'post_mime_type' => ['image/jpeg', 'image/png'],
234 - 'posts_per_page' => -1,
235 - 'fields' => 'ids',
236 - 'meta_query' => [
237 - [
238 - 'key' => '_metasync_converted_format',
239 - 'compare' => 'NOT EXISTS',
240 - ],
241 - ],
242 - ];
476 + global $wpdb;
243 477
244 - return get_posts($args);
478 + $ids = [];
479 + $last_id = 0;
480 +
481 + do {
482 + $batch = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- batch picker for the one-shot optimization queue — postmeta filter has no WordPress API
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;
245 508 }
246 509 }