$data Fields to merge in. * @return array The stored record. */ public static function save( Job $job, array $data = array() ): array { $records = self::records(); $record = isset( $records[ $job->id() ] ) ? $records[ $job->id() ] : array( 'id' => $job->id(), 'type' => 'export', 'module_id' => $job->module_id(), 'user_id' => get_current_user_id(), 'status' => 'running', 'counts' => array(), 'failures' => array(), 'package_id' => '', 'package_name' => '', 'size' => 0, 'kind' => 'module', 'phase' => '', 'done' => 0, 'total' => 0, 'created_at' => $job->created_at(), 'updated_at' => $job->created_at(), 'finished_at' => '', ); $record = array_merge( $record, $data ); // The id is the key every lookup uses; a record that disagrees can never be deleted. $record['id'] = $job->id(); // Keep the package size once the run ends, so History still shows it // after the package file is deleted. if ( 'running' !== $record['status'] && empty( $record['size'] ) && '' !== (string) $record['package_id'] ) { $record['size'] = self::package_size( (string) $record['package_id'] ); } $is_new = ! isset( $records[ $job->id() ] ); self::write_one( $record ); // An import takes over its upload's row, so one file is one row in History. // The import now references the package, so the file stays. if ( 'import' === $record['type'] && '' !== (string) $record['package_id'] ) { foreach ( $records as $id => $other ) { if ( 'upload' === ( $other['type'] ?? '' ) && $other['package_id'] === $record['package_id'] ) { delete_option( self::PREFIX . $id ); } } } if ( $is_new ) { self::cap( array( $job->id() => $record ) + $records ); } return $record; } /** * Record an uploaded package, so History can show and delete it. * * Without a row, a file uploaded and then never imported sits on disk * until the daily sweep, with no way for the user to see or remove it. * * @param string $package_id Package id. * @param string $filename Name the browser gave it. * @param array $manifest The package's manifest. * @return void */ public static function record_upload( string $package_id, string $filename, array $manifest ): void { $full_site = 'full-site' === ( $manifest['kind'] ?? '' ); $module_id = $full_site ? 'full-site' : (string) ( $manifest['module_id'] ?? '' ); $fields = array( 'type' => 'upload', 'module_id' => $module_id, 'kind' => $full_site ? 'full-site' : 'module', 'status' => 'complete', 'package_id' => $package_id, 'package_name' => $filename, 'size' => self::package_size( $package_id ), 'failures' => array(), 'finished_at' => gmdate( 'c' ), ); $row = self::upload_row( $package_id ); // The row the upload has been reporting progress on. if ( null !== $row ) { self::update( (string) $row['id'], $fields ); return; } // Re-sending a file under the same upload id lands on the same package. if ( in_array( $package_id, self::package_ids(), true ) ) { return; } self::save( new Job( $module_id ), $fields ); } /** * Record how much of an upload has arrived. * * The row exists from the first piece, so an upload whose tab was closed * shows in History: failed once it goes quiet (collect_garbage), and * deletable with its part file. A piece arriving after that revives it. * * @param string $package_id Package id the upload will be stored under. * @param string $filename Name the browser gave it. * @param int $done Bytes received so far. * @param int $total Size of the whole file. * @return void */ public static function upload_sent( string $package_id, string $filename, int $done, int $total ): void { $fields = array( 'status' => 'running', 'done' => $done, 'total' => $total, 'size' => $total, 'failures' => array(), 'finished_at' => '', ); $row = self::upload_row( $package_id ); if ( null !== $row ) { self::update( (string) $row['id'], $fields ); return; } self::save( new Job( '' ), $fields + array( 'type' => 'upload', 'package_id' => $package_id, 'package_name' => $filename, ) ); } /** * Fail an upload whose file was refused once it all arrived. * * @param string $package_id Package id. * @param string $reason Why, as the user is told. * @return void */ public static function fail_upload( string $package_id, string $reason ): void { $row = self::upload_row( $package_id ); if ( null === $row ) { return; } self::update( (string) $row['id'], array( 'status' => 'failed', 'finished_at' => gmdate( 'c' ), 'failures' => array( array( 'item' => __( 'Upload', 'transtrix' ), 'reason' => $reason, ), ), ) ); } /** * The upload row for a package, if there is one. * * @param string $package_id Package id. * @return array|null */ private static function upload_row( string $package_id ): ?array { foreach ( self::records() as $record ) { if ( 'upload' === ( $record['type'] ?? '' ) && $package_id === (string) $record['package_id'] ) { return $record; } } return null; } /** * Size of a stored package, 0 when it is gone. * * @param string $package_id Package id. * @return int */ public static function package_size( string $package_id ): int { $path = Package_Store::path( $package_id ); return is_string( $path ) && is_file( $path ) ? (int) filesize( $path ) : 0; } /** * One job record. * * @param string $job_id Job id. * @return array|null */ public static function get( string $job_id ): ?array { self::migrate(); $record = get_option( self::PREFIX . $job_id ); return is_array( $record ) ? self::with_cancel( $record ) : null; } /** * The most recent jobs, newest first. * * @param int $limit How many. * @return array> */ public static function recent( int $limit = 20 ): array { return array_slice( array_values( self::records() ), 0, max( 1, $limit ) ); } /** * Merge fields into a stored record, by id. * * @param string $job_id Job id. * @param array $data Fields to merge in. * @return array|null The updated record, or null when there is none. */ public static function update( string $job_id, array $data ): ?array { $record = self::get( $job_id ); if ( null === $record ) { return null; } $record = array_merge( $record, $data ); if ( ! isset( $data['updated_at'] ) ) { $record['updated_at'] = gmdate( 'c' ); } self::write_one( $record ); return self::with_cancel( $record ); } /** * Mark a job cancelled. * * @param string $job_id Job id. * @return array|null The updated record, or null when there is nothing to cancel. */ public static function cancel( string $job_id ): ?array { $record = self::get( $job_id ); if ( null === $record || in_array( $record['status'], self::TERMINAL, true ) ) { return null; } // The flag is what makes it stick: a step finishing at this moment // rewrites the record, but never this row. add_option( self::CANCEL_PREFIX . $job_id, gmdate( 'c' ), '', false ); return self::update( $job_id, array( 'status' => 'cancelled', 'finished_at' => gmdate( 'c' ), ) ); } /** * Remove a finished job and the package it produced. * * The package goes too unless another record still points at it, so * clearing an import does not pull the file out from under its export. * * @param string $job_id Job id. * @return bool False when there is no such job or it is still running. */ public static function delete( string $job_id ): bool { $records = self::records(); if ( ! isset( $records[ $job_id ] ) || ! in_array( $records[ $job_id ]['status'], self::TERMINAL, true ) ) { return false; } $package_id = (string) ( $records[ $job_id ]['package_id'] ?? '' ); unset( $records[ $job_id ] ); if ( '' !== $package_id && ! in_array( $package_id, array_column( $records, 'package_id' ), true ) ) { Package_Store::delete( $package_id ); } Step_State::forget( $job_id ); Step_State::clean_work_dir( $job_id ); self::forget( $job_id ); return true; } /** * A job's status, read past any object cache. * * A call to get_option() inside a long step returns the value cached when the * request started, so a cancel issued by another request is invisible to the * step that needs to see it. One indexed query a few times per step is a * cheap price for cancellation that actually works. * * @param string $job_id Job id. * @return string Empty string when there is no such job. */ public static function status( string $job_id ): string { wp_cache_delete( self::PREFIX . $job_id, 'options' ); wp_cache_delete( self::CANCEL_PREFIX . $job_id, 'options' ); wp_cache_delete( 'notoptions', 'options' ); $record = self::get( $job_id ); return null === $record ? '' : (string) $record['status']; } /** * Whether a running job has stopped reporting in. * * @param array $record Job record. * @return bool */ public static function is_stalled( array $record ): bool { if ( in_array( $record['status'], self::TERMINAL, true ) ) { return false; } return self::silent_for( $record ) > self::STALL_SECONDS; } /** * Fail jobs whose runner went away and never came back. * * A tab closed or reloaded mid-run leaves a job 'running' forever: nothing * drives the next step, so it never finishes. Nothing resumes it either. * * @param callable|null $task_for Job id => its Steppable or null, so a * stepped job's staged work (a restore's * temp tables and folders) goes with it. * @return void */ public static function collect_garbage( ?callable $task_for = null ): void { $records = self::records(); // A task's cleanup is not scoped to its own job (a restore drops every // temp table), so nothing is cleaned while another run is still live. $live = array_filter( $records, static function ( $record ) { return ! in_array( $record['status'], self::TERMINAL, true ) && ! self::is_stalled( $record ); } ); foreach ( $records as $id => $record ) { $stalled = self::is_stalled( $record ); // No step checks for a cancel, and a step that died on a fatal had no // chance to clean up: both keep staged work until it is thrown away here. $ended = in_array( $record['status'], array( 'cancelled', 'failed' ), true ) && is_array( Step_State::load( (string) $id ) ); if ( ! $stalled && ! $ended ) { continue; } $task = null === $task_for ? null : $task_for( (string) $id ); // Past the point of no return the staged work is the site itself. // A cancelled run may still have a step in flight: leave it to finish. if ( ! $live && $task instanceof Steppable && ! in_array( (string) ( $record['phase'] ?? '' ), $task->uncancellable_phases(), true ) && ( $stalled || Step_Lock::acquire( (string) $id ) ) ) { Step_Loop::abandon( (string) $id, $task ); } if ( ! $stalled ) { continue; } $record['status'] = 'failed'; $record['finished_at'] = gmdate( 'c' ); $record['failures'][] = array( 'item' => __( 'Progress', 'transtrix' ), 'reason' => __( 'This stopped because the page was closed or reloaded before it finished. Start it again.', 'transtrix' ), ); Step_State::forget( (string) $id ); Step_State::clean_work_dir( (string) $id ); self::write_one( $record ); } } /** * Package ids jobs point at. * * @param bool $running_only Only jobs still running. * @return array */ public static function package_ids( bool $running_only = false ): array { $ids = array(); foreach ( self::records() as $record ) { if ( ! empty( $record['package_id'] ) && ( ! $running_only || ! in_array( $record['status'], self::TERMINAL, true ) ) ) { $ids[] = (string) $record['package_id']; } } return $ids; } /** * Whether an export run on this site made this package. * * Such a package is a backup the user may restore again, so a restore * must not delete it the way it deletes an uploaded one. * * @param string $package_id Package id. * @return bool */ public static function exported( string $package_id ): bool { foreach ( self::records() as $record ) { if ( 'export' === $record['type'] && $package_id === (string) $record['package_id'] ) { return true; } } return false; } /** * Whether this user started a run that made or used this package. * * @param string $package_id Package id. * @param int $user_id User id. * @return bool */ public static function made_by( string $package_id, int $user_id ): bool { foreach ( self::records() as $record ) { if ( $package_id === (string) $record['package_id'] && (int) ( $record['user_id'] ?? 0 ) === $user_id ) { return true; } } return false; } /** * Delete an uploaded package once an import has finished with it. * * Its content now lives in the site, undo reads the restore point rather * than the file, and a package left on disk is unpublished content one * URL away on a server that ignores .htaccess. A package this site * exported is a backup, and stays. * * @param string $package_id Package id. * @return void */ public static function release_upload( string $package_id ): void { if ( ! self::exported( $package_id ) ) { Package_Store::delete( $package_id ); } } /** * Delete the packages of all but the newest whole-site exports. * * The run stays in History with its report; only the file goes. A package * a running job still reads is left alone. * * @param int $keep How many whole-site packages to keep. * @return void */ public static function prune_full_site( int $keep ): void { $busy = self::package_ids( true ); $seen = 0; // Records are newest first. foreach ( self::records() as $record ) { if ( 'export' !== $record['type'] || 'full-site' !== $record['kind'] || 'complete' !== $record['status'] || '' === (string) $record['package_id'] ) { continue; } if ( ++$seen > $keep && ! in_array( (string) $record['package_id'], $busy, true ) ) { Package_Store::delete( (string) $record['package_id'] ); } } } /** * Seconds since a record last reported in. * * @param array $record Job record. * @return int */ private static function silent_for( array $record ): int { $stamp = isset( $record['updated_at'] ) && '' !== $record['updated_at'] ? $record['updated_at'] : $record['created_at']; return time() - (int) strtotime( (string) $stamp ); } /** * Every record, keyed by job id, newest first. * * One query for all of them, in the order they were created. * * @return array> */ private static function records(): array { global $wpdb; self::migrate(); $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_id DESC", $wpdb->esc_like( self::PREFIX ) . '%' ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- a query WordPress has no API for; nothing to cache. $cancelled = (array) $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( self::CANCEL_PREFIX ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- a query WordPress has no API for; nothing to cache. $records = array(); foreach ( $rows as $row ) { $record = maybe_unserialize( (string) $row['option_value'] ); if ( ! is_array( $record ) || ! isset( $record['id'] ) ) { continue; } $records[ (string) $record['id'] ] = self::with_cancel( $record, in_array( self::CANCEL_PREFIX . $record['id'], $cancelled, true ) ); } return $records; } /** * Write one record to its own row. * * @param array $record Record. * @return void */ private static function write_one( array $record ): void { if ( isset( $record['failures'] ) && is_array( $record['failures'] ) && count( $record['failures'] ) > self::MAX_FAILURES ) { $record['failures'] = self::cap_failures( $record['failures'] ); } update_option( self::PREFIX . $record['id'], $record, false ); } /** * Remove a job's rows. * * @param string $job_id Job id. * @return void */ private static function forget( string $job_id ): void { delete_option( self::PREFIX . $job_id ); delete_option( self::CANCEL_PREFIX . $job_id ); } /** * A record as the cancel flag says it is. * * @param array $record Stored record. * @param bool|null $cancelled Whether the flag is set; null to look it up. * @return array */ private static function with_cancel( array $record, ?bool $cancelled = null ): array { if ( in_array( $record['status'] ?? '', self::TERMINAL, true ) ) { return $record; } if ( null === $cancelled ) { $cancelled = false !== get_option( self::CANCEL_PREFIX . $record['id'] ); } if ( $cancelled ) { $record['status'] = 'cancelled'; } return $record; } /** * Move records from the single option into one row each, once. * * @return void */ private static function migrate(): void { static $done = false; if ( $done ) { return; } $done = true; $old = get_option( self::OPTION ); if ( ! is_array( $old ) ) { return; } // Oldest first, so creation order survives as row order. foreach ( array_reverse( $old, true ) as $id => $record ) { if ( is_array( $record ) ) { $record['id'] = (string) $id; add_option( self::PREFIX . $id, $record, '', false ); } } delete_option( self::OPTION ); } /** * Keep the first problems and count the rest in one closing line. * * @param array> $failures Failures, possibly already capped. * @return array> */ private static function cap_failures( array $failures ): array { $more = 0; $kept = array(); foreach ( $failures as $failure ) { if ( isset( $failure['more'] ) ) { $more += (int) $failure['more']; continue; } $kept[] = $failure; } if ( count( $kept ) > self::MAX_FAILURES ) { $more += count( $kept ) - self::MAX_FAILURES; $kept = array_slice( $kept, 0, self::MAX_FAILURES ); } if ( $more > 0 ) { $kept[] = array( 'item' => __( 'Other problems', 'transtrix' ), 'reason' => sprintf( /* translators: %d: number of problems not listed. */ _n( '%d more problem is not listed.', '%d more problems are not listed.', $more, 'transtrix' ), $more ), 'more' => $more, ); } return $kept; } /** * Trim to the limit without ever evicting a job that is still running. * * The naive slice drops whatever falls past the limit. That was harmless * while every run finished inside one request; a stepped job that runs for * forty minutes can drift past position fifty and have its own record — * cursor, progress, package id — deleted underneath it. * * @param array> $records Records, newest first. * @return void */ private static function cap( array $records ): void { if ( count( $records ) <= self::LIMIT ) { return; } $live = 0; $done = array(); foreach ( $records as $id => $record ) { if ( in_array( $record['status'], self::TERMINAL, true ) ) { $done[] = (string) $id; } else { ++$live; } } foreach ( array_slice( $done, max( 0, self::LIMIT - $live ) ) as $id ) { self::forget( $id ); } } }