PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.1 2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 All 130 releases
wdesignkit / includes / admin / import / class-wdkit-import-session.php

class-wdkit-import-session.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at includes/admin/import/class-wdkit-import-session.php

1,331 lines 44.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Persistent import session.
4 *
5 * The browser importer keeps its progress in sessionStorage (`wdkit_import_progress_{kit_id}`
6 * in import_temp_main.js), which is wizard position rather than import progress and dies with
7 * the tab. That is left exactly as it is. This class is the server-side equivalent the PHP
8 * runner needs: a record that survives a fatal, a timeout or a closed connection so a run can
9 * be resumed instead of restarted — and, critically, so completed work is never redone.
10 *
11 * Persistence is a non-autoloaded option per session plus a small index for cleanup. No custom
12 * table: a session is a handful of kilobytes with a single-row access pattern, which is exactly
13 * what options are for, and adding a table would mean owning migrations for it.
14 *
15 * Nothing here is wired to the main website. Phase 2 will hand it a session_id; for now the
16 * only caller is the runner.
17 *
18 * @package Wdesignkit
19 * @since 2.6.5
20 */
21
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit;
24 }
25
26 if ( ! class_exists( 'Wdkit_Import_Session' ) ) {
27
28 /**
29 * Import session store.
30 */
31 class Wdkit_Import_Session {
32
33 /**
34 * Option name prefix. One option per session, autoload off.
35 */
36 const OPTION_PREFIX = 'wdkit_import_session_';
37
38 /**
39 * Index of known session ids, so old sessions can be pruned without a table scan.
40 */
41 const INDEX_OPTION = 'wdkit_import_sessions';
42
43 /**
44 * Prefix for the per-step rows.
45 *
46 * Step completion, its result and its failure record used to live inside the one
47 * session option, which every stage request read-modified-wrote. Under concurrency
48 * (RUNNER_LANES > 1) that lost updates: lane B wrote back a snapshot taken before
49 * lane A recorded its step, and A's record vanished — is_step_complete() then said
50 * "not imported" for a page that existed and the next lane imported it again.
51 *
52 * Each step now gets its own non-autoloaded row, keyed like a claim (hashed tail so
53 * a long step key cannot overflow option_name). A write touches one step and cannot
54 * disturb a neighbour. get() folds the rows back into the record shape every reader
55 * already expects, so nothing downstream changes.
56 */
57 const STEP_PREFIX = 'wdkit_istep_';
58
59 /**
60 * Seconds before an unrenewed claim can be taken over by another lane.
61 *
62 * The lane that owns a step renews its claim (touch_claim()) at each phase of the
63 * import, so a live-but-slow lane keeps it however long the import runs. This ceiling
64 * is therefore about a lane that STOPPED renewing — a process killed so hard the
65 * shutdown handler never ran — not about a slow one. Sized to comfortably clear the
66 * slowest single import phase (a page whose images are all still remote), because a
67 * takeover while the first lane is still working imports the template twice.
68 */
69 const CLAIM_TTL = 120;
70
71 /**
72 * How many times one step may fail before it stops being treated as retryable.
73 *
74 * Generous enough to ride out a flaky connection - the cloud fetch that failed three
75 * times in a row here succeeded on the fourth - and small enough that a cause which is
76 * never going to clear surfaces to the user instead of looping.
77 */
78 const MAX_STEP_ATTEMPTS = 6;
79
80 /**
81 * Cap on the stored error log. The session is a single non-autoloaded option that is read
82 * and rewritten on every request of a run, so it must not grow without bound.
83 */
84 const MAX_ERRORS = 50;
85
86 /**
87 * How many sessions to keep in the index before the oldest are pruned.
88 */
89 const INDEX_LIMIT = 20;
90
91 /**
92 * Postmeta key recording which session created a given post.
93 *
94 * Written once, in mark_step_complete(), for every page/post/product step - the same
95 * result shape Wdkit_Import_Cleanup::session_created_ids() already reads to prove
96 * ownership for the CURRENT run. Storing it on the post itself, rather than only in the
97 * session's own option row, is what would let a later run tell "a page an earlier import
98 * created" apart from a site owner's own content even after that earlier session has been
99 * pruned from the index (see INDEX_LIMIT) - even so, nothing currently reads this back out
100 * to act on it; see POST_SUPERSEDED_META and ClickUp 14ynqxywnaa for why.
101 */
102 const POST_OWNER_META = '_wdkit_import_session_id';
103
104 /**
105 * Postmeta key recording when Wdkit_Import_Reset::run() demoted a post to draft.
106 *
107 * Only ever written onto a post that already carries POST_OWNER_META - see that class.
108 * Its presence distinguishes "this importer's own reset put the page in draft status" from
109 * a site owner manually drafting a page they are editing.
110 *
111 * Nothing currently deletes or trashes on the strength of these two meta keys - draft
112 * pages a superseded import leaves behind (ClickUp 14ynqxywnaa) simply accumulate, same as
113 * before this pair existed. Automating their removal, even into trash, means new code that
114 * can end up deleting a real site's content unattended, and the call was to hold off on
115 * that until there is a reviewed, deliberate cleanup path (most likely admin-facing, not
116 * automatic) built on top of this tagging - not to have that path decide silently here.
117 */
118 const POST_SUPERSEDED_META = '_wdkit_import_superseded_at';
119
120 /** Session lifecycle states. */
121 const STATUS_PENDING = 'pending';
122 const STATUS_RUNNING = 'running';
123 const STATUS_INTERRUPTED = 'interrupted';
124 const STATUS_FAILED = 'failed';
125 const STATUS_COMPLETED = 'completed';
126
127 /**
128 * Session id currently being executed in this request, for the shutdown handler.
129 *
130 * @var string
131 */
132 private static $active_session = '';
133
134 /**
135 * True once register_shutdown_function() has been attached.
136 *
137 * @var bool
138 */
139 private static $shutdown_registered = false;
140
141 /**
142 * Request-scoped cache of hydrated sessions, keyed by sanitised id.
143 *
144 * get() rebuilds progress/results/failed with a prefix scan over the step rows; a
145 * single stage request asks for the session several times and nothing changes
146 * between those calls unless this process writes. Every write through this class
147 * busts the entry, so a stale read is not possible within a request, and a
148 * neighbouring lane's write lands in its own row and is picked up on the next read.
149 *
150 * @var array<string,array>
151 */
152 private static $hydrated = array();
153
154 /**
155 * A session id is used to build an option name, so it must be a slug and nothing else.
156 *
157 * @param string $session_id Candidate id.
158 * @return string Sanitised id, or '' when unusable.
159 */
160 public static function sanitize_id( $session_id ) {
161 $session_id = is_string( $session_id ) ? trim( $session_id ) : '';
162
163 if ( '' === $session_id ) {
164 return '';
165 }
166
167 /** letters, digits, dash and underscore only — no separators, no traversal */
168 $session_id = preg_replace( '/[^A-Za-z0-9_\-]/', '', $session_id );
169
170 return substr( (string) $session_id, 0, 64 );
171 }
172
173 /**
174 * @param string $session_id Session id.
175 * @return string Option name.
176 */
177 private static function option_name( $session_id ) {
178 return self::OPTION_PREFIX . $session_id;
179 }
180
181 /**
182 * Option name for one step row. Hashed tail, exactly like claim_key(), so an
183 * arbitrarily long step key cannot overflow option_name.
184 *
185 * @param string $session_id Session id.
186 * @param string $step Step key.
187 * @return string
188 */
189 private static function step_key( $session_id, $step ) {
190 return self::STEP_PREFIX . self::sanitize_id( $session_id ) . '_' . md5( (string) $step );
191 }
192
193 /**
194 * SQL LIKE pattern matching every step row for a session.
195 *
196 * @param string $session_id Session id.
197 * @return string
198 */
199 private static function step_like( $session_id ) {
200 global $wpdb;
201
202 return $wpdb->esc_like( self::STEP_PREFIX . self::sanitize_id( $session_id ) . '_' ) . '%';
203 }
204
205 /**
206 * Read one step row.
207 *
208 * @param string $session_id Session id.
209 * @param string $step Step key.
210 * @return array|null
211 */
212 private static function read_step( $session_id, $step ) {
213 global $wpdb;
214
215 /* Straight from the table, never get_option(). Another lane writes these rows from a
216 * different PHP process, and get_option() answers from this request's own cache - a
217 * miss cached as `notoptions` before the other lane finished the step kept saying
218 * "not done" afterwards, so this lane imported the page a second time (every page
219 * duplicated as `about-us-2` etc., ClickUp 14ynqxz2tqb). Same reason claim() reads
220 * its row directly. */
221 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- see above.
222 $raw = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", self::step_key( $session_id, $step ) ) );
223
224 $row = ( null === $raw ) ? null : maybe_unserialize( $raw );
225
226 return is_array( $row ) ? $row : null;
227 }
228
229 /**
230 * Write one step row and drop the hydrated cache for the session.
231 *
232 * @param string $session_id Session id.
233 * @param string $step Step key.
234 * @param array $row Row payload.
235 * @return void
236 */
237 private static function write_step( $session_id, $step, $row ) {
238 $row['step'] = (string) $step;
239 $row['at'] = time();
240
241 update_option( self::step_key( $session_id, $step ), $row, false );
242
243 unset( self::$hydrated[ self::sanitize_id( $session_id ) ] );
244 }
245
246 /**
247 * Every per-step row for a session, as decoded arrays.
248 *
249 * One indexed prefix scan of the options table — the same access pattern
250 * release_all() uses for claims. Bounded: a run has one row per template plus a
251 * handful of stage sub-steps.
252 *
253 * @param string $session_id Session id.
254 * @return array[]
255 */
256 private static function step_rows( $session_id ) {
257 global $wpdb;
258
259 $values = $wpdb->get_col( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name LIKE %s", self::step_like( $session_id ) ) );
260
261 $rows = array();
262
263 foreach ( (array) $values as $raw ) {
264 $row = maybe_unserialize( $raw );
265
266 if ( is_array( $row ) ) {
267 $rows[] = $row;
268 }
269 }
270
271 return $rows;
272 }
273
274 /**
275 * Delete every per-step row for a session. Teardown only — on a live run these rows
276 * are the completion ledger.
277 *
278 * @param string $session_id Session id.
279 * @return void
280 */
281 private static function purge_steps( $session_id ) {
282 global $wpdb;
283
284 $names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", self::step_like( $session_id ) ) );
285
286 foreach ( (array) $names as $name ) {
287 delete_option( $name );
288 }
289
290 unset( self::$hydrated[ self::sanitize_id( $session_id ) ] );
291 }
292
293 /**
294 * Mint a new session id. Not cryptographic — it only has to be unique per site.
295 *
296 * @return string
297 */
298 public static function generate_id() {
299 return 'wdk_' . substr( md5( uniqid( (string) wp_rand(), true ) ), 0, 24 );
300 }
301
302 /**
303 * Create (or reset) a session record.
304 *
305 * @param array $context Import context — see Wdkit_Import_Context::normalize().
306 * @param string $session_id Optional explicit id.
307 * @return array The stored session.
308 */
309 public static function create( $context = array(), $session_id = '' ) {
310 $session_id = self::sanitize_id( $session_id );
311
312 if ( '' === $session_id ) {
313 $session_id = self::generate_id();
314 }
315
316 $now = time();
317
318 $session = array(
319 'session_id' => $session_id,
320 'kit_id' => isset( $context['kit_id'] ) ? (string) $context['kit_id'] : '',
321 'builder' => isset( $context['builder'] ) ? (string) $context['builder'] : '',
322 'status' => self::STATUS_PENDING,
323 'stage' => '',
324 'step' => '',
325 'progress' => array(),
326 'completed' => array(),
327 'failed' => array(),
328 'errors' => array(),
329 'results' => array(),
330 'attempts' => 0,
331 'step_attempts' => array(),
332 'context' => is_array( $context ) ? $context : array(),
333 'created_at' => $now,
334 'updated_at' => $now,
335 'finished_at' => 0,
336 );
337
338 /* A reused id must not inherit a previous run's step rows or claims — that would
339 * make the fresh run skip templates it has never imported. */
340 self::purge_steps( $session_id );
341 self::release_all( $session_id );
342
343 self::write( $session_id, $session );
344 self::index_add( $session_id, $now );
345
346 return $session;
347 }
348
349 /**
350 * The full session record, with the per-step rows folded back in.
351 *
352 * Readers — the wizard progress screen, the runner's settle/orphan passes, the
353 * cleanup sweep — see `progress`, `completed`, `results`, `failed` and
354 * `step_attempts` exactly as they did when those lived in the blob.
355 *
356 * @param string $session_id Session id.
357 * @return array|null
358 */
359 public static function get( $session_id ) {
360 $session_id = self::sanitize_id( $session_id );
361
362 if ( '' === $session_id ) {
363 return null;
364 }
365
366 if ( isset( self::$hydrated[ $session_id ] ) ) {
367 return self::$hydrated[ $session_id ];
368 }
369
370 $session = self::base( $session_id );
371
372 if ( null === $session ) {
373 return null;
374 }
375
376 $session = self::hydrate( $session_id, $session );
377
378 self::$hydrated[ $session_id ] = $session;
379
380 return $session;
381 }
382
383 /**
384 * The stored session option, untouched — no per-step reconstruction.
385 *
386 * Every read-modify-write inside this class goes through here, never through get():
387 * writing a hydrated record back would fold the per-step rows into the blob, which
388 * is exactly the shape this split exists to avoid.
389 *
390 * @param string $session_id Session id.
391 * @return array|null
392 */
393 private static function base( $session_id ) {
394 $session = get_option( self::option_name( self::sanitize_id( $session_id ) ), null );
395
396 return is_array( $session ) ? $session : null;
397 }
398
399 /**
400 * Just the import context. The runner asks for it on every poll to build its
401 * Wdkit_Page_Importer, and does not need the per-step reconstruction to do that.
402 *
403 * @param string $session_id Session id.
404 * @return array
405 */
406 public static function context( $session_id ) {
407 $session = self::base( $session_id );
408
409 return ( null !== $session && ! empty( $session['context'] ) && is_array( $session['context'] ) )
410 ? $session['context']
411 : array();
412 }
413
414 /**
415 * Fold the per-step rows into the record shape every reader expects.
416 *
417 * @param string $session_id Session id.
418 * @param array $session Base record from base().
419 * @return array
420 */
421 private static function hydrate( $session_id, $session ) {
422 $session['progress'] = array();
423 $session['completed'] = array();
424 $session['failed'] = array();
425 $session['results'] = array();
426 $session['step_attempts'] = array();
427
428 $step_errors = array();
429
430 foreach ( self::step_rows( $session_id ) as $row ) {
431 if ( empty( $row['step'] ) ) {
432 continue;
433 }
434
435 $step = (string) $row['step'];
436
437 if ( ! empty( $row['done'] ) ) {
438 $session['progress'][ $step ] = true;
439
440 if ( ! in_array( $step, $session['completed'], true ) ) {
441 $session['completed'][] = $step;
442 }
443 }
444
445 if ( array_key_exists( 'result', $row ) && null !== $row['result'] ) {
446 $session['results'][ $step ] = $row['result'];
447 }
448
449 if ( ! empty( $row['error'] ) && is_array( $row['error'] ) ) {
450 $session['failed'][ $step ] = $row['error'];
451 $step_errors[] = $row['error'];
452 }
453
454 if ( ! empty( $row['attempts'] ) ) {
455 $session['step_attempts'][ $step ] = (int) $row['attempts'];
456 }
457 }
458
459 /* `errors` is a write-only diagnostic log — no reader keys off it (the wizard's
460 * error list reads `failed`). Surface the per-step failures alongside whatever
461 * fail()/on_shutdown() recorded on the blob. */
462 $blob_errors = ( isset( $session['errors'] ) && is_array( $session['errors'] ) ) ? $session['errors'] : array();
463 $session['errors'] = array_slice( array_merge( $step_errors, $blob_errors ), -self::MAX_ERRORS );
464
465 return $session;
466 }
467
468 /**
469 * Persist a whole session record.
470 *
471 * @param string $session_id Session id.
472 * @param array $session Record.
473 * @return bool
474 */
475 private static function write( $session_id, $session ) {
476 $session['updated_at'] = time();
477
478 /* Per-step state lives in its own rows (see STEP_PREFIX). Forcing these empty on
479 * every blob write keeps concurrent lanes racing only on scalars — last-writer-
480 * wins there is harmless — and scrubs any record left polluted by the old format. */
481 foreach ( array( 'progress', 'completed', 'failed', 'results', 'step_attempts' ) as $derived ) {
482 $session[ $derived ] = array();
483 }
484
485 unset( self::$hydrated[ self::sanitize_id( $session_id ) ] );
486
487 /** autoload false: this must never ride along on every page load */
488 return update_option( self::option_name( $session_id ), $session, false );
489 }
490
491 /**
492 * Merge changes into a session.
493 *
494 * @param string $session_id Session id.
495 * @param array $changes Keys to overwrite.
496 * @return array|null Updated session, or null when unknown.
497 */
498 public static function update( $session_id, $changes ) {
499 $session = self::base( $session_id );
500
501 if ( null === $session || ! is_array( $changes ) ) {
502 return null;
503 }
504
505 $session = array_merge( $session, $changes );
506
507 self::write( $session['session_id'], $session );
508
509 return self::get( $session_id );
510 }
511
512 /**
513 * Record that a step finished. This is what makes a resume skip work already done.
514 *
515 * @param string $session_id Session id.
516 * @param string $step Step name.
517 * @param mixed $result Optional result to keep.
518 * @return array|null
519 */
520 public static function mark_step_complete( $session_id, $step, $result = null ) {
521 if ( null === self::base( $session_id ) ) {
522 return null;
523 }
524
525 $step = (string) $step;
526 $row = self::read_step( $session_id, $step );
527 $row = is_array( $row ) ? $row : array();
528
529 $row['done'] = true;
530
531 /* A step that succeeds is no longer a failure. Clearing the error here is the
532 * invariant orphaned_post_ids() relies on to tell "imported then lost" from
533 * "imported and kept". */
534 unset( $row['error'] );
535
536 if ( null !== $result ) {
537 $row['result'] = $result;
538 }
539
540 self::write_step( $session_id, $step, $row );
541
542 self::tag_owned_post( $session_id, $step, $result );
543
544 return self::get( $session_id );
545 }
546
547 /**
548 * Stamp a newly-created post with the session that created it.
549 *
550 * Mirrors the exact step-name/result-key pairing
551 * Wdkit_Import_Cleanup::session_created_ids() uses to read ownership back out of
552 * `results` for the current run - this just also makes that ownership readable directly
553 * off the post itself, so it is still readable in a later run after this session's own
554 * option row is gone. See POST_OWNER_META for why nothing acts on it yet.
555 *
556 * @param string $session_id Session id.
557 * @param string $step Step name.
558 * @param mixed $result Step result, if any.
559 * @return void
560 */
561 private static function tag_owned_post( $session_id, $step, $result ) {
562 if ( ! is_array( $result ) ) {
563 return;
564 }
565
566 $post_id = 0;
567
568 if ( 0 === strpos( $step, 'page_' ) || 0 === strpos( $step, 'post_' ) ) {
569 $post_id = ! empty( $result['post_id'] ) ? (int) $result['post_id'] : 0;
570 } elseif ( 0 === strpos( $step, 'product_' ) ) {
571 $post_id = ! empty( $result['product_id'] ) ? (int) $result['product_id'] : 0;
572 }
573
574 if ( $post_id > 0 ) {
575 update_post_meta( $post_id, self::POST_OWNER_META, $session_id );
576 }
577 }
578
579 /**
580 * Has this step already been done in a previous attempt?
581 *
582 * @param string $session_id Session id.
583 * @param string $step Step name.
584 * @return bool
585 */
586 public static function is_step_complete( $session_id, $step ) {
587 /* Straight to the step's own row, never through the hydrated cache: a
588 * concurrent lane may have completed this step microseconds ago, and its row
589 * is the source of truth. Reading a stale "not complete" here is what makes a
590 * second lane re-import the page. */
591 $row = self::read_step( $session_id, $step );
592
593 return ! empty( $row['done'] );
594 }
595
596 /**
597 * Record a classified failure against a step, leaving the session resumable.
598 *
599 * @param string $session_id Session id.
600 * @param string $step Step name.
601 * @param array $error Error record from Wdkit_Import_Errors::to_record().
602 * @return array|null
603 */
604 public static function mark_step_failed( $session_id, $step, $error ) {
605 $session = self::base( $session_id );
606
607 if ( null === $session ) {
608 return null;
609 }
610
611 $step = (string) $step;
612 $error = is_array( $error ) ? $error : array( 'message' => (string) $error );
613
614 $row = self::read_step( $session_id, $step );
615 $row = is_array( $row ) ? $row : array();
616
617 /* A completed step is done. Recording a later failure against it would leave it
618 * in both `results` and `failed`, which orphaned_post_ids() reads as "imported
619 * then lost" and deletes the good page. Under RUNNER_LANES > 1 a stale-claim
620 * double import is the way that used to happen. */
621 if ( ! empty( $row['done'] ) ) {
622 return $session;
623 }
624
625 /* Count failures per step and stop calling a step retryable once it has burned
626 * through its budget. A retryable error only means "this MIGHT succeed if tried
627 * again" - it is not a licence to try forever. Without this ceiling a step whose
628 * cause never clears (a cloud login that has expired, a host that keeps timing
629 * out) is re-requested by the browser for as long as the tab is open: one run here
630 * reached 907 attempts on a single template, hammering the cloud the whole time,
631 * and showed the user a spinner rather than the error that was already known on
632 * attempt one.
633 *
634 * The read-increment-write here is on ONE small row, and claim() serialises a
635 * step's attempts in the common path, so a concurrent collision can at worst
636 * undercount by one — a single extra retry, never corruption. */
637 $attempts = isset( $row['attempts'] ) ? (int) $row['attempts'] : 0;
638
639 ++$attempts;
640
641 $row['attempts'] = $attempts;
642
643 if ( $attempts >= self::MAX_STEP_ATTEMPTS ) {
644 $error['retryable'] = false;
645 $error['class'] = 'exhausted';
646 $error['attempts'] = $attempts;
647 }
648
649 $row['error'] = $error;
650
651 self::write_step( $session_id, $step, $row );
652
653 if ( class_exists( 'Wdkit_Import_Log' ) ) {
654 Wdkit_Import_Log::add(
655 'failure',
656 array(
657 'session' => isset( $session['session_id'] ) ? $session['session_id'] : self::sanitize_id( $session_id ),
658 'stage' => isset( $session['stage'] ) ? $session['stage'] : '',
659 'step' => $step,
660 'code' => isset( $error['code'] ) ? $error['code'] : '',
661 'message' => isset( $error['message'] ) ? $error['message'] : '',
662 'class' => isset( $error['class'] ) ? $error['class'] : '',
663 'attempts' => $attempts,
664 /* For an unhandled PHP error the message alone does not say where it
665 * came from, and the content stage has dozens of call sites. */
666 'where' => isset( $error['context']['file'] )
667 ? $error['context']['file'] . ':' . $error['context']['line']
668 : '',
669 'trace' => isset( $error['context']['trace'] ) ? $error['context']['trace'] : array(),
670 )
671 );
672 }
673
674 return self::get( $session_id );
675 }
676
677 /**
678 * Take exclusive ownership of one step for this request.
679 *
680 * The browser runs several stage requests at once so templates import concurrently, the
681 * way Promise.all over import_page_section always did. Two requests must never pick the
682 * same template, or the kit imports twice.
683 *
684 * The claim is an `INSERT IGNORE` on the options table, checked by rows_affected.
685 * `option_name` is UNIQUE, so exactly one concurrent caller inserts the row and the rest
686 * are ignored — no read is involved, so there is no window between deciding and writing.
687 *
688 * This used to be add_option(), on the stated reasoning that it is atomic because the
689 * column is UNIQUE. It is not. WordPress core (wp-includes/option.php) checks
690 * `get_option()` first and then issues
691 * `INSERT ... ON DUPLICATE KEY UPDATE`, which never fails on a duplicate — so add_option()
692 * is read-then-upsert, and two lanes entering between the read and the write BOTH come
693 * back true. The browser opens all RUNNER_LANES lanes in the same tick, which is the
694 * worst case for exactly that window, and a lost claim here means a template or a blog
695 * post imported twice. The post-claim is_step_complete() re-checks the callers do are a
696 * second line of defence, not a substitute: neither lane has completed the step yet at
697 * the moment they collide, so both re-checks pass.
698 *
699 * A claim carries a timestamp and expires, so a request that is killed mid-template - PHP
700 * timeout, closed tab - does not lock that template out of the retry. A lane that is
701 * still working renews it with touch_claim() so a slow import is not mistaken for a
702 * dead one.
703 *
704 * @since 2.6.5
705 *
706 * @param string $session_id Session id.
707 * @param string $step Step key.
708 * @param int|null $ttl Seconds before an unrenewed claim can be taken over.
709 * Defaults to CLAIM_TTL, filtered by wdkit_import_claim_ttl.
710 * @return bool True when this request owns the step.
711 */
712 public static function claim( $session_id, $step, $ttl = null ) {
713 $key = self::claim_key( $session_id, $step );
714 $ttl = ( null === $ttl ) ? self::claim_ttl() : (int) $ttl;
715
716 if ( self::insert_claim_row( $key ) ) {
717 return true;
718 }
719
720 $held = (int) self::read_claim_row( $key );
721
722 if ( $held > 0 && ( time() - $held ) > $ttl ) {
723 /* Not renewed within the TTL — the lane that held it is gone, so take it over.
724 *
725 * Compare-and-swap, not a plain write: two lanes can notice the SAME stale claim
726 * in the same tick, and an unconditional update_option() would hand the step to
727 * both of them. The UPDATE carries the timestamp we just read in its WHERE, so
728 * only the lane that gets there first matches a row; the loser sees 0 rows and
729 * backs off. A stale claim is never the same second as now (time() - $held is
730 * greater than a positive TTL), so the "value unchanged" case that would also
731 * report 0 rows cannot arise here. */
732 return self::take_over_claim_row( $key, $held );
733 }
734
735 return false;
736 }
737
738 /**
739 * Create a claim row, once, without reading first.
740 *
741 * `INSERT IGNORE` against the UNIQUE `option_name`: the first caller inserts, everyone
742 * else is ignored and gets 0 affected rows. This is the primitive add_option() was
743 * mistakenly believed to be — see claim().
744 *
745 * The options cache is invalidated on success rather than primed, because this row is
746 * written outside the options API and a stale `notoptions` entry would make the very next
747 * get_option() report the claim as absent.
748 *
749 * @since 2.7.2
750 *
751 * @param string $key Option name.
752 * @return bool True when THIS caller created the row.
753 */
754 private static function insert_claim_row( $key ) {
755 global $wpdb;
756
757 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- an atomic claim has no cached equivalent; see the docblock.
758 $wpdb->query(
759 $wpdb->prepare(
760 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)",
761 $key,
762 (string) time(),
763 'no'
764 )
765 );
766
767 if ( 1 !== (int) $wpdb->rows_affected ) {
768 return false;
769 }
770
771 self::flush_option_cache( $key );
772
773 return true;
774 }
775
776 /**
777 * Read a claim row straight from the table.
778 *
779 * Uncached on purpose: a persistent object cache shared between the lanes can hold a
780 * `notoptions` entry from before another lane's INSERT, and a claim decision must be made
781 * on what is actually in the table.
782 *
783 * @since 2.7.2
784 *
785 * @param string $key Option name.
786 * @return string Stored value, or '' when the row is gone.
787 */
788 private static function read_claim_row( $key ) {
789 global $wpdb;
790
791 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- see the docblock.
792 $value = $wpdb->get_var(
793 $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", $key )
794 );
795
796 return ( null === $value ) ? '' : (string) $value;
797 }
798
799 /**
800 * Take over a claim whose holder has gone, but only if nobody beat us to it.
801 *
802 * @since 2.7.2
803 *
804 * @param string $key Option name.
805 * @param int $expected The stale timestamp this caller read.
806 * @return bool True when THIS caller now owns the claim.
807 */
808 private static function take_over_claim_row( $key, $expected ) {
809 global $wpdb;
810
811 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- compare-and-swap; see claim().
812 $wpdb->query(
813 $wpdb->prepare(
814 "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
815 (string) time(),
816 $key,
817 (string) $expected
818 )
819 );
820
821 if ( 1 !== (int) $wpdb->rows_affected ) {
822 return false;
823 }
824
825 self::flush_option_cache( $key );
826
827 return true;
828 }
829
830 /**
831 * Drop one option out of the object cache after a direct write.
832 *
833 * @since 2.7.2
834 *
835 * @param string $key Option name.
836 * @return void
837 */
838 private static function flush_option_cache( $key ) {
839 wp_cache_delete( $key, 'options' );
840
841 $notoptions = wp_cache_get( 'notoptions', 'options' );
842
843 if ( is_array( $notoptions ) && isset( $notoptions[ $key ] ) ) {
844 unset( $notoptions[ $key ] );
845 wp_cache_set( 'notoptions', $notoptions, 'options' );
846 }
847 }
848
849 /**
850 * Renew a claim this request already owns, so a long import keeps it.
851 *
852 * Called at each phase of an import (fetch, personalise, insert). Never creates a
853 * claim: a row that is gone was released or taken over, and resurrecting it would let
854 * two lanes believe they own the same step.
855 *
856 * @param string $session_id Session id.
857 * @param string $step Step key.
858 * @return void
859 */
860 public static function touch_claim( $session_id, $step ) {
861 global $wpdb;
862
863 $key = self::claim_key( $session_id, $step );
864
865 /* Direct, for the same reason claim() is: the row was written outside the options
866 * API, so a `notoptions` entry cached before another lane's INSERT would make
867 * get_option() report it missing — and a renewal that silently does nothing lets a
868 * lane that is still working have its template taken away from it at the TTL. The
869 * WHERE keeps the "never creates a claim" guarantee: a row that was released or
870 * taken over simply matches nothing. */
871 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- see above.
872 $wpdb->query(
873 $wpdb->prepare(
874 "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s",
875 (string) time(),
876 $key
877 )
878 );
879
880 self::flush_option_cache( $key );
881 }
882
883 /**
884 * TTL for an unrenewed claim.
885 *
886 * @return int
887 */
888 private static function claim_ttl() {
889 $ttl = (int) apply_filters( 'wdkit_import_claim_ttl', self::CLAIM_TTL );
890
891 return $ttl > 0 ? $ttl : self::CLAIM_TTL;
892 }
893
894 /**
895 * Give a step back, so a later request may attempt it.
896 *
897 * @param string $session_id Session id.
898 * @param string $step Step key.
899 * @return void
900 */
901 public static function release( $session_id, $step ) {
902 $key = self::claim_key( $session_id, $step );
903
904 delete_option( $key );
905
906 /* delete_option() bails early when its own cached read says the option is not there,
907 * which a claim row written outside the options API can easily produce. Flushing
908 * first would not help — the row must go from the TABLE — so the delete is repeated
909 * directly and the cache cleared after it. */
910 global $wpdb;
911
912 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- see above.
913 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", $key ) );
914
915 self::flush_option_cache( $key );
916 }
917
918 /**
919 * Drop every claim this session holds.
920 *
921 * @param string $session_id Session id.
922 * @return void
923 */
924 /**
925 * How many steps another lane is currently working on.
926 *
927 * A claim row that is still inside its TTL means a lane holds that step and is (as far as
928 * anything here can tell) getting on with it. The browser needs this to tell "nothing is
929 * happening" apart from "something is happening somewhere else" — see the `in_flight`
930 * note in Wdkit_Import_Wizard::run_stage().
931 *
932 * Expired rows are not counted: past the TTL the holder is presumed gone and the step is
933 * up for takeover, which is genuinely stalled until someone takes it.
934 *
935 * @since 2.7.2
936 *
937 * @param string $session_id Session id.
938 * @return int
939 */
940 public static function active_claims( $session_id ) {
941 global $wpdb;
942
943 $session_id = self::sanitize_id( $session_id );
944
945 if ( '' === $session_id ) {
946 return 0;
947 }
948
949 $like = $wpdb->esc_like( 'wdkit_claim_' . $session_id . '_' ) . '%';
950
951 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- claim rows are written outside the options API; a cached read would report stale liveness.
952 $values = $wpdb->get_col(
953 $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name LIKE %s", $like )
954 );
955
956 $ttl = self::claim_ttl();
957 $now = time();
958 $active = 0;
959
960 foreach ( (array) $values as $value ) {
961 $held = (int) $value;
962
963 if ( $held > 0 && ( $now - $held ) <= $ttl ) {
964 ++$active;
965 }
966 }
967
968 return $active;
969 }
970
971 public static function release_all( $session_id ) {
972 global $wpdb;
973
974 $like = $wpdb->esc_like( 'wdkit_claim_' . self::sanitize_id( $session_id ) . '_' ) . '%';
975
976 $names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $like ) );
977
978 /* One statement rather than N, and direct for the same reason release() is: these
979 * rows were written outside the options API, so delete_option() can bail on a cached
980 * miss and leave a claim row behind to outlive the run. */
981 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- see above.
982 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like ) );
983
984 foreach ( (array) $names as $name ) {
985 self::flush_option_cache( $name );
986 }
987 }
988
989 /**
990 * Option name for one claim. Hashed tail so a long step key cannot exceed the column.
991 *
992 * @param string $session_id Session id.
993 * @param string $step Step key.
994 * @return string
995 */
996 private static function claim_key( $session_id, $step ) {
997 return 'wdkit_claim_' . self::sanitize_id( $session_id ) . '_' . md5( (string) $step );
998 }
999
1000 /**
1001 * Clear everything that makes a failed step refuse to run again.
1002 *
1003 * The attempt ceiling exists to stop an automatic loop, not to stop a person. When
1004 * someone clicks Retry they have usually just fixed the cause - signed in to the cloud,
1005 * reconnected - and the run must start from the failed steps as if they had never been
1006 * tried. Without this the ceiling became permanent: after signing in, twelve pages that
1007 * had already reached six attempts were skipped forever and only the one page still
1008 * under the limit imported.
1009 *
1010 * Completed steps are deliberately left alone. That is the idempotency guarantee - a
1011 * retry must never re-import, or re-charge for, a page that already landed.
1012 *
1013 * @since 2.6.5
1014 *
1015 * @param string $session_id Session id.
1016 * @return array|null Updated session.
1017 */
1018 public static function reset_failures( $session_id ) {
1019 global $wpdb;
1020
1021 $session = self::base( $session_id );
1022
1023 if ( null === $session ) {
1024 return null;
1025 }
1026
1027 $names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", self::step_like( $session_id ) ) );
1028
1029 foreach ( (array) $names as $name ) {
1030 $row = get_option( $name, null );
1031
1032 if ( ! is_array( $row ) || empty( $row['done'] ) ) {
1033 /* A pure failure row — drop it so the step runs from scratch, and free
1034 * any claim a crashed lane left on it so the retry does not have to wait
1035 * out the TTL before it can pick the step up. */
1036 if ( is_array( $row ) && ! empty( $row['step'] ) ) {
1037 self::release( $session_id, (string) $row['step'] );
1038 }
1039
1040 delete_option( $name );
1041
1042 continue;
1043 }
1044
1045 /* Completed work stays: the retry must not re-import a page that landed.
1046 * Only the bits that make a step refuse to run again are cleared. */
1047 unset( $row['error'], $row['attempts'] );
1048
1049 update_option( $name, $row, false );
1050 }
1051
1052 $session['errors'] = array();
1053 $session['status'] = 'running';
1054
1055 self::write( $session['session_id'], $session );
1056
1057 if ( class_exists( 'Wdkit_Import_Log' ) ) {
1058 Wdkit_Import_Log::add( 'retry', array( 'session' => $session['session_id'] ) );
1059 }
1060
1061 return self::get( $session_id );
1062 }
1063
1064 /**
1065 * Has this step failed often enough that retrying it is pointless?
1066 *
1067 * @param string $session_id Session id.
1068 * @param string $step Step key.
1069 * @return bool
1070 */
1071 public static function is_step_exhausted( $session_id, $step ) {
1072 $row = self::read_step( $session_id, $step );
1073
1074 $attempts = ( is_array( $row ) && isset( $row['attempts'] ) ) ? (int) $row['attempts'] : 0;
1075
1076 return $attempts >= self::MAX_STEP_ATTEMPTS;
1077 }
1078
1079 /**
1080 * Move the session to a named stage.
1081 *
1082 * @param string $session_id Session id.
1083 * @param string $stage Stage name.
1084 * @return array|null
1085 */
1086 public static function set_stage( $session_id, $stage ) {
1087 return self::update( $session_id, array( 'stage' => (string) $stage ) );
1088 }
1089
1090 /**
1091 * Mark the session as running and arm the crash handler.
1092 *
1093 * @param string $session_id Session id.
1094 * @return array|null
1095 */
1096 public static function start( $session_id ) {
1097 $session = self::base( $session_id );
1098
1099 if ( null === $session ) {
1100 return null;
1101 }
1102
1103 $session['status'] = self::STATUS_RUNNING;
1104 $session['attempts'] = (int) $session['attempts'] + 1;
1105
1106 self::write( $session['session_id'], $session );
1107 self::watch( $session['session_id'] );
1108
1109 return self::get( $session_id );
1110 }
1111
1112 /**
1113 * @param string $session_id Session id.
1114 * @return array|null
1115 */
1116 public static function complete( $session_id ) {
1117 self::unwatch();
1118
1119 return self::update(
1120 $session_id,
1121 array(
1122 'status' => self::STATUS_COMPLETED,
1123 'finished_at' => time(),
1124 )
1125 );
1126 }
1127
1128 /**
1129 * @param string $session_id Session id.
1130 * @param array $error Optional final error record.
1131 * @return array|null
1132 */
1133 public static function fail( $session_id, $error = array() ) {
1134 self::unwatch();
1135
1136 $session = self::base( $session_id );
1137
1138 if ( null === $session ) {
1139 return null;
1140 }
1141
1142 $session['status'] = self::STATUS_FAILED;
1143 $session['finished_at'] = time();
1144
1145 if ( ! empty( $error ) ) {
1146 $errors = ( isset( $session['errors'] ) && is_array( $session['errors'] ) ) ? $session['errors'] : array();
1147 $errors[] = $error;
1148 $session['errors'] = array_slice( $errors, -self::MAX_ERRORS );
1149 }
1150
1151 self::write( $session['session_id'], $session );
1152
1153 return self::get( $session_id );
1154 }
1155
1156 /*
1157 |--------------------------------------------------------------------------
1158 | Crash safety
1159 |--------------------------------------------------------------------------
1160 */
1161
1162 /**
1163 * Watch this session for the rest of the request.
1164 *
1165 * If PHP dies on a fatal or the request is killed mid-run, the shutdown handler marks
1166 * the session `interrupted` rather than leaving it stuck on `running` forever. It
1167 * deliberately does not touch imported content: a half-finished import is recoverable,
1168 * a rolled-back one is not.
1169 *
1170 * @param string $session_id Session id.
1171 * @return void
1172 */
1173 public static function watch( $session_id ) {
1174 self::$active_session = self::sanitize_id( $session_id );
1175
1176 if ( self::$shutdown_registered ) {
1177 return;
1178 }
1179
1180 register_shutdown_function( array( __CLASS__, 'on_shutdown' ) );
1181
1182 self::$shutdown_registered = true;
1183 }
1184
1185 /**
1186 * Stop watching — the run reached a terminal state on its own.
1187 *
1188 * @return void
1189 */
1190 public static function unwatch() {
1191 self::$active_session = '';
1192 }
1193
1194 /**
1195 * Shutdown handler. Only acts when a watched session is still `running`.
1196 *
1197 * @return void
1198 */
1199 public static function on_shutdown() {
1200 $session_id = self::$active_session;
1201
1202 if ( '' === $session_id ) {
1203 return;
1204 }
1205
1206 /* Whatever this request had claimed, it is not working on it any more. Waiting for
1207 * the TTL to expire would leave that template unimportable for minutes: the next
1208 * request finds it claimed, reports it as still pending, imports nothing, and after
1209 * three such passes the browser's stall guard gives up with "The import stopped
1210 * making progress". Which is how a single request dying - a PHP timeout on the
1211 * heaviest page, say - turned into a stuck import rather than a resumable one. */
1212 self::release_all( $session_id );
1213
1214 $session = self::base( $session_id );
1215
1216 if ( null === $session || self::STATUS_RUNNING !== $session['status'] ) {
1217 return;
1218 }
1219
1220 $last_error = error_get_last();
1221 $fatal = array( E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR );
1222
1223 $record = array(
1224 'code' => 'import_interrupted',
1225 'message' => __( 'The import stopped unexpectedly and can be resumed.', 'wdesignkit' ),
1226 'step' => isset( $session['step'] ) ? $session['step'] : '',
1227 'class' => 'retryable',
1228 'retryable' => true,
1229 'skippable' => false,
1230 'context' => array(),
1231 );
1232
1233 if ( ! empty( $last_error ) && in_array( (int) $last_error['type'], $fatal, true ) ) {
1234 $message = isset( $last_error['message'] ) ? (string) $last_error['message'] : '';
1235
1236 /** keep the first line only, and never leak the absolute server path */
1237 $message = explode( "\n", $message )[0];
1238 $message = str_replace( ABSPATH, 'ABSPATH/', $message );
1239
1240 $record['code'] = 'import_fatal';
1241 $record['context']['error'] = $message;
1242 }
1243
1244 $errors = ( isset( $session['errors'] ) && is_array( $session['errors'] ) ) ? $session['errors'] : array();
1245 $errors[] = $record;
1246 $session['errors'] = array_slice( $errors, -self::MAX_ERRORS );
1247 $session['status'] = self::STATUS_INTERRUPTED;
1248
1249 self::write( $session_id, $session );
1250
1251 self::$active_session = '';
1252 }
1253
1254 /*
1255 |--------------------------------------------------------------------------
1256 | Index / cleanup
1257 |--------------------------------------------------------------------------
1258 */
1259
1260 /**
1261 * @param string $session_id Session id.
1262 * @param int $created Creation timestamp.
1263 * @return void
1264 */
1265 private static function index_add( $session_id, $created ) {
1266 $index = get_option( self::INDEX_OPTION, array() );
1267 $index = is_array( $index ) ? $index : array();
1268
1269 $index[ $session_id ] = (int) $created;
1270
1271 /** oldest first, then trim — bounded growth without a cron job */
1272 if ( count( $index ) > self::INDEX_LIMIT ) {
1273 asort( $index );
1274
1275 $excess = count( $index ) - self::INDEX_LIMIT;
1276
1277 foreach ( array_slice( array_keys( $index ), 0, $excess ) as $old_id ) {
1278 delete_option( self::option_name( $old_id ) );
1279 self::purge_steps( $old_id );
1280 self::release_all( $old_id );
1281 unset( $index[ $old_id ] );
1282 }
1283 }
1284
1285 update_option( self::INDEX_OPTION, $index, false );
1286 }
1287
1288 /**
1289 * Delete one session record.
1290 *
1291 * @param string $session_id Session id.
1292 * @return void
1293 */
1294 public static function delete( $session_id ) {
1295 $session_id = self::sanitize_id( $session_id );
1296
1297 if ( '' === $session_id ) {
1298 return;
1299 }
1300
1301 delete_option( self::option_name( $session_id ) );
1302
1303 /* Claims and per-step rows are separate option rows, so dropping the session
1304 * alone would orphan them. */
1305 self::purge_steps( $session_id );
1306 self::release_all( $session_id );
1307
1308 $index = get_option( self::INDEX_OPTION, array() );
1309
1310 if ( is_array( $index ) && isset( $index[ $session_id ] ) ) {
1311 unset( $index[ $session_id ] );
1312 update_option( self::INDEX_OPTION, $index, false );
1313 }
1314 }
1315
1316 /**
1317 * Known session ids, newest first.
1318 *
1319 * @return string[]
1320 */
1321 public static function all_ids() {
1322 $index = get_option( self::INDEX_OPTION, array() );
1323 $index = is_array( $index ) ? $index : array();
1324
1325 arsort( $index );
1326
1327 return array_keys( $index );
1328 }
1329 }
1330 }
1331