0 ) { return true; } // Enqueue returned <= 0, but another process may have scheduled the // same action between the check and the enqueue. if ( ActionSchedulerProbe::has_pending_action( $hook, $args, self::ACTION_GROUP ) ) { return true; } // Action Scheduler exists but enqueue failed and no duplicate was found. return false; } // --- WP-Cron fallback --- if ( function_exists( 'wp_schedule_single_event' ) && function_exists( 'wp_next_scheduled' ) ) { if ( wp_next_scheduled( $hook, $args ) ) { return true; } $scheduled = wp_schedule_single_event( time() + 1, $hook, $args ); return $scheduled; } return false; } /** * Cancel all scheduled jobs for a migration. * * @param int $migration_id Migration ID. */ public static function cancel( int $migration_id ): void { if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( self::ACTION_HOOK, [ $migration_id ], self::ACTION_GROUP ); as_unschedule_all_actions( self::FILE_ACTION_HOOK, [ $migration_id ], self::ACTION_GROUP ); } if ( function_exists( 'wp_clear_scheduled_hook' ) ) { wp_clear_scheduled_hook( self::ACTION_HOOK, [ $migration_id ] ); wp_clear_scheduled_hook( self::FILE_ACTION_HOOK, [ $migration_id ] ); } } /** * True when the migration left running/pending before the worker crashed. A * cancel, pause or completion is the operator's call; failing the row would * overwrite it. */ private static function operator_resolved( int $migration_id, \wpdb $wpdb ): bool { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $status = $wpdb->get_var( $wpdb->prepare( 'SELECT status FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_migrations', $migration_id ) ); return in_array( (string) $status, [ MigrationStatus::Cancelled->value, MigrationStatus::Paused->value, MigrationStatus::Completed->value, ], true ); } /** * Maximum DB chunks to process per Action Scheduler invocation. */ private const MAX_CHUNKS_PER_JOB = 500; /** * Maximum file chunks or batches to process per Action Scheduler invocation. */ private const MAX_FILE_CHUNKS_PER_JOB = 100; /** * Wall-clock budget (seconds) per Action Scheduler invocation. * Keeps execution inside the 60 s Action Scheduler worker timeout. */ private const JOB_TIME_BUDGET_SECONDS = 58; /** * Wall-clock budget for file-stage work. */ private const FILE_JOB_TIME_BUDGET_SECONDS = 50; /** * Handle one scheduler invocation: process DB chunks until MAX_CHUNKS_PER_JOB or * JOB_TIME_BUDGET_SECONDS, then re-schedule if more remain. */ /** * Cron and Action Scheduler runs need more time and memory than a web request. * Both calls are best effort. */ private static function raise_runtime_limits(): void { if ( function_exists( 'set_time_limit' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_set_time_limit -- Raises the PHP time limit for a long chunk; hosts that disable it return false and the job continues. @set_time_limit( 0 ); } if ( function_exists( 'wp_raise_memory_limit' ) ) { wp_raise_memory_limit( 'admin' ); } } public static function handle( int $migration_id ): void { global $wpdb; self::raise_runtime_limits(); $logger = new MigrationLogger( $migration_id ); // Check the file stage before building the engine, which is wasted work // if this worker returns immediately. // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $file_offsets_raw = $wpdb->get_var( $wpdb->prepare( 'SELECT file_offsets_json FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_migrations', $migration_id ) ); // Only a non-empty value means a file stage is active. NULL, '' and [] // let the DB worker proceed. if ( $file_offsets_raw !== null && $file_offsets_raw !== '' ) { $decoded = json_decode( $file_offsets_raw, true ); if ( JSON_ERROR_NONE !== json_last_error() ) { $logger->critical( 'Corrupt file_offsets_json in DB worker check.', [ 'json_error' => json_last_error_msg(), ] ); // Stale offsets are safe to clear in the database stage; in a file // stage they mean real state, so the run fails instead. // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $current_stage = $wpdb->get_var( $wpdb->prepare( 'SELECT current_stage FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_migrations', $migration_id ) ); if ( 'database' === ( $current_stage ?? '' ) ) { $logger->info( 'Auto-repair: clearing corrupt file_offsets_json during database stage.', [ 'migration_id' => $migration_id, ] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $wpdb->update( $wpdb->prefix . 'shiftora_migrations', [ 'file_offsets_json' => null, 'updated_at' => current_time( 'mysql', true ) ], [ 'id' => $migration_id ], [ '%s', '%s' ], [ '%d' ] ); // Continue to the DB worker below. } else { $logger->error( 'Auto-repair not possible, corrupt file_offsets_json during file stage.', [ 'current_stage' => $current_stage, ] ); try { $engine = self::build_engine( $wpdb ); $engine->fail( $migration_id, 'Migration file offset state is corrupt and auto-repair not possible during file stage.' ); } catch ( \Throwable $e ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $wpdb->update( $wpdb->prefix . 'shiftora_migrations', [ 'status' => MigrationStatus::Failed->value, 'error_message' => 'Migration file offset state is corrupt and auto-repair not possible during file stage.', 'updated_at' => current_time( 'mysql', true ), ], [ 'id' => $migration_id ], [ '%s', '%s', '%s' ], [ '%d' ] ); } return; } } elseif ( is_array( $decoded ) && $decoded !== [] ) { // File stage is active, so leave it to the file worker. return; } } try { $engine = self::build_engine( $wpdb ); $engine->load( $migration_id ); $engine->enable_pull_batching(); $deadline = microtime( true ) + self::JOB_TIME_BUDGET_SECONDS; $more = false; for ( $i = 0; $i < self::MAX_CHUNKS_PER_JOB; $i++ ) { try { $more = $engine->process_next_chunk( $migration_id, $deadline ); } catch ( DeadlineExceeded $e ) { // Budget exhausted mid-chunk. The next scheduled action continues. $logger->info( $e->getMessage(), [ 'migration_id' => $migration_id ] ); $more = true; break; } if ( ! $more || microtime( true ) >= $deadline ) { break; } $record = $engine->load( $migration_id ); if ( ! empty( $record->file_offsets_json ) ) { break; } } if ( $more ) { $record = $engine->load( $migration_id ); if ( empty( $record->file_offsets_json ) ) { if ( ! self::schedule( $migration_id ) ) { $logger->error( 'DB chunk reschedule failed: scheduler unavailable.' ); $engine->pause_for_scheduler_unavailable( $migration_id, 'Background worker reschedule failed.' ); } } } } catch ( \Throwable $e ) { $logger->critical( 'Unhandled exception in migration job.', [ 'exception' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ] ); try { if ( self::operator_resolved( $migration_id, $wpdb ) ) { $logger->warning( 'Job finished over a stopped migration; not overwriting its outcome.', [ 'migration_id' => $migration_id, ] ); return; } $engine = $engine ?? self::build_engine( $wpdb ); $engine->fail( $migration_id, $e ); } catch ( \Throwable $inner ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $wpdb->update( $wpdb->prefix . 'shiftora_migrations', [ 'status' => MigrationStatus::Failed->value, 'error_message' => $e->getMessage(), 'updated_at' => current_time( 'mysql', true ), ], [ 'id' => $migration_id ], [ '%s', '%s', '%s' ], [ '%d' ] ); } } } /** * Handle a single file chunk. Called by Action Scheduler. * * @param int $migration_id Migration ID. */ public static function handle_file_chunk( int $migration_id ): void { self::raise_runtime_limits(); global $wpdb; $logger = new MigrationLogger( $migration_id ); try { $engine = self::build_engine( $wpdb ); $record = $engine->load( $migration_id ); if ( 'database' === ( $record->current_stage ?? '' ) ) { return; } $deadline = microtime( true ) + self::FILE_JOB_TIME_BUDGET_SECONDS; $more = false; for ( $i = 0; $i < self::MAX_FILE_CHUNKS_PER_JOB; $i++ ) { try { $more = $engine->process_next_file_chunk( $migration_id, $deadline ); } catch ( DeadlineExceeded $e ) { // Budget exhausted mid-chunk. The next scheduled action continues. $logger->info( $e->getMessage(), [ 'migration_id' => $migration_id ] ); $more = true; break; } if ( ! $more || microtime( true ) >= $deadline ) { break; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $foffs = $wpdb->get_var( $wpdb->prepare( 'SELECT file_offsets_json FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_migrations', $migration_id ) ); if ( $foffs === null || $foffs === '' ) { break; } } if ( $more ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $foffs = $wpdb->get_var( $wpdb->prepare( 'SELECT file_offsets_json FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_migrations', $migration_id ) ); $scheduled = false; if ( $foffs !== null && $foffs !== '' ) { $scheduled = self::schedule_file( $migration_id ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $current_stage = $wpdb->get_var( $wpdb->prepare( 'SELECT current_stage FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_migrations', $migration_id ) ); if ( 'database' === ( $current_stage ?? '' ) ) { $scheduled = self::schedule( $migration_id ); } } if ( ! $scheduled ) { $logger->error( 'Chunk reschedule failed: scheduler unavailable.' ); $engine->pause_for_scheduler_unavailable( $migration_id, 'Background worker reschedule failed.' ); } } } catch ( \Throwable $e ) { $logger->critical( 'Unhandled exception in file-chunk job.', [ 'exception' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ] ); try { if ( self::operator_resolved( $migration_id, $wpdb ) ) { $logger->warning( 'File job finished over a stopped migration; not overwriting its outcome.', [ 'migration_id' => $migration_id, ] ); return; } $engine = $engine ?? self::build_engine( $wpdb ); $engine->fail( $migration_id, $e ); } catch ( \Throwable $inner ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $wpdb->update( $wpdb->prefix . 'shiftora_migrations', [ 'status' => MigrationStatus::Failed->value, 'error_message' => $e->getMessage(), 'updated_at' => current_time( 'mysql', true ), ], [ 'id' => $migration_id ], [ '%s', '%s', '%s' ], [ '%d' ] ); } } } /** * Process whichever stage is active, for a caller driving the migration itself. * * `kick_now()` only advances the database stage, so a caller looping on it never * moves a file stage where nothing fires `shiftora_process_file_chunk` (pure CLI, * WP-Cron disabled). This runs the matching processor and fails the run on an * unrecoverable error instead of leaving it "running". A migration that is not * running is left alone so a state set elsewhere is not overwritten. */ public static function kick_active_stage( int $migration_id, int $seconds ): void { self::raise_runtime_limits(); global $wpdb; $logger = new MigrationLogger( $migration_id ); $engine = null; try { $engine = self::build_engine( $wpdb ); $record = $engine->load( $migration_id ); if ( MigrationStatus::Running !== $record->status ) { return; } $is_file_stage = ! empty( $record->file_offsets_json ); /* * Floored at the real job budget, not the poll interval: a near deadline * caps every request at MIN_REQUEST_TIMEOUT, so an ordinary slow chunk * would never finish. The floor does not make a retry survive a down * remote; the caller's stall guard (MigrationWatchLoop) bounds that. */ $budget = $is_file_stage ? self::FILE_JOB_TIME_BUDGET_SECONDS : self::JOB_TIME_BUDGET_SECONDS; $deadline = microtime( true ) + max( $seconds, $budget ); /* * One chunk, not a loop: the caller already repeats on its own schedule, * and looping here fought a scheduled worker for the lock. */ if ( $is_file_stage ) { self::drive_file_stage( $engine, $migration_id, $deadline ); } else { self::drive_database_stage( $engine, $migration_id, $deadline ); } } catch ( \Throwable $e ) { $logger->critical( 'Unhandled exception while driving migration from the CLI.', [ 'exception' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ] ); try { if ( self::operator_resolved( $migration_id, $wpdb ) ) { return; } $engine = $engine ?? self::build_engine( $wpdb ); $engine->fail( $migration_id, $e ); } catch ( \Throwable $inner ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $wpdb->update( $wpdb->prefix . 'shiftora_migrations', [ 'status' => MigrationStatus::Failed->value, 'error_message' => $e->getMessage(), 'updated_at' => current_time( 'mysql', true ), ], [ 'id' => $migration_id ], [ '%s', '%s', '%s' ], [ '%d' ] ); } } } /** * One database chunk. DeadlineExceeded means the engine yielded a retry; the * caller's stall guard, not this deadline, bounds a down remote. */ private static function drive_database_stage( MigrationEngine $engine, int $migration_id, float $deadline ): void { try { $engine->process_next_chunk( $migration_id, $deadline ); } catch ( DeadlineExceeded $e ) { return; } } /** * One file chunk, mirroring {@see drive_database_stage()}. */ private static function drive_file_stage( MigrationEngine $engine, int $migration_id, float $deadline ): void { try { $engine->process_next_file_chunk( $migration_id, $deadline ); } catch ( DeadlineExceeded $e ) { return; } } /** * Build a MigrationEngine with all dependencies wired. */ private static function build_engine( \wpdb $wpdb ): MigrationEngine { return ServiceFactory::migration_engine( $wpdb ); } /** * Synchronous kick right after schedule(), so the first status poll shows * progress. Processes DB chunks for at most $max_seconds, then re-schedules. * * @param int $max_seconds Maximum wall-clock seconds to spend. */ public static function kick_now( int $migration_id, int $max_seconds = 5 ): void { self::raise_runtime_limits(); global $wpdb; $deadline = microtime( true ) + $max_seconds; $logger = new MigrationLogger( $migration_id ); try { $engine = self::build_engine( $wpdb ); $engine->load( $migration_id ); $more = false; for ( $i = 0; $i < self::MAX_CHUNKS_PER_JOB; $i++ ) { try { $more = $engine->process_next_chunk( $migration_id, $deadline ); } catch ( DeadlineExceeded $e ) { // Kick window exhausted. The scheduled worker picks it up. $more = true; break; } if ( ! $more || microtime( true ) >= $deadline ) { break; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $foffs = $wpdb->get_var( $wpdb->prepare( 'SELECT file_offsets_json FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_migrations', $migration_id ) ); if ( $foffs !== null && $foffs !== '' ) { break; } } if ( $more ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $foffs = $wpdb->get_var( $wpdb->prepare( 'SELECT file_offsets_json FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_migrations', $migration_id ) ); if ( $foffs === null || $foffs === '' ) { self::schedule( $migration_id ); } self::dispatch_queue_runner(); } $logger->info( 'Kick bootstrap completed.', [ 'max_seconds' => $max_seconds, 'has_more' => $more, ] ); } catch ( \Throwable $e ) { $logger->error( 'Kick bootstrap failed.', [ 'error' => $e->getMessage(), ] ); } } /** * Start the queued worker now. Action Scheduler only fires async runs on wp-admin * page loads, and the wizard polls over REST, so otherwise the first worker waits * for the next WP-Cron tick. */ private static function dispatch_queue_runner(): void { if ( ! class_exists( 'ActionScheduler_AsyncRequest_QueueRunner' ) || ! class_exists( 'ActionScheduler' ) || ! did_action( 'action_scheduler_init' ) ) { return; } try { ( new \ActionScheduler_AsyncRequest_QueueRunner( \ActionScheduler::store() ) )->maybe_dispatch(); } catch ( \Throwable $e ) { // WP-Cron still runs it, just later. unset( $e ); } } }