← All changes
|
vendor/codeinwp/themeisle-sdk/src/Modules/Migrator.php
+236
-16
4.2.8
→
trunk
View file →
| @@ -21,11 +21,11 @@ | ||
| 21 | 21 | |
| 22 | 22 | /** |
| 23 | 23 | * Migrator module for ThemeIsle SDK. |
| 24 | 24 | * |
| 25 | - * Allows products to ship PHP migration files that run automatically on | |
| 26 | - * admin page loads. Each product opts in by registering its migrations | |
| 27 | - * directory via the `{product_slug}_sdk_migrations_path` filter. | |
| 25 | + * Allows products to ship PHP migration files that run automatically on the | |
| 26 | + * first complete request for each version. Each product opts in by registering | |
| 27 | + * its migrations directory via the `{product_slug}_sdk_migrations_path` filter. | |
| 28 | 28 | */ |
| 29 | 29 | class Migrator extends Abstract_Module { |
| 30 | 30 | /** |
| 31 | 31 | * Option key suffix used to store the list of ran migrations. |
| @@ -32,11 +32,52 @@ | ||
| 32 | 32 | */ |
| 33 | 33 | const OPTION_SUFFIX = '_ran_migrations'; |
| 34 | 34 | |
| 35 | 35 | /** |
| 36 | + * Option key suffix used to store the last fully migrated product version. | |
| 37 | + */ | |
| 38 | + const VERSION_OPTION_SUFFIX = '_migrated_version'; | |
| 39 | + | |
| 40 | + /** | |
| 41 | + * Option/cache key suffix used while migrations are running. | |
| 42 | + */ | |
| 43 | + const LOCK_SUFFIX = '_migration_lock'; | |
| 44 | + | |
| 45 | + /** | |
| 46 | + * Cache group used for migration locks. | |
| 47 | + */ | |
| 48 | + const LOCK_CACHE_GROUP = 'themeisle_sdk_migrations'; | |
| 49 | + | |
| 50 | + /** | |
| 51 | + * Maximum lock lifetime in seconds. | |
| 52 | + */ | |
| 53 | + const LOCK_TTL = 300; | |
| 54 | + | |
| 55 | + /** | |
| 56 | + * Token identifying the lock owned by this instance. | |
| 57 | + * | |
| 58 | + * @var string | |
| 59 | + */ | |
| 60 | + private $lock_token = ''; | |
| 61 | + | |
| 62 | + /** | |
| 63 | + * Serialized value used by the database lock. | |
| 64 | + * | |
| 65 | + * @var string | |
| 66 | + */ | |
| 67 | + private $lock_value = ''; | |
| 68 | + | |
| 69 | + /** | |
| 70 | + * Lock backend used by this instance. | |
| 71 | + * | |
| 72 | + * @var string | |
| 73 | + */ | |
| 74 | + private $lock_driver = ''; | |
| 75 | + | |
| 76 | + /** | |
| 36 | 77 | * Check if we should load the module for this product. |
| 37 | 78 | * |
| 38 | - * Always returns true — the actual path check happens lazily at admin_init. | |
| 79 | + * Always returns true — the actual path check happens lazily at wp_loaded. | |
| 39 | 80 | * |
| 40 | 81 | * @param Product $product Product to load the module for. |
| 41 | 82 | * |
| 42 | 83 | * @return bool |
| @@ -53,9 +94,9 @@ | ||
| 53 | 94 | * @return Migrator |
| 54 | 95 | */ |
| 55 | 96 | public function load( $product ) { |
| 56 | 97 | $this->product = $product; |
| 57 | - add_action( 'admin_init', array( $this, 'run_pending' ) ); | |
| 98 | + add_action( 'wp_loaded', array( $this, 'run_pending' ) ); | |
| 58 | 99 | add_action( 'themeisle_sdk_rollback_migration_' . $product->get_slug(), array( $this, 'rollback' ) ); |
| 59 | 100 | return $this; |
| 60 | 101 | } |
| 61 | 102 | |
| @@ -61,28 +102,62 @@ | ||
| 61 | 102 | |
| 62 | 103 | /** |
| 63 | 104 | * Discover and run any pending migrations for the product. |
| 64 | 105 | * |
| 65 | - * Only runs when a version upgrade was detected during this request, indicated | |
| 66 | - * by the themeisle_sdk_update_{slug} action having fired. | |
| 106 | + * Runs on the first complete request for each product version. The migrated | |
| 107 | + * version is recorded only after all pending migrations finish successfully, | |
| 108 | + * so interrupted or failed runs are retried on the next request. | |
| 67 | 109 | * |
| 68 | 110 | * @return void |
| 69 | 111 | */ |
| 70 | 112 | public function run_pending() { |
| 71 | - if ( ! did_action( 'themeisle_sdk_update_' . $this->product->get_slug() ) ) { | |
| 113 | + $path = $this->get_migrations_path(); | |
| 114 | + | |
| 115 | + if ( empty( $path ) || ! is_dir( $path ) ) { | |
| 72 | 116 | return; |
| 73 | 117 | } |
| 74 | 118 | |
| 75 | - $path = $this->get_migrations_path(); | |
| 119 | + $version_key = $this->product->get_key() . self::VERSION_OPTION_SUFFIX; | |
| 120 | + $current_version = $this->product->get_version(); | |
| 76 | 121 | |
| 77 | - if ( empty( $path ) || ! is_dir( $path ) ) { | |
| 122 | + if ( get_option( $version_key, '' ) === $current_version ) { | |
| 78 | 123 | return; |
| 79 | 124 | } |
| 80 | 125 | |
| 126 | + if ( ! $this->acquire_lock() ) { | |
| 127 | + return; | |
| 128 | + } | |
| 129 | + | |
| 130 | + // Another request may have completed while this request waited for the lock. | |
| 131 | + if ( get_option( $version_key, '' ) === $current_version ) { | |
| 132 | + $this->release_lock(); | |
| 133 | + return; | |
| 134 | + } | |
| 135 | + | |
| 136 | + if ( $this->execute_pending( $path ) ) { | |
| 137 | + update_option( $version_key, $current_version ); | |
| 138 | + } | |
| 139 | + | |
| 140 | + $this->release_lock(); | |
| 141 | + } | |
| 142 | + | |
| 143 | + /** | |
| 144 | + * Execute all pending migration files. | |
| 145 | + * | |
| 146 | + * @param string $path Absolute migrations directory path. | |
| 147 | + * | |
| 148 | + * @return bool True when every migration was handled successfully. | |
| 149 | + */ | |
| 150 | + private function execute_pending( $path ) { | |
| 81 | 151 | $files = glob( trailingslashit( $path ) . '*.php' ); |
| 82 | 152 | |
| 153 | + if ( false === $files ) { | |
| 154 | + $this->log_error( 'discovery', 'failed to read the migrations directory' ); | |
| 155 | + return false; | |
| 156 | + } | |
| 157 | + | |
| 83 | 158 | if ( empty( $files ) ) { |
| 84 | - return; | |
| 159 | + return true; | |
| 85 | 160 | } |
| 86 | 161 | |
| 87 | 162 | sort( $files ); // Alphabetical order = chronological order given timestamp naming. |
| 88 | 163 | |
| @@ -87,8 +162,9 @@ | ||
| 87 | 162 | sort( $files ); // Alphabetical order = chronological order given timestamp naming. |
| 88 | 163 | |
| 89 | 164 | $option_key = $this->product->get_key() . self::OPTION_SUFFIX; |
| 90 | 165 | $ran = get_option( $option_key, array() ); |
| 166 | + $ran = is_array( $ran ) ? $ran : array(); | |
| 91 | 167 | |
| 92 | 168 | foreach ( $files as $file ) { |
| 93 | 169 | $name = basename( $file, '.php' ); |
| 94 | 170 | |
| @@ -99,9 +175,10 @@ | ||
| 99 | 175 | try { |
| 100 | 176 | $migration = require $file; // Migration files return an anonymous class instance. |
| 101 | 177 | |
| 102 | 178 | if ( ! ( $migration instanceof Abstract_Migration ) ) { |
| 103 | - continue; | |
| 179 | + $this->log_error( $name, 'migration file must return an Abstract_Migration instance' ); | |
| 180 | + return false; | |
| 104 | 181 | } |
| 105 | 182 | |
| 106 | 183 | if ( ! $migration->should_run() ) { |
| 107 | 184 | continue; |
| @@ -110,14 +187,157 @@ | ||
| 110 | 187 | $migration->up(); |
| 111 | 188 | $ran[] = $name; |
| 112 | 189 | update_option( $option_key, $ran ); |
| 113 | 190 | } catch ( \Throwable $e ) { |
| 114 | - // Log and stop — leave the migration unrecorded so it retries next load. | |
| 115 | - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 116 | - error_log( 'ThemeIsle SDK Migrator: failed to run ' . $name . ': ' . $e->getMessage() ); | |
| 117 | - break; | |
| 191 | + // Stop and leave the product version incomplete so the next request retries. | |
| 192 | + $this->log_error( $name, $e->getMessage() ); | |
| 193 | + return false; | |
| 118 | 194 | } |
| 119 | 195 | } |
| 196 | + | |
| 197 | + return true; | |
| 198 | + } | |
| 199 | + | |
| 200 | + /** | |
| 201 | + * Acquire a cross-request migration lock. | |
| 202 | + * | |
| 203 | + * Persistent object caches provide an atomic add operation. Sites without | |
| 204 | + * one use an atomic database insert/compare-and-swap fallback. | |
| 205 | + * | |
| 206 | + * @return bool True when the lock was acquired. | |
| 207 | + */ | |
| 208 | + private function acquire_lock() { | |
| 209 | + $this->lock_token = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( '', true ); | |
| 210 | + | |
| 211 | + if ( function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache() ) { | |
| 212 | + $acquired = wp_cache_add( | |
| 213 | + $this->get_lock_key(), | |
| 214 | + $this->lock_token, | |
| 215 | + self::LOCK_CACHE_GROUP, | |
| 216 | + self::LOCK_TTL | |
| 217 | + ); | |
| 218 | + | |
| 219 | + if ( $acquired ) { | |
| 220 | + $this->lock_driver = 'cache'; | |
| 221 | + } | |
| 222 | + | |
| 223 | + return $acquired; | |
| 224 | + } | |
| 225 | + | |
| 226 | + return $this->acquire_database_lock(); | |
| 227 | + } | |
| 228 | + | |
| 229 | + /** | |
| 230 | + * Acquire the database lock used without a persistent object cache. | |
| 231 | + * | |
| 232 | + * @return bool True when the lock was acquired. | |
| 233 | + */ | |
| 234 | + private function acquire_database_lock() { | |
| 235 | + global $wpdb; | |
| 236 | + | |
| 237 | + $key = $this->get_lock_key(); | |
| 238 | + $this->lock_value = wp_json_encode( | |
| 239 | + array( | |
| 240 | + 'token' => $this->lock_token, | |
| 241 | + 'expires' => time() + self::LOCK_TTL, | |
| 242 | + ) | |
| 243 | + ); | |
| 244 | + | |
| 245 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 246 | + $inserted = $wpdb->query( | |
| 247 | + $wpdb->prepare( | |
| 248 | + "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)", | |
| 249 | + $key, | |
| 250 | + $this->lock_value, | |
| 251 | + 'no' | |
| 252 | + ) | |
| 253 | + ); | |
| 254 | + | |
| 255 | + if ( 1 === (int) $inserted ) { | |
| 256 | + $this->lock_driver = 'database'; | |
| 257 | + return true; | |
| 258 | + } | |
| 259 | + | |
| 260 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 261 | + $existing = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $key ) ); | |
| 262 | + $lock = json_decode( (string) $existing, true ); | |
| 263 | + | |
| 264 | + if ( is_array( $lock ) && ! empty( $lock['expires'] ) && (int) $lock['expires'] >= time() ) { | |
| 265 | + return false; | |
| 266 | + } | |
| 267 | + | |
| 268 | + // Replace an expired lock only if it has not changed since it was read. | |
| 269 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 270 | + $updated = $wpdb->query( | |
| 271 | + $wpdb->prepare( | |
| 272 | + "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", | |
| 273 | + $this->lock_value, | |
| 274 | + $key, | |
| 275 | + $existing | |
| 276 | + ) | |
| 277 | + ); | |
| 278 | + | |
| 279 | + if ( 1 === (int) $updated ) { | |
| 280 | + $this->lock_driver = 'database'; | |
| 281 | + return true; | |
| 282 | + } | |
| 283 | + | |
| 284 | + return false; | |
| 285 | + } | |
| 286 | + | |
| 287 | + /** | |
| 288 | + * Release the lock owned by this instance. | |
| 289 | + * | |
| 290 | + * @return void | |
| 291 | + */ | |
| 292 | + private function release_lock() { | |
| 293 | + if ( 'cache' === $this->lock_driver ) { | |
| 294 | + $current_token = wp_cache_get( $this->get_lock_key(), self::LOCK_CACHE_GROUP ); | |
| 295 | + | |
| 296 | + if ( $current_token === $this->lock_token ) { | |
| 297 | + wp_cache_delete( $this->get_lock_key(), self::LOCK_CACHE_GROUP ); | |
| 298 | + } | |
| 299 | + } | |
| 300 | + | |
| 301 | + if ( 'database' === $this->lock_driver ) { | |
| 302 | + global $wpdb; | |
| 303 | + | |
| 304 | + // Delete only the lock value created by this instance. | |
| 305 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 306 | + $wpdb->query( | |
| 307 | + $wpdb->prepare( | |
| 308 | + "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", | |
| 309 | + $this->get_lock_key(), | |
| 310 | + $this->lock_value | |
| 311 | + ) | |
| 312 | + ); | |
| 313 | + } | |
| 314 | + | |
| 315 | + $this->lock_token = ''; | |
| 316 | + $this->lock_value = ''; | |
| 317 | + $this->lock_driver = ''; | |
| 318 | + } | |
| 319 | + | |
| 320 | + /** | |
| 321 | + * Get the migration lock key. | |
| 322 | + * | |
| 323 | + * @return string Lock key. | |
| 324 | + */ | |
| 325 | + private function get_lock_key() { | |
| 326 | + return $this->product->get_key() . self::LOCK_SUFFIX; | |
| 327 | + } | |
| 328 | + | |
| 329 | + /** | |
| 330 | + * Log a migration failure. | |
| 331 | + * | |
| 332 | + * @param string $name Migration name or operation. | |
| 333 | + * @param string $message Error message. | |
| 334 | + * | |
| 335 | + * @return void | |
| 336 | + */ | |
| 337 | + private function log_error( $name, $message ) { | |
| 338 | + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 339 | + error_log( 'ThemeIsle SDK Migrator: failed to run ' . $name . ': ' . $message ); | |
| 120 | 340 | } |
| 121 | 341 | |
| 122 | 342 | /** |
| 123 | 343 | * Roll back a single migration by name. |