get_results( $wpdb->prepare( "SELECT id, file_offsets_json FROM %i WHERE status = 'running' AND last_heartbeat_at IS NOT NULL AND last_heartbeat_at < DATE_SUB( UTC_TIMESTAMP(), INTERVAL %d SECOND )", $wpdb->prefix . 'shiftora_migrations', $threshold ) ); foreach ( (array) $rows as $row ) { $migration_id = (int) $row->id; $in_file_stage = isset( $row->file_offsets_json ) && null !== $row->file_offsets_json; // Pick the hook that matches the current stage. $scheduled = false; if ( $in_file_stage ) { $scheduled = MigrationJob::schedule_file( $migration_id ); } else { $scheduled = MigrationJob::schedule( $migration_id ); } if ( ! $scheduled ) { ( new MigrationLogger( $migration_id ) )->error( 'Stall recovery: failed to re-schedule migration. Scheduler unavailable.', [ 'migration_id' => $migration_id ] ); continue; } ( new MigrationLogger( $migration_id ) )->warning( "Stalled migration detected and re-scheduled (no heartbeat for {$threshold}s).", [ 'migration_id' => $migration_id ] ); } if ( null !== $finish_cancel ) { self::finish_stuck_cancels( $wpdb, $finish_cancel, $threshold ); } } /** * Whether a cancelling migration has no worker left. Cancel parks the row until a * worker or a retry settles it; if the worker died, nothing else ever will. * Judged on the row alone: a held lock or a recent heartbeat means someone is on it. */ public static function is_cancel_orphaned( object $row, int $now, int $threshold ): bool { $lock = isset( $row->worker_lock_expires ) ? strtotime( (string) $row->worker_lock_expires . ' UTC' ) : false; if ( false !== $lock && $lock > $now ) { return false; } // A migration cancelled before its first heartbeat has none; its last update is the next best clock. $seen = $row->last_heartbeat_at ?? $row->updated_at ?? null; $last = null !== $seen ? strtotime( (string) $seen . ' UTC' ) : false; return false === $last || ( $now - $last ) >= $threshold; } private static function finish_stuck_cancels( \wpdb $wpdb, callable $finish_cancel, int $threshold ): void { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT id, last_heartbeat_at, updated_at, worker_lock_expires FROM %i WHERE status = 'cancelling'", $wpdb->prefix . 'shiftora_migrations' ) ); foreach ( $rows as $row ) { if ( ! self::is_cancel_orphaned( $row, time(), $threshold ) ) { continue; } $migration_id = (int) $row->id; ( new MigrationLogger( $migration_id ) )->warning( "Cancelling migration has no worker (no heartbeat for {$threshold}s); finishing the cancel.", [ 'migration_id' => $migration_id ] ); $finish_cancel( $migration_id ); } } /** * Register the 2-minute WP-Cron schedule if not already registered. Call on * 'admin_init' and after activation. */ public static function schedule_cron(): void { if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time(), self::CRON_INTERVAL, self::CRON_HOOK ); } } /** * Remove the WP-Cron event. Call on deactivation. */ public static function unschedule_cron(): void { wp_clear_scheduled_hook( self::CRON_HOOK ); } }