PluginProbe
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization / trunk
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization vtrunk
4.2.14 4.2.13 4.2.12 4.2.11 4.2.10 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 2.5.5 2.5.6 2.5.7 3.0.0 3.0.1 3.1.0 3.1.1 3.1.2 3.1.3 3.10.0 3.11.0 3.11.1 3.11.2 3.11.3 3.12.0 All 135 releases
← All changes | vendor/codeinwp/themeisle-sdk/src/Modules/Migrator.php +236 -16 4.2.9 → 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.