PluginProbe
Media Cloud Sync / 1.3.11
Media Cloud Sync v1.3.11
1.4.0 1.3.12 1.3.11 1.3.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.2.0 1.2.10 1.2.11 1.2.12 1.2.13 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 1.3.0 All 34 releases
media-cloud-sync / includes / base / bg-runner.php

bg-runner.php in Media Cloud Sync 1.3.11, at includes/base/bg-runner.php

493 lines 17.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace Dudlewebs\WPMCS;
3
4 defined('ABSPATH') || exit;
5
6 class BGRunner {
7 private static $instance = null;
8 private $meta_key = 'dw_bg_runner_meta';
9 private $transient_key = 'dw_bg_runner_state';
10 private $control_transient_key = 'dw_bg_runner_control';
11 private $action_hook = 'dw_bg_runner_cron';
12 private $callback_map = []; // type => callback
13 private $state_cache = null; // cached state array
14 private static $lock_duration = 5 * 60; // lock duration (seconds)
15
16 private function __construct() {
17 $this->meta_key = WPMCS_TOKEN . '_bg_runner_meta';
18 $this->control_transient_key = WPMCS_TOKEN . '_bg_runner_control';
19 $this->action_hook = WPMCS_TOKEN . '_bg_runner_cron';
20
21 add_filter('cron_schedules', [$this, 'add_cron_schedules']);
22 add_action($this->action_hook, [$this, 'run_all']);
23
24 // Ensure cron always exists
25 if (!wp_next_scheduled($this->action_hook)) {
26 wp_schedule_event(time(), 'every_minute', $this->action_hook);
27 }
28
29 // Force remove lock
30 add_action('init', [$this, 'force_remove_lock']);
31 }
32
33 public static function instance() {
34 if (!self::$instance) self::$instance = new self();
35 return self::$instance;
36 }
37
38 public function set_callback(string $type, callable $callback) {
39 $this->callback_map[$type] = $callback;
40 }
41
42 public function start(string $type, int $iterations) {
43 $state = $this->get_state();
44 $state[$type]['status'] = 'running';
45 $state[$type]['iterations_total'] = $iterations;
46 $state[$type]['iterations_done'] = 0;
47 $state[$type]['failed_count'] = 0;
48 $this->save_state($state);
49
50 // reset control flags
51 $control = $this->get_control();
52 $control[$type] = $this->default_control_state();
53 $this->save_control($control);
54 }
55
56 public function pause(string $type) {
57 $control = $this->get_control();
58 $control[$type]['pause_requested'] = true;
59 $control[$type]['time'] = time();
60 $this->save_control($control);
61 }
62
63 public function stop(string $type) {
64 $control = $this->get_control();
65 $control[$type]['stop_requested'] = true;
66 $control[$type]['time'] = time();
67 $this->save_control($control);
68 }
69
70 public function resume(string $type) {
71 $control = $this->get_control();
72 $control[$type]['pause_requested'] = false;
73 $control[$type]['stop_requested'] = false;
74 $control[$type]['time'] = time();
75 $this->save_control($control);
76
77 $state = $this->get_state();
78 if (in_array($state[$type]['status'] ?? 'stopped', ['paused','stopped'], true)) {
79 $state[$type]['status'] = 'running';
80 $this->save_state($state);
81 }
82 }
83
84 public function status(string $type) {
85 $state = $this->get_state();
86 $s = $state[$type] ?? $this->default_type_state();
87 $control = $this->get_control();
88 $c = $control[$type] ?? $this->default_control_state();
89
90 // if running but pause/stop requested and lock expired, update state
91 if(
92 (( $s['status'] ?? 'stopped') === 'running' ) &&
93 ( isset($c['time']) && ( $c['time'] > 0 ) && ( ( time() - $c['time'] ) > self::$lock_duration ) ) &&
94 ($c['pause_requested'] === true || $c['stop_requested'] === true)
95 ) {
96 if($c['stop_requested'] === true) {
97 $s = $this->default_type_state();
98 $c = $this->default_control_state();
99 } else {
100 $s['status'] = 'paused';
101 $c['pause_requested'] = false;
102 }
103 $state[$type] = $s;
104 $this->save_state($state);
105 }
106
107
108 return $this->format_status($type, $state);
109 }
110
111 public function all_statuses() {
112 $state = $this->get_state();
113 $control = $this->get_control();
114 $statuses = [];
115 foreach ($state as $type => $s) {
116 $c = $control[$type] ?? $this->default_control_state();
117 // if running but pause/stop requested and lock expired, update state
118 if(
119 (( $s['status'] ?? 'stopped') === 'running' ) &&
120 ( isset($c['time']) && ( $c['time'] > 0 ) && ( ( time() - $c['time'] ) > self::$lock_duration ) ) &&
121 ($c['pause_requested'] === true || $c['stop_requested'] === true)
122 ) {
123 if($c['stop_requested'] === true) {
124 $s = $this->default_type_state();
125 $c = $this->default_control_state();
126 } else {
127 $s['status'] = 'paused';
128 $c['pause_requested'] = false;
129 }
130 $state[$type] = $s;
131 $this->save_state($state);
132 }
133
134
135 $statuses[$type] = $this->format_status($type, $state);
136 }
137 return $statuses;
138 }
139
140 private function format_status(string $type, array &$state) {
141 $s = $state[$type] ?? $this->default_type_state();
142 $control = $this->get_control();
143 $c = $control[$type] ?? $this->default_control_state();
144
145 $total = $s['iterations_total'] ?? 0;
146 $done = $s['iterations_done'] ?? 0;
147 $failed_count = $s['failed_count'] ?? 0;
148 $remaining = max(0, $total - $done);
149 $percentage = $total > 0 ? round(($done / $total) * 100, 2) : 0;
150
151 $status = [
152 'total' => $total,
153 'processed' => $done,
154 'failed' => $failed_count,
155 'remaining' => $remaining,
156 'percentage' => $percentage,
157 'status' => $s['status'],
158 'last_run' => $s['last_run'] ?? 0,
159 'pause_requested' => $c['pause_requested'] ?? false,
160 'stop_requested' => $c['stop_requested'] ?? false,
161 ];
162
163 // Report completed if marked so in state
164 if (!empty($s['completed'])) {
165 $status['percentage'] = 100;
166 $status['remaining'] = 0;
167 $status['status'] = 'completed';
168 }
169
170 // Reset state and control if completed or stopped before finishing
171 if(
172 !empty($s['completed']) ||
173 (
174 $status['status'] === 'stopped' &&
175 $s['iterations_done'] < $s['iterations_total']
176 )
177 ) {
178 // reset type state after reporting completed
179 $state[$type] = $this->default_type_state();
180 $this->save_state($state);
181 $control[$type] = $this->default_control_state();
182 $this->save_control($control);
183 }
184
185 return $status;
186 }
187
188 public function run_all() {
189 $state = $this->get_state();
190 foreach ($state as $type => $s) {
191 // refresh state for each type to pick up changes made during processing of other types
192 $latest_state = $this->get_state(true);
193 $s = $latest_state[$type] ?? $s;
194
195 if (($s['status'] ?? 'stopped') !== 'running') {
196 $latest_control = $this->get_control();
197 $c = $latest_control[$type] ?? $this->default_control_state();
198
199 if ($c['stop_requested'] ?? false) {
200 $s['status'] = 'stopped';
201 $latest_state[$type] = $s;
202 $latest_control[$type]['stop_requested'] = false;
203 $this->save_state($latest_state);
204 $this->save_control($latest_control);
205 }
206 continue;
207 }
208
209 if (!isset($this->callback_map[$type])) continue;
210 if ($this->is_locked($s)) continue;
211
212 // lock, save and process
213 $this->lock($latest_state, $type);
214 $this->save_state($latest_state);
215
216 $this->process_iterations($type, $this->callback_map[$type]);
217
218 // Fetch state again to ensure we have the latest and then unlock
219 $latest_state = $this->get_state(true);
220 $this->unlock($latest_state, $type);
221 $this->save_state($latest_state);
222 }
223 }
224
225 private function process_iterations(string $type, callable $callback) {
226 $s = $this->get_state_counts($type, true);
227
228 $max_exec = (int) ini_get('max_execution_time');
229 $max_exec = $max_exec !== 0 ? $max_exec : 55;
230 $max_mem = ini_get('memory_limit') ? $this->return_bytes(ini_get('memory_limit')) : 128 * 1024 * 1024;
231 $memory_safe = $max_mem * 0.80;
232 $time_safe = $max_exec * 0.80;
233 $start_time = microtime(true);
234 $max_per_run = 50;
235 $iterations_count = 0;
236
237 while ($s['iterations_done'] < $s['iterations_total'] && $iterations_count < $max_per_run) {
238 // limit iterations per run to avoid long blocking
239 $iterations_count++;
240
241 // force refresh so pause/stop requests are seen immediately
242 $latest_control = get_transient($this->control_transient_key) ?: [];
243 $latest_c = $latest_control[$type] ?? $this->default_control_state();
244
245 // stop if paused or stopped
246 if (!empty($latest_c['pause_requested']) || !empty($latest_c['stop_requested'])) {
247 break;
248 }
249
250 // check memory and time using more precise calls
251 if ((memory_get_usage(false) > $memory_safe) || ((microtime(true) - $start_time) > $time_safe)) {
252 break;
253 }
254
255 try {
256 $already_done = $s['iterations_done'];
257 $result = call_user_func($callback, $already_done, $s['iterations_total']);
258 if ($result !== true) {
259 $s['failed_count']++;
260 }
261 } catch (\Exception $e) {
262 $s['failed_count']++;
263 }
264
265 $s['iterations_done']++;
266
267 // Update state after each iteration to ensure progress is saved
268 $this->update_state_counts($type, $s, true, false);
269
270 if($iterations_count % 10 === 0) {
271 gc_collect_cycles();
272 }
273 }
274
275 // Get latest state again
276 $latest_state = $this->get_state(true);
277 $latest_s = $latest_state[$type];
278 $latest_control = $this->get_control();
279 $latest_c = $latest_control[$type] ?? $this->default_control_state();
280 $is_completed = ($latest_s['iterations_done'] ?? 0) >= ($latest_s['iterations_total'] ?? 0);
281
282 // Update state if completed, paused or stopped
283 if ($is_completed || ($latest_c['stop_requested'] ?? false) || ($latest_c['pause_requested'] ?? false)) {
284 $latest_s['status'] = $is_completed || ($latest_c['stop_requested'] ?? false) ? 'stopped' : 'paused';
285 $latest_s['completed'] = $is_completed;
286 $latest_state[$type] = $latest_s;
287 $this->save_state($latest_state);
288
289 $latest_control[$type]['pause_requested'] = false;
290 $latest_control[$type]['stop_requested'] = false;
291 $latest_control[$type]['time'] = time();
292 $this->save_control($latest_control);
293 }
294
295 gc_collect_cycles();
296 }
297
298 private function lock(array &$state, string $type) {
299 $state[$type]['lock_until'] = time() + self::$lock_duration;
300 }
301
302 private function unlock(array &$state, string $type) {
303 $state[$type]['lock_until'] = 0;
304 $state[$type]['last_run'] = time();
305 }
306
307 private function is_locked(array $s) {
308 return ($s['lock_until'] ?? 0) && time() < $s['lock_until'];
309 }
310
311 private function return_bytes($val) {
312 $val = trim($val);
313 $last = strtolower($val[strlen($val)-1] ?? '');
314 $num = (int) $val;
315 switch ($last) {
316 case 'g': $num *= 1024 * 1024 * 1024; break;
317 case 'm': $num *= 1024 * 1024; break;
318 case 'k': $num *= 1024; break;
319 }
320 return $num;
321 }
322
323 public function add_cron_schedules($schedules) {
324 $schedules['every_minute'] = [
325 'interval' => 60,
326 'display' => 'Every Minute'
327 ];
328 return $schedules;
329 }
330
331
332 /**
333 * Get only the count-related fields of the state for a given type.
334 *
335 * @param string $type The type key in the state array.
336 * @param bool $force Reload state from transient/option instead of cache.
337 *
338 * @return array {
339 * @type int $iterations_total
340 * @type int $iterations_done
341 * @type int $failed_count
342 * }
343 */
344 private function get_state_counts($type, $force = false) {
345 $state = $this->get_state($force);
346
347 if (!isset($state[$type])) {
348 return [
349 'iterations_total' => 0,
350 'iterations_done' => 0,
351 'failed_count' => 0,
352 ];
353 }
354
355 return [
356 'iterations_total' => (int) ($state[$type]['iterations_total'] ?? 0),
357 'iterations_done' => (int) ($state[$type]['iterations_done'] ?? 0),
358 'failed_count' => (int) ($state[$type]['failed_count'] ?? 0),
359 ];
360 }
361
362
363 /**
364 * Update only the count-related fields for a given type.
365 *
366 * @param string $type The type key in the state array.
367 * @param array $counts {
368 * @type int $iterations_total
369 * @type int $iterations_done
370 * @type int $failed_count
371 * }
372 * @param bool $update_transient Whether to update transient.
373 * @param bool $update_options Whether to update options.
374 */
375 private function update_state_counts($type, array $counts, $update_transient = true, $update_options = true) {
376 $state = $this->get_state( true ); // full state array
377
378 if (!isset($state[$type])) {
379 $state[$type] = $this->default_type_state();
380 }
381
382 // update only the count fields
383 if (isset($counts['iterations_total'])) {
384 $state[$type]['iterations_total'] = (int) $counts['iterations_total'];
385 }
386 if (isset($counts['iterations_done'])) {
387 $state[$type]['iterations_done'] = (int) $counts['iterations_done'];
388 }
389 if (isset($counts['failed_count'])) {
390 $state[$type]['failed_count'] = (int) $counts['failed_count'];
391 }
392
393 $this->save_state($state, $update_transient, $update_options);
394 }
395
396
397 /**
398 * Get the state of the bg runner.
399 *
400 * If $force is true, the state will be reloaded from the transient or option.
401 * If $force is false and the state cache already exists, the cached state will be returned.
402 *
403 * If the state is not available from the transient, it will be loaded from the option.
404 * The state will then be cached and set as a transient for one week.
405 *
406 * @param bool $force Reload the state from the transient or option.
407 *
408 * @return array The state of the bg runner.
409 */
410 private function get_state($force = false) {
411 // if not forcing and cache already exists, return cached
412 if (!$force && $this->state_cache !== null) {
413 return $this->state_cache;
414 }
415
416 // try transient first
417 $state = get_transient($this->transient_key);
418
419 if ($state !== false) {
420 if ($force) {
421 // update cache with the fresh transient
422 $this->state_cache = $state;
423 }
424 return $state;
425 }
426
427 // fallback to option if transient missing
428 $state = get_option($this->meta_key, []);
429 $this->state_cache = $state;
430 set_transient($this->transient_key, $state, WEEK_IN_SECONDS);
431
432 return $state;
433 }
434
435 private function save_state($state, $update_transient = true, $update_options = true) {
436 // Only write if changed to reduce option churn
437 if ($this->state_cache === null || $this->state_cache !== $state) {
438 $this->state_cache = $state;
439 if ($update_transient) set_transient($this->transient_key, $state, WEEK_IN_SECONDS);
440 if ($update_options) update_option($this->meta_key, $state, false);
441 }
442 }
443
444 private function default_type_state() {
445 return [
446 'lock_until' => 0,
447 'status' => 'stopped',
448 'iterations_total' => 0,
449 'iterations_done' => 0,
450 'failed_count' => 0,
451 'completed' => false,
452 'last_run' => 0,
453 ];
454 }
455
456 private function get_control() {
457 return get_transient($this->control_transient_key) ?: [];
458 }
459
460 private function save_control($control) {
461 set_transient($this->control_transient_key, $control, WEEK_IN_SECONDS);
462 }
463
464 private function default_control_state() {
465 return [
466 'pause_requested' => false,
467 'stop_requested' => false,
468 'time' => time(),
469 ];
470 }
471
472 /**
473 * Forces removal of the bg runner lock. This is a debug utility and should not be used in production.
474 * The lock is removed when the query string parameter 'force_reset_sync' is set to '1'.
475 * The purpose of this function is to allow for easy reset of the bg runner lock in debug environments.
476 * It is not intended for use in production and can potentially cause issues with the bg runner's operation.
477 */
478 public function force_remove_lock() {
479 if (isset($_GET['force_reset_sync']) && $_GET['force_reset_sync'] == '1') {
480 if (! current_user_can('manage_options')) {
481 return;
482 }
483
484 delete_transient($this->transient_key);
485 delete_option($this->meta_key);
486 delete_transient($this->control_transient_key);
487
488 if (! defined('DOING_AJAX')) {
489 wp_die('Locks removed successfully.');
490 }
491 }
492 }
493 }