PluginProbe
Media Cloud Sync / 1.4.1
Media Cloud Sync v1.4.1
1.4.1 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 All 35 releases
← All changes | includes/base/bg-runner.php +358 -185 1.3.111.4.1 View file →
@@ -7,16 +7,24 @@
7 7 private static $instance = null;
8 8 private $meta_key = 'dw_bg_runner_meta';
9 9 private $transient_key = 'dw_bg_runner_state';
10 10 private $control_transient_key = 'dw_bg_runner_control';
11 + // Unlike $meta_key's state rows, never cleared on idle — see get_type_settings().
12 + private $settings_meta_key = 'dw_bg_runner_settings';
11 13 private $action_hook = 'dw_bg_runner_cron';
12 14 private $callback_map = []; // type => callback
13 - private $state_cache = null; // cached state array
15 + private $tick_supported = []; // type => bool, whether ajax-driven ticking is wired up for this type
16 + private $state_cache = []; // type => cached state array for that type
14 17 private static $lock_duration = 5 * 60; // lock duration (seconds)
18 + // How long a tick-supported type can go without a successful pass before cron treats
19 + // it as abandoned (e.g. laptop lid closed mid-sync) and steps in as a safety net.
20 + private static $ajax_abandoned_threshold = 120;
15 21
16 22 private function __construct() {
17 23 $this->meta_key = WPMCS_TOKEN . '_bg_runner_meta';
24 + $this->transient_key = WPMCS_TOKEN . '_bg_runner_state';
18 25 $this->control_transient_key = WPMCS_TOKEN . '_bg_runner_control';
26 + $this->settings_meta_key = Schema::getConstant('BG_RUNNER_SETTINGS_KEY');
19 27 $this->action_hook = WPMCS_TOKEN . '_bg_runner_cron';
20 28
21 29 add_filter('cron_schedules', [$this, 'add_cron_schedules']);
22 30 add_action($this->action_hook, [$this, 'run_all']);
@@ -24,9 +32,9 @@
24 32 // Ensure cron always exists
25 33 if (!wp_next_scheduled($this->action_hook)) {
26 34 wp_schedule_event(time(), 'every_minute', $this->action_hook);
27 35 }
28 -
36 +
29 37 // Force remove lock
30 38 add_action('init', [$this, 'force_remove_lock']);
31 39 }
32 40
@@ -34,115 +42,123 @@
34 42 if (!self::$instance) self::$instance = new self();
35 43 return self::$instance;
36 44 }
37 45
38 - public function set_callback(string $type, callable $callback) {
46 + /**
47 + * Register a job type's per-iteration callback.
48 + *
49 + * @param string $type Job type key.
50 + * @param callable $callback Called with ($offset, $total_iterations) once per iteration.
51 + * @param bool $supports_tick Whether this type also supports being driven synchronously
52 + * via run_all($type, ...) from a REST request (ajax/mixed sync
53 + * mode), in addition to the WP-Cron tick.
54 + */
55 + public function set_callback(string $type, callable $callback, bool $supports_tick = false) {
56 + // Empty type would collide with any other empty-type registration on the same rows.
57 + if ($type === '') {
58 + return;
59 + }
60 +
39 61 $this->callback_map[$type] = $callback;
62 + $this->tick_supported[$type] = $supports_tick;
40 63 }
41 64
42 65 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);
66 + $s = $this->get_type_state($type);
67 + $s['status'] = 'running';
68 + $s['iterations_total'] = $iterations;
69 + $s['iterations_done'] = 0;
70 + $s['failed_count'] = 0;
71 + $s['last_run'] = time();
72 + $this->save_type_state($type, $s);
49 73
50 74 // reset control flags
51 - $control = $this->get_control();
52 - $control[$type] = $this->default_control_state();
53 - $this->save_control($control);
75 + $this->save_type_control($type, $this->default_control_state());
76 +
77 + do_action("wpmcs_{$type}_started", $type);
54 78 }
55 79
56 80 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);
81 + $c = $this->get_type_control($type);
82 + $c['pause_requested'] = true;
83 + $c['time'] = time();
84 + $this->save_type_control($type, $c);
61 85 }
62 86
63 87 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);
88 + $c = $this->get_type_control($type);
89 + $c['stop_requested'] = true;
90 + $c['time'] = time();
91 + $this->save_type_control($type, $c);
68 92 }
69 93
70 94 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);
95 + $c = $this->get_type_control($type);
96 + $c['pause_requested'] = false;
97 + $c['stop_requested'] = false;
98 + $c['time'] = time();
99 + $this->save_type_control($type, $c);
76 100
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);
101 + $s = $this->get_type_state($type);
102 + if (in_array($s['status'] ?? 'stopped', ['paused','stopped'], true)) {
103 + $s['status'] = 'running';
104 + $s['last_run'] = time();
105 + $this->save_type_state($type, $s);
81 106 }
82 107 }
83 108
84 109 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();
110 + $s = $this->get_type_state($type);
111 + $c = $this->get_type_control($type);
89 112
90 113 // if running but pause/stop requested and lock expired, update state
91 - if(
92 - (( $s['status'] ?? 'stopped') === 'running' ) &&
114 + if(
115 + (( $s['status'] ?? 'stopped') === 'running' ) &&
93 116 ( isset($c['time']) && ( $c['time'] > 0 ) && ( ( time() - $c['time'] ) > self::$lock_duration ) ) &&
94 117 ($c['pause_requested'] === true || $c['stop_requested'] === true)
95 118 ) {
96 - if($c['stop_requested'] === true) {
119 + $was_stop_requested = $c['stop_requested'] === true;
120 +
121 + if($was_stop_requested) {
97 122 $s = $this->default_type_state();
98 - $c = $this->default_control_state();
99 123 } else {
100 124 $s['status'] = 'paused';
101 - $c['pause_requested'] = false;
102 125 }
103 - $state[$type] = $s;
104 - $this->save_state($state);
126 + $this->save_type_state($type, $s);
127 +
128 + // Consume the request this self-heal just acted on.
129 + $c['pause_requested'] = false;
130 + $c['stop_requested'] = false;
131 + $c['time'] = time();
132 + $this->save_type_control($type, $c);
133 +
134 + // Stale-lock self-heal is a second cancellation path, separate from
135 + // process_iterations()'s own — a job cancelled via a stalled lock
136 + // (e.g. cron stopped ticking) still needs to fire the same hook.
137 + if ($was_stop_requested) {
138 + do_action("wpmcs_{$type}_cancelled", $type);
139 + }
105 140 }
106 -
107 141
108 - return $this->format_status($type, $state);
142 + return $this->format_status($type, $s);
109 143 }
110 144
111 145 public function all_statuses() {
112 - $state = $this->get_state();
113 - $control = $this->get_control();
114 146 $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);
147 + foreach (array_keys($this->callback_map) as $type) {
148 + $statuses[$type] = $this->status($type);
136 149 }
137 150 return $statuses;
138 151 }
139 152
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();
153 + /** Job type keys registered so far via set_callback() — e.g. for an integration to hook every type's lifecycle actions. */
154 + public function get_registered_types() {
155 + return array_keys($this->callback_map);
156 + }
144 157
158 + private function format_status(string $type, array $s) {
159 + $c = $this->get_type_control($type);
160 +
145 161 $total = $s['iterations_total'] ?? 0;
146 162 $done = $s['iterations_done'] ?? 0;
147 163 $failed_count = $s['failed_count'] ?? 0;
148 164 $remaining = max(0, $total - $done);
@@ -157,8 +173,9 @@
157 173 'status' => $s['status'],
158 174 'last_run' => $s['last_run'] ?? 0,
159 175 'pause_requested' => $c['pause_requested'] ?? false,
160 176 'stop_requested' => $c['stop_requested'] ?? false,
177 + 'sync_method' => $this->get_sync_method($type),
161 178 ];
162 179
163 180 // Report completed if marked so in state
164 181 if (!empty($s['completed'])) {
@@ -166,73 +183,165 @@
166 183 $status['remaining'] = 0;
167 184 $status['status'] = 'completed';
168 185 }
169 186
170 - // Reset state and control if completed or stopped before finishing
187 + // Once idle, delete state/control rows instead of resetting in place — a missing
188 + // row and a freshly-defaulted one read back identically.
171 189 if(
172 - !empty($s['completed']) ||
190 + !empty($s['completed']) ||
173 191 (
174 - $status['status'] === 'stopped' &&
192 + $status['status'] === 'stopped' &&
175 193 $s['iterations_done'] < $s['iterations_total']
176 - )
194 + )
177 195 ) {
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);
196 + $this->delete_type_state($type);
197 + $this->delete_type_control($type);
183 198 }
184 199
185 200 return $status;
186 201 }
187 202
188 - public function run_all() {
189 - $state = $this->get_state();
190 - foreach ($state as $type => $s) {
203 + /**
204 + * Process registered job types.
205 + *
206 + * Called by WP-Cron with no args (processes every running type, up to 50
207 + * iterations each). Also called synchronously from a REST request with a
208 + * specific $type and a small $max_per_run to drive ajax/mixed sync mode.
209 + *
210 + * @param string|null $only_type If set, only this type is processed (all others are
211 + * left for the next cron tick to own).
212 + * @param int $max_per_run Max iterations to process per type in this call.
213 + */
214 + public function run_all(?string $only_type = null, int $max_per_run = 50) {
215 + $max_per_run = max(1, $max_per_run);
216 +
217 + $max_exec = (int) ini_get('max_execution_time');
218 + $max_exec = $max_exec !== 0 ? $max_exec : 55;
219 + $time_safe = $max_exec * 0.80;
220 + $run_start_time = microtime(true);
221 +
222 + // Registered types are the source of truth now that state/control/lock live per type.
223 + foreach (array_keys($this->callback_map) as $type) {
224 + if ($only_type !== null && $type !== $only_type) continue;
225 +
226 + // shared budget for this whole tick, not per type
227 + if ((microtime(true) - $run_start_time) > $time_safe) {
228 + break;
229 + }
230 +
191 231 // 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 -
232 + $s = $this->get_type_state($type, true);
233 +
195 234 if (($s['status'] ?? 'stopped') !== 'running') {
196 - $latest_control = $this->get_control();
197 - $c = $latest_control[$type] ?? $this->default_control_state();
235 + $c = $this->get_type_control($type);
198 236
199 237 if ($c['stop_requested'] ?? false) {
200 238 $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);
239 + $this->save_type_state($type, $s);
240 + $c['stop_requested'] = false;
241 + $this->save_type_control($type, $c);
205 242 }
206 243 continue;
207 244 }
208 245
209 - if (!isset($this->callback_map[$type])) continue;
246 + // Ajax mode: the browser drives this type via its own run_all($type, ...) calls,
247 + // so cron's untargeted pass yields to it while last_run is still recent. Only
248 + // applies to the cron-driven invocation; a type that never opted into ticking is
249 + // never skipped here. If last_run goes stale (browser gone, e.g. laptop lid
250 + // closed without firing pagehide), cron steps back in as a safety net.
251 + if ($only_type === null && !empty($this->tick_supported[$type]) && $this->get_effective_sync_method($type) === 'ajax') {
252 + $last_run = $s['last_run'] ?? 0;
253 + if ((time() - $last_run) < self::$ajax_abandoned_threshold) {
254 + continue;
255 + }
256 + }
257 +
210 258 if ($this->is_locked($s)) continue;
211 259
212 - // lock, save and process
213 - $this->lock($latest_state, $type);
214 - $this->save_state($latest_state);
260 + // Real cross-process lock — is_locked() above is a cheap, non-atomic first
261 + // filter; acquire_process_lock() is what actually prevents two processes from
262 + // working the same type at once.
263 + if (!$this->acquire_process_lock($type)) continue;
215 264
216 - $this->process_iterations($type, $this->callback_map[$type]);
265 + try {
266 + // lock, save and process
267 + $s = $this->lock($s);
268 + $this->save_type_state($type, $s);
217 269
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);
270 + $this->process_iterations($type, $this->callback_map[$type], $run_start_time, $time_safe, $max_per_run);
271 + } finally {
272 + // Fetch state again to ensure we have the latest and then unlock
273 + $latest_s = $this->get_type_state($type, true);
274 + $latest_s = $this->unlock($latest_s);
275 + $this->save_type_state($type, $latest_s);
276 + $this->release_process_lock($type);
277 + }
222 278 }
223 279 }
224 280
225 - private function process_iterations(string $type, callable $callback) {
226 - $s = $this->get_state_counts($type, true);
281 + /**
282 + * Whether Pro is installed, licensed, and set to ajax/mixed sync mode for this type.
283 + * Falls back to 'cron' whenever Pro isn't installed and currently licensed.
284 + *
285 + * @return string 'ajax'|'cron'|'mixed'
286 + */
287 + private function get_effective_sync_method(string $type) {
288 + return Utils::is_pro_licensed() ? $this->get_sync_method($type) : 'cron';
289 + }
227 290
228 - $max_exec = (int) ini_get('max_execution_time');
229 - $max_exec = $max_exec !== 0 ? $max_exec : 55;
291 + /**
292 + * Force the next get_transient() for this key to be a real database read instead of a
293 + * possibly-stale value from WordPress's per-request object cache, which otherwise keeps
294 + * returning the same cached copy for the life of a request no matter how many times
295 + * it's read again.
296 + */
297 + private function bust_transient_cache(string $key) {
298 + wp_cache_delete($key, 'transient');
299 + wp_cache_delete('_transient_' . $key, 'options');
300 + }
301 +
302 + /**
303 + * Cross-process mutual exclusion for a type, via add_option()'s atomic INSERT (backed
304 + * by the UNIQUE index on wp_options.option_name) rather than a MySQL-specific locking
305 + * function. One row per type so unrelated sync types never block each other. Any
306 + * process can reclaim a stale lock by age — see renew_process_lock() for why a
307 + * heartbeat is needed rather than just the acquisition timestamp.
308 + *
309 + * @return bool True if the lock was acquired (fresh or reclaimed from a stale holder).
310 + */
311 + private function acquire_process_lock(string $type) {
312 + $lock_option = $this->meta_key . '_proc_lock_' . $type;
313 +
314 + if (add_option($lock_option, time(), '', false)) {
315 + return true;
316 + }
317 +
318 + $acquired_at = (int) get_option($lock_option, 0);
319 + if ($acquired_at > 0 && (time() - $acquired_at) > self::$lock_duration) {
320 + delete_option($lock_option);
321 + return add_option($lock_option, time(), '', false);
322 + }
323 +
324 + return false;
325 + }
326 +
327 + private function release_process_lock(string $type) {
328 + delete_option($this->meta_key . '_proc_lock_' . $type);
329 + }
330 +
331 + /**
332 + * Refresh the lock's timestamp so a batch that's simply slow doesn't look identical to
333 + * a dead one to acquire_process_lock()'s staleness check.
334 + */
335 + private function renew_process_lock(string $type) {
336 + update_option($this->meta_key . '_proc_lock_' . $type, time(), false);
337 + }
338 +
339 + private function process_iterations(string $type, callable $callback, float $run_start_time, float $time_safe, int $max_per_run = 50) {
340 + $s = $this->get_type_state_counts($type, true);
341 +
230 342 $max_mem = ini_get('memory_limit') ? $this->return_bytes(ini_get('memory_limit')) : 128 * 1024 * 1024;
231 343 $memory_safe = $max_mem * 0.80;
232 - $time_safe = $max_exec * 0.80;
233 - $start_time = microtime(true);
234 - $max_per_run = 50;
235 344 $iterations_count = 0;
236 345
237 346 while ($s['iterations_done'] < $s['iterations_total'] && $iterations_count < $max_per_run) {
238 347 // limit iterations per run to avoid long blocking
@@ -238,10 +347,9 @@
238 347 // limit iterations per run to avoid long blocking
239 348 $iterations_count++;
240 349
241 350 // 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();
351 + $latest_c = $this->get_type_control($type);
244 352
245 353 // stop if paused or stopped
246 354 if (!empty($latest_c['pause_requested']) || !empty($latest_c['stop_requested'])) {
247 355 break;
@@ -246,10 +354,10 @@
246 354 if (!empty($latest_c['pause_requested']) || !empty($latest_c['stop_requested'])) {
247 355 break;
248 356 }
249 357
250 - // check memory and time using more precise calls
251 - if ((memory_get_usage(false) > $memory_safe) || ((microtime(true) - $start_time) > $time_safe)) {
358 + // check memory and time using more precise calls (time budget shared across all types in this tick)
359 + if ((memory_get_usage(false) > $memory_safe) || ((microtime(true) - $run_start_time) > $time_safe)) {
252 360 break;
253 361 }
254 362
255 363 try {
@@ -263,46 +371,55 @@
263 371 }
264 372
265 373 $s['iterations_done']++;
266 374
267 - // Update state after each iteration to ensure progress is saved
268 - $this->update_state_counts($type, $s, true, false);
375 + // Write progress every item, not just at the end of the batch, so a slow item
376 + // can't silently lose the batch's progress if it gets killed mid-way.
377 + $this->update_type_state_counts($type, $s, true, false);
378 + $this->renew_process_lock($type);
269 379
270 380 if($iterations_count % 10 === 0) {
271 381 gc_collect_cycles();
272 382 }
273 383 }
274 -
384 +
275 385 // 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();
386 + $latest_s = $this->get_type_state($type, true);
387 + $latest_c = $this->get_type_control($type);
280 388 $is_completed = ($latest_s['iterations_done'] ?? 0) >= ($latest_s['iterations_total'] ?? 0);
281 389
282 390 // Update state if completed, paused or stopped
283 391 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';
392 + $was_stop_requested = $latest_c['stop_requested'] ?? false;
393 +
394 + $latest_s['status'] = $is_completed || $was_stop_requested ? 'stopped' : 'paused';
285 395 $latest_s['completed'] = $is_completed;
286 - $latest_state[$type] = $latest_s;
287 - $this->save_state($latest_state);
396 + $this->save_type_state($type, $latest_s);
288 397
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);
398 + $latest_c['pause_requested'] = false;
399 + $latest_c['stop_requested'] = false;
400 + $latest_c['time'] = time();
401 + $this->save_type_control($type, $latest_c);
402 +
403 + if ($is_completed) {
404 + do_action("wpmcs_{$type}_completed", $type);
405 + } elseif ($was_stop_requested) {
406 + do_action("wpmcs_{$type}_cancelled", $type);
407 + }
293 408 }
294 409
295 410 gc_collect_cycles();
296 411 }
297 412
298 - private function lock(array &$state, string $type) {
299 - $state[$type]['lock_until'] = time() + self::$lock_duration;
413 + private function lock(array $s) {
414 + $s['lock_until'] = time() + self::$lock_duration;
415 + return $s;
300 416 }
301 417
302 - private function unlock(array &$state, string $type) {
303 - $state[$type]['lock_until'] = 0;
304 - $state[$type]['last_run'] = time();
418 + private function unlock(array $s) {
419 + $s['lock_until'] = 0;
420 + $s['last_run'] = time();
421 + return $s;
305 422 }
306 423
307 424 private function is_locked(array $s) {
308 425 return ($s['lock_until'] ?? 0) && time() < $s['lock_until'];
@@ -309,8 +426,13 @@
309 426 }
310 427
311 428 private function return_bytes($val) {
312 429 $val = trim($val);
430 + // "-1"/blank mean no limit; treating it as a numeric byte count made the
431 + // memory-budget check in process_iterations() permanently true.
432 + if ($val === '' || $val === '-1') {
433 + return PHP_INT_MAX;
434 + }
313 435 $last = strtolower($val[strlen($val)-1] ?? '');
314 436 $num = (int) $val;
315 437 switch ($last) {
316 438 case 'g': $num *= 1024 * 1024 * 1024; break;
@@ -331,9 +453,9 @@
331 453
332 454 /**
333 455 * Get only the count-related fields of the state for a given type.
334 456 *
335 - * @param string $type The type key in the state array.
457 + * @param string $type The job type.
336 458 * @param bool $force Reload state from transient/option instead of cache.
337 459 *
338 460 * @return array {
339 461 * @type int $iterations_total
@@ -340,23 +462,15 @@
340 462 * @type int $iterations_done
341 463 * @type int $failed_count
342 464 * }
343 465 */
344 - private function get_state_counts($type, $force = false) {
345 - $state = $this->get_state($force);
466 + private function get_type_state_counts(string $type, bool $force = false) {
467 + $s = $this->get_type_state($type, $force);
346 468
347 - if (!isset($state[$type])) {
348 - return [
349 - 'iterations_total' => 0,
350 - 'iterations_done' => 0,
351 - 'failed_count' => 0,
352 - ];
353 - }
354 -
355 469 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),
470 + 'iterations_total' => (int) ($s['iterations_total'] ?? 0),
471 + 'iterations_done' => (int) ($s['iterations_done'] ?? 0),
472 + 'failed_count' => (int) ($s['failed_count'] ?? 0),
359 473 ];
360 474 }
361 475
362 476
@@ -362,9 +476,9 @@
362 476
363 477 /**
364 478 * Update only the count-related fields for a given type.
365 479 *
366 - * @param string $type The type key in the state array.
480 + * @param string $type The job type.
367 481 * @param array $counts {
368 482 * @type int $iterations_total
369 483 * @type int $iterations_done
370 484 * @type int $failed_count
@@ -371,77 +485,90 @@
371 485 * }
372 486 * @param bool $update_transient Whether to update transient.
373 487 * @param bool $update_options Whether to update options.
374 488 */
375 - private function update_state_counts($type, array $counts, $update_transient = true, $update_options = true) {
376 - $state = $this->get_state( true ); // full state array
489 + private function update_type_state_counts(string $type, array $counts, bool $update_transient = true, bool $update_options = true) {
490 + $s = $this->get_type_state($type, true);
377 491
378 - if (!isset($state[$type])) {
379 - $state[$type] = $this->default_type_state();
380 - }
381 -
382 492 // update only the count fields
383 493 if (isset($counts['iterations_total'])) {
384 - $state[$type]['iterations_total'] = (int) $counts['iterations_total'];
494 + $s['iterations_total'] = (int) $counts['iterations_total'];
385 495 }
386 496 if (isset($counts['iterations_done'])) {
387 - $state[$type]['iterations_done'] = (int) $counts['iterations_done'];
497 + $s['iterations_done'] = (int) $counts['iterations_done'];
388 498 }
389 499 if (isset($counts['failed_count'])) {
390 - $state[$type]['failed_count'] = (int) $counts['failed_count'];
500 + $s['failed_count'] = (int) $counts['failed_count'];
391 501 }
392 502
393 - $this->save_state($state, $update_transient, $update_options);
503 + $this->save_type_state($type, $s, $update_transient, $update_options);
394 504 }
395 505
396 506
397 507 /**
398 - * Get the state of the bg runner.
508 + * Get the state for one job type. Each type has its own transient/option row rather
509 + * than one row holding every type's state, so concurrent processing of two different
510 + * types never races on the same shared row.
399 511 *
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.
512 + * @param string $type The job type.
513 + * @param bool $force Reload the state from the transient or option.
402 514 *
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.
515 + * @return array The state for this type.
409 516 */
410 - private function get_state($force = false) {
517 + private function get_type_state(string $type, bool $force = false) {
411 518 // if not forcing and cache already exists, return cached
412 - if (!$force && $this->state_cache !== null) {
413 - return $this->state_cache;
519 + if (!$force && isset($this->state_cache[$type])) {
520 + return $this->state_cache[$type];
414 521 }
415 522
523 + $transient_key = $this->transient_key . '_' . $type;
524 +
525 + if ($force) {
526 + $this->bust_transient_cache($transient_key);
527 + }
528 +
416 529 // try transient first
417 - $state = get_transient($this->transient_key);
530 + $s = get_transient($transient_key);
418 531
419 - if ($state !== false) {
532 + if ($s !== false) {
420 533 if ($force) {
421 534 // update cache with the fresh transient
422 - $this->state_cache = $state;
535 + $this->state_cache[$type] = $s;
423 536 }
424 - return $state;
537 + return $s;
425 538 }
426 539
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);
540 + // fallback to option if transient missing (e.g. evicted from a persistent object cache)
541 + $option_key = $this->meta_key . '_' . $type;
542 + $s = get_option($option_key, null);
543 + if ($s === null) {
544 + // Genuinely idle/never started — return the default without persisting a row,
545 + // since run_all() now touches every registered type on every cron tick.
546 + $s = $this->default_type_state();
547 + $this->state_cache[$type] = $s;
548 + return $s;
549 + }
550 + $this->state_cache[$type] = $s;
551 + set_transient($transient_key, $s, WEEK_IN_SECONDS);
431 552
432 - return $state;
553 + return $s;
433 554 }
434 555
435 - private function save_state($state, $update_transient = true, $update_options = true) {
556 + private function save_type_state(string $type, array $s, bool $update_transient = true, bool $update_options = true) {
436 557 // 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);
558 + if (!isset($this->state_cache[$type]) || $this->state_cache[$type] !== $s) {
559 + $this->state_cache[$type] = $s;
560 + if ($update_transient) set_transient($this->transient_key . '_' . $type, $s, WEEK_IN_SECONDS);
561 + if ($update_options) update_option($this->meta_key . '_' . $type, $s, false);
441 562 }
442 563 }
443 564
565 + private function delete_type_state(string $type) {
566 + delete_transient($this->transient_key . '_' . $type);
567 + delete_option($this->meta_key . '_' . $type);
568 + unset($this->state_cache[$type]);
569 + }
570 +
444 571 private function default_type_state() {
445 572 return [
446 573 'lock_until' => 0,
447 574 'status' => 'stopped',
@@ -452,16 +579,30 @@
452 579 'last_run' => 0,
453 580 ];
454 581 }
455 582
456 - private function get_control() {
457 - return get_transient($this->control_transient_key) ?: [];
583 + /**
584 + * Control (pause/stop) always busts the cache before reading — every caller needs it
585 + * live, unlike state there's no non-forced fast path here.
586 + *
587 + * @return array
588 + */
589 + private function get_type_control(string $type) {
590 + $key = $this->control_transient_key . '_' . $type;
591 + $this->bust_transient_cache($key);
592 +
593 + $c = get_transient($key);
594 + return $c !== false ? $c : $this->default_control_state();
458 595 }
459 596
460 - private function save_control($control) {
461 - set_transient($this->control_transient_key, $control, WEEK_IN_SECONDS);
597 + private function save_type_control(string $type, array $c) {
598 + set_transient($this->control_transient_key . '_' . $type, $c, WEEK_IN_SECONDS);
462 599 }
463 600
601 + private function delete_type_control(string $type) {
602 + delete_transient($this->control_transient_key . '_' . $type);
603 + }
604 +
464 605 private function default_control_state() {
465 606 return [
466 607 'pause_requested' => false,
467 608 'stop_requested' => false,
@@ -468,8 +609,33 @@
468 609 'time' => time(),
469 610 ];
470 611 }
471 612
613 + // One option row for every type, keyed by $type — not one option per type.
614 + private function get_type_settings(string $type) {
615 + return Utils::get_option($type, [], $this->settings_meta_key);
616 + }
617 +
618 + private function save_type_settings(string $type, array $settings) {
619 + Utils::update_option($type, $settings, $this->settings_meta_key);
620 + }
621 +
622 + // Raw stored preference, not license-gated — see get_effective_sync_method() for that.
623 + public function get_sync_method(string $type) {
624 + $settings = $this->get_type_settings($type);
625 + return $settings['sync_method'] ?? 'cron';
626 + }
627 +
628 + public function set_sync_method(string $type, string $method) {
629 + if (!in_array($method, ['ajax', 'cron', 'mixed'], true)) {
630 + return false;
631 + }
632 + $settings = $this->get_type_settings($type);
633 + $settings['sync_method'] = $method;
634 + $this->save_type_settings($type, $settings);
635 + return true;
636 + }
637 +
472 638 /**
473 639 * Forces removal of the bg runner lock. This is a debug utility and should not be used in production.
474 640 * The lock is removed when the query string parameter 'force_reset_sync' is set to '1'.
475 641 * The purpose of this function is to allow for easy reset of the bg runner lock in debug environments.
@@ -480,14 +646,21 @@
480 646 if (! current_user_can('manage_options')) {
481 647 return;
482 648 }
483 649
650 + // Legacy pre-per-type keys, in case any still linger.
484 651 delete_transient($this->transient_key);
485 652 delete_option($this->meta_key);
486 653 delete_transient($this->control_transient_key);
487 654
655 + foreach (array_keys($this->callback_map) as $type) {
656 + $this->delete_type_state($type);
657 + $this->delete_type_control($type);
658 + delete_option($this->meta_key . '_proc_lock_' . $type);
659 + }
660 +
488 661 if (! defined('DOING_AJAX')) {
489 662 wp_die('Locks removed successfully.');
490 663 }
491 664 }
492 665 }
493 -}
666 +}