| 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 |
|