|WP_Error */ public static function advance( string $job_id, Steppable $task ) { $record = Job_Store::get( $job_id ); if ( null === $record ) { return new WP_Error( 'transtrix_job_not_found', __( 'That export or import no longer exists. It may have been deleted from History.', 'transtrix' ) ); } if ( in_array( Job_Store::status( $job_id ), Job_Store::TERMINAL, true ) ) { return self::progress( $job_id, $record, true ); } if ( ! Step_Lock::acquire( $job_id ) ) { return self::progress( $job_id, $record, false ); } // A closed browser tab must not kill PHP part way through appending to // the archive. The truncate-on-resume in Stream_Zip_Writer repairs a // torn write, but not writing one in the first place is cheaper. ignore_user_abort( true ); register_shutdown_function( array( self::class, 'fail_on_fatal' ), $job_id ); try { return self::run_step( $job_id, $task ); } finally { Step_Lock::release( $job_id ); } } /** * Abandon a run and throw away its partial work. * * @param string $job_id Job id. * @param Steppable $task The work. * @return void */ public static function abandon( string $job_id, Steppable $task ): void { $state = Step_State::load( $job_id ); if ( is_array( $state ) ) { $task->cleanup( $state ); } Step_State::forget( $job_id ); Step_State::clean_work_dir( $job_id ); Step_Lock::release( $job_id ); } /** * Whether a job may still be cancelled. * * @param string $job_id Job id. * @param Steppable $task The work. * @return bool */ public static function is_cancellable( string $job_id, Steppable $task ): bool { $record = Job_Store::get( $job_id ); if ( null === $record || in_array( $record['status'], Job_Store::TERMINAL, true ) ) { return false; } $phase = isset( $record['phase'] ) ? (string) $record['phase'] : ''; return ! in_array( $phase, $task->uncancellable_phases(), true ); } /** * The locked part: load the cursor, delegate, persist, heartbeat. * * @param string $job_id Job id. * @param Steppable $task The work. * @return array|WP_Error */ private static function run_step( string $job_id, Steppable $task ) { $state = Step_State::load( $job_id ); if ( is_wp_error( $state ) ) { // Never best-effort resume from state we cannot read. A guessed // cursor writes a corrupt archive and reports success. self::fail( $job_id, $state->get_error_message() ); return $state; } if ( null === $state ) { return new WP_Error( 'transtrix_job_not_started', __( 'There is no saved progress to continue from. Start it again.', 'transtrix' ) ); } self::$stepping = $job_id; $result = $task->step( $state, Budget::start() ); self::$stepping = ''; if ( is_wp_error( $result ) ) { $task->cleanup( $state ); self::fail( $job_id, $result->get_error_message() ); return $result; } $cursor = isset( $result['cursor'] ) && is_array( $result['cursor'] ) ? $result['cursor'] : $state; Step_State::save( $job_id, $cursor ); $data = array( 'done' => isset( $result['done'] ) ? (int) $result['done'] : 0, 'total' => isset( $result['total'] ) ? (int) $result['total'] : 0, 'phase' => isset( $cursor['phase'] ) ? (string) $cursor['phase'] : '', // Opaque to the loop: whatever the task wants a client to see. 'detail' => isset( $result['detail'] ) && is_array( $result['detail'] ) ? $result['detail'] : array(), 'updated_at' => gmdate( 'c' ), ); if ( ! empty( $result['failures'] ) && is_array( $result['failures'] ) ) { $record = Job_Store::get( $job_id ); $existing = ( null !== $record && ! empty( $record['failures'] ) ) ? $record['failures'] : array(); $data['failures'] = array_merge( $existing, $result['failures'] ); } if ( ! empty( $result['complete'] ) ) { $finished = $task->finish( $cursor ); if ( is_wp_error( $finished ) ) { $task->cleanup( $cursor ); self::fail( $job_id, $finished->get_error_message() ); return $finished; } $data = array_merge( $data, $finished, array( 'status' => 'complete', 'finished_at' => gmdate( 'c' ), ) ); Step_State::forget( $job_id ); Step_State::clean_work_dir( $job_id ); } $record = Job_Store::update( $job_id, $data ); return self::progress( $job_id, (array) $record, ! empty( $result['complete'] ) ); } /** * Mark a job failed with a named reason. * * @param string $job_id Job id. * @param string $reason Why. * @return void */ private static function fail( string $job_id, string $reason ): void { $record = Job_Store::get( $job_id ); $failures = ( null !== $record && ! empty( $record['failures'] ) ) ? $record['failures'] : array(); $failures[] = array( 'item' => __( 'Progress', 'transtrix' ), 'reason' => $reason, ); Job_Store::update( $job_id, array( 'status' => 'failed', 'failures' => $failures, 'finished_at' => gmdate( 'c' ), 'updated_at' => gmdate( 'c' ), ) ); Step_State::forget( $job_id ); } /** * Fail a job whose step died on a PHP fatal. * * Without this the job reads 'running' until the stall timeout. Saved * progress is kept, so the garbage sweep can throw away what it staged: * cleaning up here could run out of memory again. * * @param string $job_id Job id. * @return void */ public static function fail_on_fatal( string $job_id ): void { $error = error_get_last(); $fatal = array( E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR ); if ( $job_id !== self::$stepping || null === $error || ! in_array( $error['type'], $fatal, true ) ) { return; } $record = Job_Store::get( $job_id ); if ( null === $record || in_array( $record['status'], Job_Store::TERMINAL, true ) ) { return; } $failures = ! empty( $record['failures'] ) ? $record['failures'] : array(); $failures[] = array( 'item' => __( 'Progress', 'transtrix' ), 'reason' => false !== strpos( (string) $error['message'], 'Allowed memory size' ) ? __( 'The server ran out of memory. Ask your host to raise the PHP memory limit, then try again.', 'transtrix' ) : __( 'The server hit an error and stopped. Try again. If it happens again, the site’s PHP error log shows the cause.', 'transtrix' ), ); Job_Store::update( $job_id, array( 'status' => 'failed', 'failures' => $failures, 'finished_at' => gmdate( 'c' ), ) ); } /** * The shape every step response takes. * * The token is the server's, not the client's — a client that owns a step * index can replay one. The client only ever echoes this back. * * @param string $job_id Job id. * @param array $record Job record. * @param bool $complete Whether the work is finished. * @return array */ private static function progress( string $job_id, array $record, bool $complete ): array { return array( 'job_id' => $job_id, 'token' => md5( $job_id . '|' . ( isset( $record['updated_at'] ) ? $record['updated_at'] : '' ) . '|' . ( isset( $record['done'] ) ? $record['done'] : 0 ) ), 'status' => isset( $record['status'] ) ? $record['status'] : 'running', 'phase' => isset( $record['phase'] ) ? $record['phase'] : '', 'done' => isset( $record['done'] ) ? (int) $record['done'] : 0, 'total' => isset( $record['total'] ) ? (int) $record['total'] : 0, 'complete' => $complete || ( isset( $record['status'] ) && in_array( $record['status'], Job_Store::TERMINAL, true ) ), 'failures' => isset( $record['failures'] ) ? $record['failures'] : array(), 'detail' => isset( $record['detail'] ) ? $record['detail'] : array(), ); } }