PluginProbe
Visualizer – Tables & Charts Manager with Built-in AI Generator / 4.0.9
Visualizer – Tables & Charts Manager with Built-in AI Generator v4.0.9
4.0.9 4.0.8 4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 3.1.2 3.1.3 3.10.0 3.10.1 3.10.10 3.10.11 3.10.12 3.10.13 3.10.14 3.10.15 3.10.2 All 150 releases
← All changes | vendor/codeinwp/themeisle-sdk/src/Modules/Migrator.php +236 -16 4.0.5 → 4.0.9 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.