pins = new BackupPinStore( $wpdb ); } /** Registers the hook Action Scheduler fires for shiftora_auto_recovery. */ public function register_hooks(): void { add_action( 'shiftora_auto_recovery', [ $this, 'run_auto_recovery' ], 10, 1 ); } /** * Creates a recovery lock before a migration starts, only when a restore point * exists, so file-only migrations get none. * * @param int $backup_id Restore point backup ID; 0 skips lock creation. */ public function create_lock( int $migration_id, int $backup_id ): void { if ( $backup_id <= 0 ) { $logger = new MigrationLogger( $migration_id ); $logger->info( 'No restore point available, recovery lock not created.' ); return; } $data = [ 'migration_id' => $migration_id, 'backup_id' => $backup_id, 'created_at' => time(), 'status' => 'active', ]; set_transient( self::lock_key( $migration_id ), $data, DAY_IN_SECONDS ); // The pin survives an object cache flush that clears the transient. $this->pins->pin( $backup_id, $migration_id ); $logger = new MigrationLogger( $migration_id ); $logger->info( 'Snapshot-based recovery lock created.', [ 'backup_id' => $backup_id, ] ); $this->supersede_stale_locks( $migration_id ); } /** Updates the recovery lock with current progress. */ public function update_progress( int $migration_id, string $stage, int $progress ): void { $lock = get_transient( self::lock_key( $migration_id ) ); if ( ! is_array( $lock ) ) { return; } $lock['last_stage'] = $stage; $lock['last_progress'] = $progress; $lock['updated_at'] = time(); set_transient( self::lock_key( $migration_id ), $lock, DAY_IN_SECONDS ); } /** * Marks a migration failed in the recovery lock and schedules auto-recovery. * * Uses Action Scheduler with a wp-cron fallback, and logs when neither can * schedule so recovery is never assumed to be automatic. */ public function mark_failed( int $migration_id, string $error_message ): void { $lock = get_transient( self::lock_key( $migration_id ) ); $logger = new MigrationLogger( $migration_id ); if ( ! is_array( $lock ) ) { $logger->warning( 'Migration failed but no recovery lock was found. Snapshot-based recovery is not available.', [ 'error' => $error_message, ] ); return; } $lock['status'] = 'failed'; $lock['failed_at'] = time(); $lock['error_message'] = $error_message; set_transient( self::lock_key( $migration_id ), $lock, DAY_IN_SECONDS ); $logger->warning( 'Migration failed. Snapshot-based recovery is available.', [ 'error' => $error_message, ] ); $scheduled = false; if ( function_exists( 'as_schedule_single_action' ) ) { $action_id = as_schedule_single_action( time() + 30, 'shiftora_auto_recovery', [ $migration_id ], 'shiftora' ); $scheduled = is_int( $action_id ) && $action_id > 0; } if ( ! $scheduled && function_exists( 'wp_schedule_single_event' ) ) { $scheduled = wp_schedule_single_event( time() + 30, 'shiftora_auto_recovery', [ $migration_id ] ); } if ( ! $scheduled ) { $logger->error( 'Failed to schedule automatic recovery, no scheduler available.', [ 'migration_id' => $migration_id, ] ); } } /** * Records that automatic recovery decided not to run, so it is not asked again. * Otherwise the stall cron would re-fail the run and overwrite its original * error. The lock stays: the notice and pinned snapshot still describe a real failure. * * @param array $lock Current lock. */ private function settle_skip( int $migration_id, array $lock, string $reason ): void { $lock['status'] = 'recovery_skipped'; $lock['skip_reason'] = $reason; set_transient( self::lock_key( $migration_id ), $lock, DAY_IN_SECONDS ); } /** Clears the recovery lock after manual cleanup or a successful restore. */ public function clear_lock( int $migration_id ): void { delete_transient( self::lock_key( $migration_id ) ); $this->pins->unpin_for_migration( $migration_id ); } /** * Cancels recovery for older migrations on the same connection. A newer run * makes their snapshots stale, and a still-armed lock would let a queued * auto-recovery roll back over the newer work. */ private function supersede_stale_locks( int $migration_id ): void { $connection_id = $this->own_connection_id( $migration_id ); if ( null === $connection_id || $connection_id <= 0 ) { return; } // Same connection, strictly older ids. The per-lock check still runs so locks // written before the pins table existed are honored. $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id FROM %i WHERE connection_id = %d AND id < %d', $wpdb->prefix . 'shiftora_migrations', $connection_id, $migration_id ) ); if ( ! is_array( $rows ) ) { return; } foreach ( $rows as $row ) { $candidate_id = (int) $row->id; if ( ! $this->has_lock( $candidate_id ) ) { continue; } $this->clear_lock( $candidate_id ); $this->wpdb->update( $this->wpdb->prefix . 'shiftora_migrations', [ 'recovery_status' => 'superseded' ], [ 'id' => $candidate_id ], [ '%s' ], [ '%d' ] ); } } /** Connection id of a migration, or null when it has none. */ private function own_connection_id( int $migration_id ): ?int { $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $row = $wpdb->get_row( $wpdb->prepare( 'SELECT connection_id FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_migrations', $migration_id ) ); return isset( $row->connection_id ) ? (int) $row->connection_id : null; } /** Whether another migration on the same connection started after this one. */ private function newer_migration_exists( int $connection_id, int $migration_id ): bool { $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id FROM %i WHERE connection_id = %d AND id > %d LIMIT 1', $wpdb->prefix . 'shiftora_migrations', $connection_id, $migration_id ) ); return is_array( $rows ) && [] !== $rows; } /** * Backups still pinned as restore points by live recovery locks. * * Retention calls this before sweeping, so an active or failed migration keeps * the snapshot a rollback could need. The pin table is the source of truth * because it survives a transient or cache flush. * * @return int[] Backup ids referenced by any recovery lock. */ public static function pinned_backup_ids( \wpdb $wpdb ): array { return ( new BackupPinStore( $wpdb ) )->pinned_backup_ids(); } /** Detects unfinished migrations and shows a notice when recovery is available. Hooked to admin_init. */ public function detect_and_offer_recovery(): void { // A restore is an administrator's decision; other roles neither see the notice nor cost a lookup. if ( ! current_user_can( 'manage_options' ) ) { return; } // One pin read for the whole pass, shared by the filter and the notices. $pins = $this->pins->pins_by_migration(); $unfinished = $this->find_unfinished_migrations( $pins ); if ( empty( $unfinished ) ) { return; } foreach ( $unfinished as $migration ) { $this->maybe_show_recovery_notice( (int) $migration->id, $pins[ (int) $migration->id ] ?? [] ); } } /** * Runs automatic recovery for a migration. * * A live recovery lock is the only thing that may authorize a restore, so a * delayed action cannot revive a cleared recovery. The row is re-read just * before restoring so a resumed, completed or canceled run is never rolled * back. On failure the lock is kept with status=recovery_failed. */ public function run_auto_recovery( int $migration_id ): void { $logger = new MigrationLogger( $migration_id ); $lock = get_transient( self::lock_key( $migration_id ) ); if ( ! is_array( $lock ) ) { $logger->warning( 'Auto-recovery skipped, no recovery lock present.', [ 'migration_id' => $migration_id, ] ); return; } $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $row = $wpdb->get_row( $wpdb->prepare( 'SELECT id, status, direction, recovery_status, connection_id FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_migrations', $migration_id ) ); $reason = self::auto_recovery_allowed( $row, $lock ); if ( '' !== $reason ) { $logger->warning( 'Auto-recovery skipped.', [ 'migration_id' => $migration_id, 'reason' => $reason, ] ); $this->settle_skip( $migration_id, $lock, $reason ); return; } // A newer run on the same connection took over; restoring the old snapshot would overwrite its work. $connection_id = (int) ( $row->connection_id ?? 0 ); if ( $connection_id > 0 && $this->newer_migration_exists( $connection_id, $migration_id ) ) { $logger->warning( 'Auto-recovery skipped, a newer migration superseded this run.', [ 'migration_id' => $migration_id, 'connection_id' => $connection_id, ] ); $this->settle_skip( $migration_id, $lock, 'a newer migration superseded this run' ); return; } $backup_id = (int) ( $lock['backup_id'] ?? 0 ); if ( $backup_id <= 0 ) { $logger->warning( 'Auto-recovery skipped, no restore point found.', [ 'migration_id' => $migration_id, ] ); $this->settle_skip( $migration_id, $lock, 'no restore point found' ); return; } $logger->info( 'Starting automatic recovery from restore point.', [ 'backup_id' => $backup_id, ] ); $recovery_error = ''; try { $result = $this->rollback_manager->restore_snapshot( $backup_id ); if ( ! empty( $result['success'] ) ) { $logger->info( 'Automatic recovery completed successfully, state restored from snapshot.', [ 'backup_id' => $backup_id, ] ); // Warn-only sanity check of the restored site URLs and users. $verify_issues = $this->verify_restore_result(); if ( ! empty( $verify_issues ) ) { $logger->warning( 'Auto-recovery: post-restore verification found issues.', [ 'issues' => $verify_issues, ] ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $previous_error = (string) $wpdb->get_var( $wpdb->prepare( 'SELECT error_message FROM %i WHERE id = %d', $wpdb->prefix . 'shiftora_migrations', $migration_id ) ); $updated = $this->wpdb->update( $this->wpdb->prefix . 'shiftora_migrations', [ 'status' => MigrationStatus::Recovered->value, 'error_message' => $this->compose_recovered_message( $previous_error ), 'recovery_status' => 'restored', 'recovery_error' => '', 'updated_at' => current_time( 'mysql', true ), ], [ 'id' => $migration_id ], [ '%s', '%s', '%s', '%s', '%s' ], [ '%d' ] ); if ( false === $updated ) { $logger->error( 'Auto-recovery: restored snapshot but failed to update migration record.', [ 'migration_id' => $migration_id, 'db_error' => $this->wpdb->last_error, ] ); $lock['status'] = 'recovery_failed'; $lock['recovery_error'] = "Recovery restored snapshot but failed to mark migration as recovered: {$this->wpdb->last_error}"; $lock['failed_at'] = time(); set_transient( self::lock_key( $migration_id ), $lock, DAY_IN_SECONDS ); $this->wpdb->update( $this->wpdb->prefix . 'shiftora_migrations', [ 'recovery_status' => 'failed', 'recovery_error' => $lock['recovery_error'], 'updated_at' => current_time( 'mysql', true ), ], [ 'id' => $migration_id ], [ '%s', '%s', '%s' ], [ '%d' ] ); return; } $this->clear_lock( $migration_id ); return; } $recovery_error = 'Restore snapshot returned no success.'; } catch ( \Throwable $e ) { $recovery_error = $e->getMessage(); $logger->error( 'Automatic recovery failed: ' . $recovery_error, [ 'backup_id' => $backup_id, ] ); } if ( ! is_array( $lock ) ) { $lock = [ 'migration_id' => $migration_id, 'backup_id' => $backup_id, 'created_at' => time(), ]; } $lock['status'] = 'recovery_failed'; $lock['recovery_error'] = $recovery_error; $lock['failed_at'] = time(); set_transient( self::lock_key( $migration_id ), $lock, DAY_IN_SECONDS ); $this->wpdb->update( $this->wpdb->prefix . 'shiftora_migrations', [ 'error_message' => __( 'Migration failed and automatic snapshot recovery could not complete. Manual restore may be required.', 'shiftora' ), 'recovery_status' => 'failed', 'recovery_error' => $recovery_error, 'updated_at' => current_time( 'mysql', true ), ], [ 'id' => $migration_id ], [ '%s', '%s', '%s', '%s' ], [ '%d' ] ); } /** * Whether a snapshot restore may run. The lock names the exact backup and its * presence shows the migration was not resolved since; the live row is checked * so a resumed, completed, canceled or already-restored run stops the action. * * @param object|null $row Current shiftora_migrations row. * @param array|null $lock Recovery lock transient. * @return string Empty when allowed, otherwise the reason it is blocked. */ public static function auto_recovery_allowed( ?object $row, ?array $lock ): string { if ( null === $row ) { return 'migration record not found'; } if ( null === $lock ) { return 'recovery lock cleared or missing'; } if ( MigrationStatus::Failed->value !== (string) ( $row->status ?? '' ) ) { return sprintf( 'migration status is %s, not failed', (string) ( $row->status ?? 'unknown' ) ); } if ( MigrationDirection::Pull->value !== (string) ( $row->direction ?? '' ) ) { return sprintf( 'migration direction is %s, a local restore would revert the source', (string) ( $row->direction ?? 'unknown' ) ); } if ( 'restored' === (string) ( $row->recovery_status ?? '' ) ) { return 'migration already restored'; } if ( 'recovery_failed' === ( $lock['status'] ?? '' ) ) { return 'a prior automatic restore failed; manual restore only'; } if ( (int) ( $lock['backup_id'] ?? 0 ) <= 0 ) { return 'lock carries no restore point'; } return ''; } /** Appends the checkpoint note to the original error so history shows both cause and rollback. */ private function compose_recovered_message( string $original_error ): string { $notice = __( 'Shiftora restored the previous state from the recovery checkpoint.', 'shiftora' ); if ( '' === trim( $original_error ) ) { return $notice; } return $original_error . ' ' . $notice; } /** * Validates siteurl, home and user existence after a restore. Warnings only, * so verification cannot block the success path. * * @return string[] Issue descriptions, empty when all is well. */ private function verify_restore_result(): array { $issues = []; $site_url = untrailingslashit( get_site_url() ); $home = untrailingslashit( get_home_url() ); if ( '' === $site_url ) { $issues[] = 'site_url is empty after restore'; } if ( '' === $home ) { $issues[] = 'home URL is empty after restore'; } if ( $site_url !== $home ) { $issues[] = "site_url ({$site_url}) differs from home ({$home})"; } // An empty users table is a basic DB integrity signal. $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the transfer reads and writes the site's own tables, a cached copy would be stale $user_count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $wpdb->prefix . 'users' ) ); if ( $user_count === null || (int) $user_count === 0 ) { $issues[] = 'users table appears empty after restore'; } return $issues; } /** Whether a migration has a recovery lock. */ public function has_lock( int $migration_id ): bool { return false !== get_transient( self::lock_key( $migration_id ) ); } /** * Gets recovery lock data for a migration. * * @return array|null */ public function get_lock( int $migration_id ): ?array { $lock = get_transient( self::lock_key( $migration_id ) ); return is_array( $lock ) ? $lock : null; } /** * Finds migrations that look unfinished: running with a stale heartbeat, or * failed with a recovery lock or pin. * * @param array $pins Pin map from BackupPinStore; read here when the caller has not fetched it. * @return array */ private function find_unfinished_migrations( array $pins = [] ): array { $threshold = self::STALL_THRESHOLD; $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $all = $wpdb->get_results( $wpdb->prepare( 'SELECT id, status, last_heartbeat_at, error_message FROM %i WHERE ( status = %s AND last_heartbeat_at IS NOT NULL AND last_heartbeat_at < DATE_SUB( UTC_TIMESTAMP(), INTERVAL %d SECOND ) ) OR ( status = %s )', $wpdb->prefix . 'shiftora_migrations', MigrationStatus::Running->value, $threshold, MigrationStatus::Failed->value ) ); if ( empty( $all ) ) { return []; } // A failed migration only matters if a restore point exists. The pin table is // the source of truth so a cache flush cannot hide a failure; the transient // check covers locks written before the pin table existed. $pinned = [] === $pins ? array_flip( $this->pins->pinned_migration_ids() ) : array_flip( array_keys( $pins ) ); $filtered = []; foreach ( $all as $row ) { if ( MigrationStatus::Failed->value === $row->status ) { if ( ! isset( $pinned[ (int) $row->id ] ) && ! $this->has_lock( (int) $row->id ) ) { continue; } } $filtered[] = $row; } return $filtered; } /** * Shows an admin notice when recovery is available for a migration. * * The lock authorizes a restore, so without it the one-click action is * withheld. The pin outlives the transient, so a cache flush costs the button * but not the notice; a failed pull may leave the site half-migrated. * * @param int[] $pinned_backup Backup ids still pinned for it. */ private function maybe_show_recovery_notice( int $migration_id, array $pinned_backup = [] ): void { $notice_id = 'shiftora_recovery_' . $migration_id; if ( get_transient( $notice_id . '_dismissed' ) ) { return; } $lock = $this->get_lock( $migration_id ); $is_authorised = is_array( $lock ) && isset( $lock['backup_id'] ); $restore_url = $is_authorised ? wp_nonce_url( admin_url( 'admin-post.php?action=shiftora_recovery_restore&migration_id=' . $migration_id ), 'shiftora_recovery_' . $migration_id ) : ''; if ( ! $is_authorised && [] === $pinned_backup ) { return; } $is_failed = $is_authorised && ( $lock['status'] ?? '' ) === 'recovery_failed'; $notice_type = $is_failed ? 'notice-error' : 'notice-warning'; if ( $is_failed ) { $message = __( 'Shiftora automatic recovery did not complete. Open Backups and restore the restore point saved before this migration.', 'shiftora' ); } elseif ( $is_authorised ) { $message = __( 'Shiftora detected an unfinished migration. A restore point was created before this migration, you can safely restore the previous state.', 'shiftora' ); } else { $message = __( 'Shiftora detected a failed migration and is still holding its restore point. The recovery record for that run has expired, so the one-click restore is gone. Open Backups to restore it by hand.', 'shiftora' ); } $dismiss_url = wp_nonce_url( admin_url( 'admin-post.php?action=shiftora_dismiss_recovery_notice&migration_id=' . $migration_id ), AdminPostHandlers::dismiss_action( $migration_id ) ); add_action( 'admin_notices', static function () use ( $notice_id, $restore_url, $dismiss_url, $message, $notice_type ): void { // Only on Shiftora's own screens, so the rest of the dashboard stays free of it. $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || ! in_array( $screen->id, Assets::PAGE_HOOKS, true ) ) { return; } ?>