/job-tables.json). * * @var array[]|null */ private $tables = null; /** * @var bool */ private $tables_dirty = false; /** * @var Dumper|null */ private $dumper; /** * @var Zipper|null */ private $zipper; /** * @var bool */ private $snapshot = false; /** * Load the active job, if any. * * @return Job|null */ public static function load() { $data = Options::get( self::OPTION ); if ( ! is_array( $data ) || empty( $data['id'] ) ) { return null; } $job = new self(); $job->s = $data; if ( empty( $data['tables_external'] ) ) { $job->tables = isset( $data['tables'] ) ? (array) $data['tables'] : array(); } unset( $job->s['tables'] ); return $job; } /** * Create and persist a new job (caller holds the Lock). * * @param array $scope Normalized scope. * @param array[] $tables Resolved work list. * @param string $name Backup name ('' → auto). * @param int $created_by User id. * @param string $mode background|browser. * @return Job */ public static function create( array $scope, array $tables, $name, $created_by, $mode ) { $last_id = 0; foreach ( Storage::manifests() as $m ) { $last_id = max( $last_id, (int) $m['id'] ); } $id = max( time(), $last_id + 1 ); $token = wp_generate_password( 12, false ); $user = get_userdata( (int) $created_by ); $home = Adapter_Factory::home(); $job = new self(); $job->s = array( 'id' => $id, 'token' => $token, 'name' => '' !== trim( (string) $name ) ? (string) $name : Table_Catalog::default_name( $scope, $tables ), 'scope' => $scope, 'stage' => 'preflight', 'prev_stage' => null, 'created_by' => (int) $created_by, 'created_by_name' => $user ? $user->display_name : '', 'created_at' => time(), 'mode' => $mode, 'backend' => array( 'adapter' => $home->id(), 'server' => $home->server_info(), 'datasets' => array_keys( Adapter_Factory::all() ), ), 'env' => Environment::summary(), 'tables_external' => count( $tables ) > self::INLINE_TABLES, 'table_count' => count( $tables ), 'table_index' => 0, 'table_phase' => 'header', 'cursor' => array(), 'rows_done' => 0, 'rows_written' => 0, 'table_sql_bytes' => 0, 'table_huge' => false, 'part_index' => 1, 'part_bytes' => 0, 'multi' => false, 'relock' => false, 'pending_parts' => array(), 'parts_zipped' => 0, 'dump_done' => false, 'footer_found' => false, 'sql_bytes' => 0, 'sql_bytes_closed' => 0, 'bytes_est_total' => 0, 'rows_est_total' => 0, 'batch_rows' => 1000, 'batch_cap' => 0, 'part_size' => self::default_part_size(), 'chunk_size' => 4 * MB_IN_BYTES, 'zip' => array( 'bytes' => 0, 'count' => 0, 'current' => null, 'finished' => false, 'extras' => array(), 'zip64' => false, ), 'verify' => null, 'consistent' => false, 'last_progress' => time(), 'started_at' => time(), 'ticks' => 0, 'tick_open' => 0, 'crashes' => 0, 'last_fatal' => '', 'warnings' => array(), 'percent_shown' => 0, 'error' => null, ); $job->tables = $tables; $job->tables_dirty = true; Storage::ensure(); Storage::cleanup_orphans(); if ( ! Storage::make_work_dir( $job->work_dir() ) ) { throw new Backup_Exception( 'dir_not_writable', 'mkdir work dir failed', array( 'path' => Storage::dir() ) ); } $job->save(); Options::delete( self::CANCEL_OPTION ); return $job; } /** * @return int Part size in bytes (filter dbmg_part_size, 1 MB – 2 GB). */ public static function default_part_size() { $size = (int) apply_filters( 'dbmg_part_size', 64 * MB_IN_BYTES ); return max( MB_IN_BYTES / 4, min( 2048 * MB_IN_BYTES, $size ) ); } /** * Persist state (the Lock holder only). */ public function save() { $data = $this->s; if ( null !== $this->tables ) { if ( ! empty( $this->s['tables_external'] ) ) { if ( $this->tables_dirty ) { Storage::write_json( $this->work_dir() . '/job-tables.json', $this->tables ); } $data['tables'] = null; } else { $data['tables'] = $this->tables; } } $this->tables_dirty = false; Options::set( self::OPTION, $data ); } /** * @return string */ public function work_dir() { return Storage::work_dir( $this->s['id'], $this->s['token'] ); } /** * @return array[] */ public function tables() { if ( null === $this->tables ) { $data = Storage::read_json( $this->work_dir() . '/job-tables.json' ); $this->tables = is_array( $data ) ? $data : array(); } return $this->tables; } /** * @param int $i Index. * @return array|null */ public function table( $i ) { $tables = $this->tables(); return isset( $tables[ $i ] ) ? $tables[ $i ] : null; } /** * @param int $i Index. * @param array $fields Fields to merge. */ public function update_table( $i, array $fields ) { $this->tables(); if ( isset( $this->tables[ $i ] ) ) { $this->tables[ $i ] = array_merge( $this->tables[ $i ], $fields ); $this->tables_dirty = true; } } /** * Record a non-fatal warning once. * * @param string $code Code. * @param string $table Table. */ public function warn( $code, $table ) { foreach ( $this->s['warnings'] as $w ) { if ( $w['code'] === $code && $w['table'] === $table ) { return; } } $this->s['warnings'][] = array( 'code' => $code, 'table' => $table, ); } /** * @return bool Work remains and the job isn't paused. */ public function has_work() { return in_array( $this->s['stage'], self::ACTIVE_STAGES, true ); } /** * @return bool Running or paused (blocks new backups). */ public function is_active() { return $this->has_work() || 'stalled' === $this->s['stage']; } /** * @return Dumper */ private function dumper() { if ( ! $this->dumper ) { $this->dumper = new Dumper( $this ); } return $this->dumper; } /** * @return Zipper */ public function zipper() { if ( ! $this->zipper ) { $this->zipper = new Zipper( $this ); } return $this->zipper; } /** * Crash detection + bookkeeping at the start of a tick (D9). * * @param string $source Trigger. */ public function begin_tick( $source ) { if ( ! empty( $this->s['tick_open'] ) ) { // The previous tick never reached end_tick(): it was killed (fatal, timeout, OOM). ++$this->s['crashes']; $this->s['batch_cap'] = max( 1, (int) floor( $this->s['batch_rows'] / 2 ) ); $this->s['batch_rows'] = $this->s['batch_cap']; $this->s['chunk_size'] = max( 65536, (int) floor( $this->s['chunk_size'] / 2 ) ); if ( $this->s['crashes'] >= self::MAX_CRASHES ) { $this->mark_stalled( 'crashes' ); return; } } $this->s['tick_open'] = time(); $this->s['last_source'] = $source; ++$this->s['ticks']; $this->save(); $job = $this; register_shutdown_function( static function () use ( $job ) { $err = error_get_last(); if ( $job->s['tick_open'] && $err && in_array( $err['type'], array( E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR ), true ) ) { $job->s['last_fatal'] = substr( $err['message'], 0, 500 ); Options::set( self::OPTION, array_merge( (array) Options::get( self::OPTION, array() ), array( 'last_fatal' => $job->s['last_fatal'] ) ) ); } } ); } /** * One unit of work. * * @param bool $first First step of this tick. * @return string continue | yield | done */ public function step( $first ) { switch ( $this->s['stage'] ) { case 'queued': case 'preflight': $this->preflight(); return 'continue'; case 'dumping': case 'zipping': if ( ! empty( $this->s['pending_parts'] ) ) { if ( ! $first && $this->zipper()->starting_entry() ) { return 'yield'; // Compression gets a fresh budget (§10.1). } $this->s['stage'] = 'zipping'; $this->zipper()->zip_step(); return 'continue'; } if ( empty( $this->s['dump_done'] ) ) { $this->s['stage'] = 'dumping'; $this->dumper()->step(); return 'continue'; } $this->end_snapshot(); $this->zipper()->finish_archive( $this->manifest( 'ready', null, true ) ); $this->s['stage'] = 'verifying'; return 'continue'; case 'verifying': if ( $this->zipper()->verify_step() ) { $this->complete(); return 'done'; } return 'continue'; } return 'done'; } /** * Estimates, backend info, optional consistent snapshot, then start dumping. */ private function preflight() { $bytes = 0; $rows = 0; foreach ( $this->tables() as $t ) { $bytes += (int) $t['bytes_est']; $rows += (int) $t['rows_est']; } $est = Diskspace::estimate( $this->tables(), $this->s['part_size'] ); $this->s['bytes_est_total'] = max( $est['est_sql'], 1 ); $this->s['rows_est_total'] = $rows; if ( ! Storage::make_work_dir( $this->work_dir() ) ) { throw new Backup_Exception( 'dir_not_writable', 'work dir missing', array( 'path' => Storage::dir() ) ); } $this->s['stage'] = 'dumping'; // §9.6: small single-connection MySQL sites get a consistent snapshot if the dump fits in this tick. $adapters = Adapter_Factory::all(); if ( 1 === count( $adapters ) && 'mysql' === Adapter_Factory::home()->id() && $this->s['bytes_est_total'] < 20 * MB_IN_BYTES && apply_filters( 'dbmg_consistent_snapshot', true ) ) { $this->snapshot = Adapter_Factory::home()->begin_snapshot(); } } /** * Persist after each step (commit rule: files were flushed by the step). */ public function commit() { $this->s['last_progress'] = time(); $this->s['crashes'] = 0; $this->s['percent_shown'] = max( (int) $this->s['percent_shown'], $this->percent() ); if ( ! $this->snapshot && $this->is_active() ) { $this->save(); } } /** * Close the consistent-snapshot transaction (if any). Called when the dump ends or the tick ends. */ public function end_snapshot() { if ( $this->snapshot ) { Adapter_Factory::home()->end_snapshot(); $this->snapshot = false; if ( ! empty( $this->s['dump_done'] ) ) { $this->s['consistent'] = true; } } } /** * Close files and persist at the end of a tick. */ public function end_tick() { if ( $this->dumper ) { $this->dumper->close(); } if ( $this->zipper ) { $this->zipper->close(); } $this->end_snapshot(); if ( $this->is_active() ) { $this->s['tick_open'] = 0; $this->save(); } } /** * @return bool A cancel was requested for this job. */ public function cancel_requested() { $c = Options::get( self::CANCEL_OPTION ); return is_array( $c ) && (int) $c['id'] === (int) $this->s['id']; } /** * Move the finished zip into place, write the manifest, clean up (§10.3). */ private function complete() { $this->end_tick_files(); $src = $this->zipper()->zip_path(); $dest = Storage::dir() . '/' . (int) $this->s['id'] . '-' . $this->s['token'] . '.zip'; if ( ! @rename( $src, $dest ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.rename_rename throw new Backup_Exception( 'dir_not_writable', 'Could not move the finished zip into the backup folder', array( 'path' => Storage::dir() ) ); } $this->s['stage'] = 'ready'; Storage::save_manifest( $this->manifest( 'ready' ) ); $this->finish(); Notices::queue( (int) $this->s['created_by'], (int) $this->s['id'], 'ready' ); } /** * Fail the job with a reason (§17). * * @param string $code Error code. * @param string $detail Technical detail. * @param array $context Placeholders. */ public function fail( $code, $detail = '', array $context = array() ) { $this->end_tick_files(); $this->end_snapshot(); $error = array( 'code' => $code, 'message' => Errors::message( $code, $context ), 'fix' => Errors::fix( $code, $context ), 'detail' => $detail, 'stage' => 'stalled' === $this->s['stage'] ? $this->s['prev_stage'] : $this->s['stage'], ); $this->s['error'] = $error; $this->s['stage'] = 'failed'; if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log( sprintf( '[Database Export Genie] backup %d failed (%s at %s): %s', $this->s['id'], $code, $error['stage'], $detail ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } try { Storage::save_manifest( $this->manifest( 'failed', $error ) ); } catch ( \Exception $e ) { unset( $e ); // Disk may be full; the job option removal below still frees the UI. } $this->finish(); Notices::queue( (int) $this->s['created_by'], (int) $this->s['id'], 'failed' ); } /** * Cancel (§8.6). * * @param int $user_id Who asked. */ public function cancel( $user_id ) { $this->end_tick_files(); $this->end_snapshot(); $user = get_userdata( (int) $user_id ); $name = $user ? $user->display_name : __( 'a user', 'database-export-genie' ); $this->s['stage'] = 'cancelled'; $this->s['error'] = array( 'code' => 'cancelled', 'message' => Errors::message( 'cancelled', array( 'user' => $name ) ), 'fix' => '', 'detail' => '', 'stage' => '', ); Storage::save_manifest( $this->manifest( 'cancelled', $this->s['error'] ) ); $this->finish(); } /** * Close open files without persisting. */ private function end_tick_files() { if ( $this->dumper ) { $this->dumper->close(); } if ( $this->zipper ) { $this->zipper->close(); } } /** * Common cleanup once the job reached a final state. */ private function finish() { Storage::rrmdir( $this->work_dir() ); Options::delete( self::OPTION ); Options::delete( self::CANCEL_OPTION ); Options::delete( Runner::KEY_OPTION ); Runner::unschedule_watchdog(); } /** * Pause: no progress for 5 min or repeated crashes (§8.5). Caller holds the Lock. * * @param string $reason timeout|crashes. */ public function mark_stalled( $reason ) { if ( 'stalled' === $this->s['stage'] ) { return; } $this->s['prev_stage'] = $this->s['stage']; $this->s['stage'] = 'stalled'; $this->s['stalled_at'] = time(); $this->s['stall_reason'] = $reason; $this->s['tick_open'] = 0; $this->save(); } /** * Resume a paused job (§8.5). Caller holds the Lock. */ public function resume() { if ( 'stalled' !== $this->s['stage'] ) { return; } $this->s['stage'] = $this->s['prev_stage'] ? $this->s['prev_stage'] : 'dumping'; $this->s['prev_stage'] = null; $this->s['crashes'] = 0; $this->s['tick_open'] = 0; $this->s['last_progress'] = time(); $this->save(); } /** * Housekeeping outside ticks: stall detection, 24 h give-up. Caller holds the Lock. * * @return bool True when state changed. */ public function watchdog() { $now = time(); if ( 'stalled' === $this->s['stage'] ) { if ( $now - (int) $this->s['stalled_at'] > self::GIVE_UP_AFTER ) { $this->fail( 'stalled_timeout', 'Paused for more than 24 hours' ); return true; } return false; } if ( $this->has_work() && $now - (int) $this->s['last_progress'] > self::STALL_AFTER ) { $this->mark_stalled( 'timeout' ); return true; } return false; } /** * Overall percentage (§13), capped at 99 until ready. * * @return int */ public function percent() { $s = $this->s; if ( 'ready' === $s['stage'] ) { return 100; } $sql = (int) $s['sql_bytes']; $dump = ! empty( $s['dump_done'] ) ? 1.0 : min( 1.0, $sql / max( (int) $s['bytes_est_total'], $sql + 1 ) ); if ( ! empty( $s['dump_done'] ) ) { $parts_total = (int) $s['parts_zipped'] + count( (array) $s['pending_parts'] ); } else { $parts_total = max( (int) $s['part_index'], (int) ceil( (int) $s['bytes_est_total'] / max( 1, (int) $s['part_size'] ) ) ); } $zipped = (float) $s['parts_zipped']; if ( ! empty( $s['zip']['current'] ) && $s['zip']['current']['usize'] > 0 ) { $zipped += $s['zip']['current']['in'] / $s['zip']['current']['usize']; } $zip = min( 1.0, $zipped / max( 1, $parts_total ) ); $verify = 0.0; if ( 'verifying' === $s['stage'] ) { $verify = 0.5; } $overall = 0.85 * $dump + 0.12 * $zip + 0.03 * $verify; return (int) min( 99, floor( $overall * 100 ) ); } /** * Snapshot for the UI (§13 dbmg_status). * * @return array */ public function snapshot() { $s = $this->s; $tables = $this->tables(); $count = count( $tables ); $index = (int) $s['table_index']; $window = array( 0, $count ); if ( $count > self::INLINE_TABLES ) { $window = array( max( 0, $index - 50 ), min( $count, $index + 200 ) ); } $list = array(); $done = 0; foreach ( $tables as $i => $t ) { if ( 'done' === $t['state'] ) { ++$done; } if ( $i < $window[0] || $i >= $window[1] ) { continue; } $rows = (int) $t['rows_done']; if ( $i === $index && 'exporting' === $t['state'] ) { $rows = (int) $s['rows_done']; } $est = (int) $t['rows_est']; $pct = 'done' === $t['state'] ? 100 : ( $est > 0 ? (int) min( 99, floor( 100 * $rows / $est ) ) : 0 ); $list[] = array( 'i' => $i, 'name' => ! empty( $t['rename_to'] ) ? $t['rename_to'] : $t['name'], 'state' => $t['state'], 'rows' => $rows, 'est' => $est, 'pct' => $pct, 'view' => 'VIEW' === $t['type'], ); } $elapsed = time() - (int) $s['started_at']; $percent = max( (int) $s['percent_shown'], $this->percent() ); $eta = null; if ( $elapsed >= 20 && $percent >= 1 && $percent < 100 && $this->has_work() ) { $eta = (int) round( $elapsed * ( 100 - $percent ) / $percent ); } $current = $this->table( $index ); $parts = ! empty( $s['dump_done'] ) ? (int) $s['parts_zipped'] + count( (array) $s['pending_parts'] ) : max( (int) $s['part_index'], (int) $s['parts_zipped'] + 1 ); return array( 'id' => (int) $s['id'], 'name' => $s['name'], 'stage' => $s['stage'], 'prev_stage' => $s['prev_stage'], 'stall_reason' => isset( $s['stall_reason'] ) ? $s['stall_reason'] : '', 'last_fatal' => $s['last_fatal'], 'percent' => $percent, 'eta' => $eta, 'mode' => $s['mode'], 'created_at' => (int) $s['created_at'], 'created_by' => (int) $s['created_by'], 'created_by_name' => $s['created_by_name'], 'table_count' => $count, 'tables_done' => $done, 'table_index' => $index, 'current_table' => $current ? ( ! empty( $current['rename_to'] ) ? $current['rename_to'] : $current['name'] ) : '', 'tables' => $list, 'parts_zipped' => (int) $s['parts_zipped'], 'parts_total' => $parts, 'zip_entry_pct' => ! empty( $s['zip']['current'] ) && $s['zip']['current']['usize'] > 0 ? (int) floor( 100 * $s['zip']['current']['in'] / $s['zip']['current']['usize'] ) : 0, 'verify_phase' => ! empty( $s['verify']['phase'] ) ? $s['verify']['phase'] : '', 'sql_bytes' => (int) $s['sql_bytes'], 'last_progress' => (int) $s['last_progress'], 'cancelling' => $this->cancel_requested(), ); } /** * Manifest (§6.2). * * @param string $status ready|failed|cancelled. * @param array|null $error Error. * @param bool $for_zip backup-info.json variant (no internal file names, no sizes yet). * @return array */ public function manifest( $status, $error = null, $for_zip = false ) { $s = $this->s; $tables = array(); $all = $this->tables(); $rows = 0; foreach ( $all as $t ) { $rows += (int) $t['rows_done']; } if ( count( $all ) <= self::INLINE_TABLES || $for_zip ) { foreach ( $all as $t ) { $tables[] = array( 'name' => $t['name'], 'exported_as' => ! empty( $t['rename_to'] ) ? $t['rename_to'] : $t['name'], 'type' => $t['type'], 'dataset' => $t['dataset'], 'blog_id' => $t['blog_id'], 'rows_estimated' => (int) $t['rows_est'], 'rows_exported' => (int) $t['rows_done'], 'sql_bytes' => (int) $t['sql_bytes'], ); } } $zip_file = (int) $s['id'] . '-' . $s['token'] . '.zip'; $path = Storage::dir() . '/' . $zip_file; $zip_size = 0; if ( 'ready' === $status && ! $for_zip ) { clearstatcache( true, $path ); // The zip was written this request; PHP < 8.4 would report a stale size (D32). $zip_size = is_file( $path ) ? (int) filesize( $path ) : 0; } $data = array( 'id' => (int) $s['id'], 'token' => $s['token'], 'status' => $status, 'name' => $s['name'], 'scope' => $s['scope'], 'backend' => $s['backend'], 'created_at_utc' => gmdate( 'Y-m-d\TH:i:s\Z', (int) $s['created_at'] ), 'completed_at_utc' => gmdate( 'Y-m-d\TH:i:s\Z' ), 'created_by' => (int) $s['created_by'], 'created_by_name' => $s['created_by_name'], 'zip_file' => 'ready' === $status ? $zip_file : null, 'zip_bytes' => $zip_size, 'sql_bytes' => (int) $s['sql_bytes'], 'parts' => (int) $s['parts_zipped'], 'table_count' => count( $all ), 'rows_exported' => $rows, 'tables' => $tables, 'verified' => array( 'zip_checkcons' => 'ready' === $status && ! $for_zip, 'part_crcs_match' => 'ready' === $status && ! $for_zip, 'footer_found' => (bool) $s['footer_found'], ), 'consistent_snapshot' => (bool) $s['consistent'], 'warnings' => $s['warnings'], 'env' => array_merge( $s['env'], array( 'db' => $s['backend']['server'] ) ), 'plugin_version' => DBMG_VERSION, 'error' => $error, ); if ( $for_zip ) { unset( $data['token'], $data['zip_file'], $data['zip_bytes'], $data['error'], $data['completed_at_utc'] ); $data['parts'] = (int) $s['parts_zipped']; } return $data; } }