Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
77.78% |
154 / 198 |
|
37.50% |
3 / 8 |
CRAP | |
0.00% |
0 / 1 |
| CopyEngine | |
77.78% |
154 / 198 |
|
37.50% |
3 / 8 |
83.83 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| advance | |
58.33% |
7 / 12 |
|
0.00% |
0 / 1 |
8.60 | |||
| runBurst | |
85.71% |
36 / 42 |
|
0.00% |
0 / 1 |
13.49 | |||
| runCopyPhase | |
75.68% |
84 / 111 |
|
0.00% |
0 / 1 |
34.00 | |||
| withSuffix | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| result | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| isDone | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| cleanup | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Chunked, resumable copy of a WordPress content tree, advanced in short bursts. |
| 4 | * |
| 5 | * @package NewfoldLabs\WP\Module\Staging |
| 6 | */ |
| 7 | |
| 8 | namespace NewfoldLabs\WP\Module\Staging\Copy; |
| 9 | |
| 10 | /** |
| 11 | * Replaces the single long `rsync` that clone and deploy used to run with an engine that advances one |
| 12 | * bounded burst per call, checkpoints, and resumes. Each call does at most `budget_seconds` of work, |
| 13 | * so nothing ever runs long enough to trip the per-process CPU ceiling or load the box, and no |
| 14 | * long-lived detached worker is needed: a REST "advance" endpoint (driven by the UI poll loop) and a |
| 15 | * WP-cron fallback call advance() repeatedly until it reports done. |
| 16 | * |
| 17 | * One engine serves both flows; they differ only in config: |
| 18 | * - clone (production -> new staging): dest_suffix '', link_dest_root null, no finalize. |
| 19 | * - deploy (staging -> production): dest_suffix '-new', link_dest_root = production wp-content, |
| 20 | * then the caller runs the bash `finalize_new_content_dirs` |
| 21 | * atomic swap over the finished `-new` tree. |
| 22 | * |
| 23 | * The engine itself is pure PHP; WordPress-specific wiring (REST, cron, options) lives in the caller. |
| 24 | */ |
| 25 | class CopyEngine { |
| 26 | |
| 27 | /** |
| 28 | * Default per-burst time budget, in seconds. Well under any FPM request timeout and the 120 CPU-s |
| 29 | * per-process cap; file copy is I/O-bound so a burst spends little CPU. |
| 30 | */ |
| 31 | const DEFAULT_BUDGET = 10.0; |
| 32 | |
| 33 | /** |
| 34 | * Consecutive copy errors on a single file before the job is declared failed. |
| 35 | */ |
| 36 | const MAX_RETRIES = 3; |
| 37 | |
| 38 | /** |
| 39 | * Resolved job configuration (see the constructor). |
| 40 | * |
| 41 | * @var array |
| 42 | */ |
| 43 | private $config; |
| 44 | |
| 45 | /** |
| 46 | * The file manifest for this job. |
| 47 | * |
| 48 | * @var CopyManifest |
| 49 | */ |
| 50 | private $manifest; |
| 51 | |
| 52 | /** |
| 53 | * The resumable cursor for this job. |
| 54 | * |
| 55 | * @var CopyCheckpoint |
| 56 | */ |
| 57 | private $checkpoint; |
| 58 | |
| 59 | /** |
| 60 | * Absolute path to the advisory lock file. |
| 61 | * |
| 62 | * @var string |
| 63 | */ |
| 64 | private $lock_path; |
| 65 | |
| 66 | /** |
| 67 | * The wp-content subtrees a staging copy moves, when the caller does not name its own. |
| 68 | * |
| 69 | * Must stay in step with CONTENT_DIRS in lib/.staging and with the three directories |
| 70 | * finalize_new_content_dirs promotes: a directory copied here but not promoted there would be |
| 71 | * built and then silently left behind. |
| 72 | */ |
| 73 | const DEFAULT_ROOTS = array( 'uploads', 'themes', 'plugins' ); |
| 74 | |
| 75 | /** |
| 76 | * Construct an engine for one copy job. |
| 77 | * |
| 78 | * @param array $config { |
| 79 | * @type string $source_root Absolute wp-content to copy FROM. |
| 80 | * @type string $dest_root Absolute wp-content to copy TO. |
| 81 | * @type string $dest_suffix '' for clone, '-new' for deploy. |
| 82 | * @type string[] $roots Content roots to copy, e.g. ['uploads','themes','plugins']. |
| 83 | * @type string|null $link_dest_root Absolute wp-content to hardlink unchanged files from, or null. |
| 84 | * @type string $job_dir Absolute dir for manifest/cursor/lock (under nfd-private). |
| 85 | * @type float $budget_seconds Optional per-burst budget. Defaults to DEFAULT_BUDGET. |
| 86 | * } |
| 87 | */ |
| 88 | public function __construct( array $config ) { |
| 89 | $config = array_merge( |
| 90 | array( |
| 91 | 'dest_suffix' => '', |
| 92 | 'roots' => self::DEFAULT_ROOTS, |
| 93 | 'link_dest_root' => null, |
| 94 | 'budget_seconds' => self::DEFAULT_BUDGET, |
| 95 | ), |
| 96 | $config |
| 97 | ); |
| 98 | $this->config = $config; |
| 99 | $this->manifest = new CopyManifest( $config['job_dir'] . '/manifest.list' ); |
| 100 | $this->checkpoint = new CopyCheckpoint( $config['job_dir'] . '/cursor.json' ); |
| 101 | $this->lock_path = $config['job_dir'] . '/copy.lock'; |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * Do one bounded burst of work and return progress. |
| 106 | * |
| 107 | * Safe to call concurrently: a non-blocking flock means only one caller advances at a time; the |
| 108 | * others get the current progress and 'running'. Safe to call after a crash: it resumes from the |
| 109 | * last saved cursor. |
| 110 | * |
| 111 | * @return array { status: running|success|error, progress: array, message?: string } |
| 112 | */ |
| 113 | public function advance() { |
| 114 | if ( ! is_dir( $this->config['job_dir'] ) && ! mkdir( $this->config['job_dir'], 0700, true ) && ! is_dir( $this->config['job_dir'] ) ) { |
| 115 | return $this->result( 'error', __( 'Could not create the copy job directory.', 'wp-module-staging' ) ); |
| 116 | } |
| 117 | |
| 118 | $lock = fopen( $this->lock_path, 'cb' ); |
| 119 | if ( false === $lock ) { |
| 120 | // Cannot even open the lock file (permission/space). Fail rather than loop "running" forever. |
| 121 | return $this->result( 'error', __( 'Could not open the copy lock.', 'wp-module-staging' ) ); |
| 122 | } |
| 123 | if ( ! flock( $lock, LOCK_EX | LOCK_NB ) ) { |
| 124 | fclose( $lock ); |
| 125 | // Another burst is in flight; report where we are without touching anything. |
| 126 | $this->checkpoint->load(); |
| 127 | return $this->result( 'running' ); |
| 128 | } |
| 129 | |
| 130 | try { |
| 131 | return $this->runBurst(); |
| 132 | } finally { |
| 133 | flock( $lock, LOCK_UN ); |
| 134 | fclose( $lock ); |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | /** |
| 139 | * The actual work, under the lock. |
| 140 | * |
| 141 | * @return array |
| 142 | */ |
| 143 | private function runBurst() { |
| 144 | $cp = $this->checkpoint; |
| 145 | |
| 146 | // A fresh job, OR a cursor that could not be read (missing, corrupt, or truncated by a crash): |
| 147 | // start clean. We reseed the worklist, SAVE THE CURSOR FIRST, and only then truncate any stale |
| 148 | // manifest. Ordering matters: if the manifest existed but the cursor did not, proceeding would |
| 149 | // walk an empty worklist, mark the walk "complete", and copy a partial tree over production. |
| 150 | if ( ! $cp->load() ) { |
| 151 | $pending = array(); |
| 152 | foreach ( (array) $this->config['roots'] as $root ) { |
| 153 | if ( is_dir( $this->config['source_root'] . '/' . $root ) ) { |
| 154 | $pending[] = $root; |
| 155 | } |
| 156 | } |
| 157 | $cp->set( |
| 158 | array( |
| 159 | 'job_id' => uniqid( 'copy_', true ), |
| 160 | 'phase' => CopyCheckpoint::PHASE_ENUMERATE, |
| 161 | 'pending_dirs' => $pending, |
| 162 | 'walk_complete' => false, |
| 163 | ) |
| 164 | ); |
| 165 | if ( ! $cp->save() ) { |
| 166 | return $this->result( 'error', __( 'Could not write the copy cursor.', 'wp-module-staging' ) ); |
| 167 | } |
| 168 | if ( false === file_put_contents( $this->manifest->path(), '' ) ) { |
| 169 | return $this->result( 'error', __( 'Could not create the copy manifest.', 'wp-module-staging' ) ); |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | $deadline = microtime( true ) + (float) $this->config['budget_seconds']; |
| 174 | |
| 175 | if ( CopyCheckpoint::PHASE_ENUMERATE === $cp->get( 'phase' ) ) { |
| 176 | $walk = $this->manifest->walk( $this->config['source_root'], $cp, $deadline ); |
| 177 | if ( CopyManifest::WALK_ERROR === $walk ) { |
| 178 | $cp->save(); |
| 179 | return $this->result( 'error', __( 'Could not enumerate the files to copy.', 'wp-module-staging' ) ); |
| 180 | } |
| 181 | if ( CopyManifest::WALK_DONE === $walk ) { |
| 182 | $cp->set( |
| 183 | array( |
| 184 | 'phase' => CopyCheckpoint::PHASE_COPY, |
| 185 | 'manifest_offset' => 0, |
| 186 | ) |
| 187 | ); |
| 188 | } |
| 189 | // A failed save (full disk / over quota) must fail the job, not report progress that did not |
| 190 | // persist: on the next burst the cursor would reload stale and could mis-sequence the phases. |
| 191 | if ( ! $cp->save() ) { |
| 192 | return $this->result( 'error', __( 'Could not write the copy cursor.', 'wp-module-staging' ) ); |
| 193 | } |
| 194 | return $this->result( 'running' ); |
| 195 | } |
| 196 | |
| 197 | if ( CopyCheckpoint::PHASE_COPY === $cp->get( 'phase' ) ) { |
| 198 | // Defend the C1 invariant: never copy from a manifest whose walk did not finish. |
| 199 | if ( ! $cp->get( 'walk_complete' ) ) { |
| 200 | return $this->result( 'error', __( 'Copy manifest is incomplete; cannot continue.', 'wp-module-staging' ) ); |
| 201 | } |
| 202 | $status = $this->runCopyPhase( $cp, $deadline ); |
| 203 | // Persist the copy cursor before reporting; a failed save would otherwise lose the advance and |
| 204 | // let the next burst re-copy from a stale offset (or, at DONE, fail to record completion). |
| 205 | if ( ! $cp->save() ) { |
| 206 | return $this->result( 'error', __( 'Could not write the copy cursor.', 'wp-module-staging' ) ); |
| 207 | } |
| 208 | return $status; |
| 209 | } |
| 210 | |
| 211 | // Already done. |
| 212 | return $this->result( 'success' ); |
| 213 | } |
| 214 | |
| 215 | /** |
| 216 | * Copy manifest entries until the deadline, EOF, or a fatal error. |
| 217 | * |
| 218 | * @param CopyCheckpoint $cp Cursor. |
| 219 | * @param float $deadline microtime(true) to stop at. |
| 220 | * @return array |
| 221 | */ |
| 222 | private function runCopyPhase( CopyCheckpoint $cp, $deadline ) { |
| 223 | $handle = $this->manifest->openAt( (int) $cp->get( 'manifest_offset' ) ); |
| 224 | if ( false === $handle ) { |
| 225 | return $this->result( 'error', __( 'Could not read the copy manifest.', 'wp-module-staging' ) ); |
| 226 | } |
| 227 | |
| 228 | $offset = (int) $cp->get( 'manifest_offset' ); |
| 229 | $current_file = (string) $cp->get( 'current_file' ); |
| 230 | $file_offset = (int) $cp->get( 'file_offset' ); |
| 231 | $file_resnapshots = (int) $cp->get( 'file_resnapshots' ); |
| 232 | $files_done = (int) $cp->get( 'files_done' ); |
| 233 | $bytes_done = (int) $cp->get( 'bytes_done' ); |
| 234 | $retries = (int) $cp->get( 'retries' ); |
| 235 | $res = null; |
| 236 | |
| 237 | // Deadline is checked at the END of each iteration, so a burst always processes at least one |
| 238 | // entry (and FileCopier always moves at least one chunk), guaranteeing forward progress. |
| 239 | while ( true ) { |
| 240 | $line_start = ftell( $handle ); |
| 241 | $line = fgets( $handle ); |
| 242 | if ( false === $line ) { |
| 243 | // fgets() returns false at EOF AND on a read error (e.g. NFS EIO). Only a genuine EOF |
| 244 | // means the manifest is fully consumed; treating a mid-file read error as "done" would |
| 245 | // promote a partial tree and delete the un-copied files from production. Require feof(), |
| 246 | // and that we actually reached the end of the manifest, before declaring the copy done. |
| 247 | clearstatcache(); |
| 248 | $size = @filesize( $this->manifest->path() ); |
| 249 | if ( ! feof( $handle ) || ( false !== $size && $line_start < $size ) ) { |
| 250 | fclose( $handle ); |
| 251 | $cp->set( array( 'manifest_offset' => $line_start ) ); |
| 252 | return $this->result( 'error', __( 'Could not read the whole copy manifest; cannot continue safely.', 'wp-module-staging' ) ); |
| 253 | } |
| 254 | $cp->set( array( 'phase' => CopyCheckpoint::PHASE_DONE ) ); |
| 255 | $offset = $line_start; |
| 256 | break; |
| 257 | } |
| 258 | $line_end = ftell( $handle ); |
| 259 | $entry = CopyManifest::parseLine( $line ); |
| 260 | if ( null === $entry ) { |
| 261 | if ( '' === trim( $line ) ) { |
| 262 | // A blank / whitespace-only line (e.g. a trailing newline) is benign: skip it. |
| 263 | $offset = $line_end; |
| 264 | continue; |
| 265 | } |
| 266 | // A non-empty line we cannot parse is a file we would never copy -- and the finalize swap |
| 267 | // would then DELETE it from production. A re-parse cannot fix a corrupt manifest, so fail |
| 268 | // CLOSED immediately rather than retry or silently drop the entry. |
| 269 | fclose( $handle ); |
| 270 | $cp->set( array( 'manifest_offset' => $line_start ) ); |
| 271 | return $this->result( 'error', __( 'The copy manifest is corrupt; cannot continue safely.', 'wp-module-staging' ) ); |
| 272 | } |
| 273 | |
| 274 | $rel = $entry['path']; |
| 275 | $is_dir = isset( $entry['kind'] ) && 'dir' === $entry['kind']; |
| 276 | $src = $this->config['source_root'] . '/' . $rel; |
| 277 | $dest = $this->config['dest_root'] . '/' . $this->withSuffix( $rel ); |
| 278 | $cand = null !== $this->config['link_dest_root'] ? $this->config['link_dest_root'] . '/' . $rel : null; |
| 279 | $foff = ( $rel === $current_file ) ? $file_offset : 0; |
| 280 | $resnap = ( $rel === $current_file ) ? $file_resnapshots : 0; |
| 281 | $size = (int) $entry['size']; |
| 282 | $mtime = (int) $entry['mtime']; |
| 283 | if ( $rel === $current_file ) { |
| 284 | $snap_size = (int) $cp->get( 'file_size' ); |
| 285 | if ( $snap_size > 0 ) { |
| 286 | $size = $snap_size; |
| 287 | $mtime = (int) $cp->get( 'file_mtime' ); |
| 288 | } |
| 289 | } |
| 290 | if ( $is_dir ) { |
| 291 | $res = FileCopier::ensureDirectory( $src, $dest, $mtime, $this->config['source_root'] ); |
| 292 | } else { |
| 293 | $res = FileCopier::copyOne( $src, $dest, $size, $mtime, $cand, $foff, $deadline, $this->config['source_root'], $resnap ); |
| 294 | } |
| 295 | |
| 296 | if ( FileCopier::RESULT_PARTIAL === $res ) { |
| 297 | $current_file = $rel; |
| 298 | $file_offset = $foff; |
| 299 | $file_resnapshots = $resnap; |
| 300 | // bytes_done counts only COMPLETED files; the in-flight offset is added at display time |
| 301 | // (CopyCheckpoint::progress), so it is not double-counted across resumes of this file. |
| 302 | $offset = $line_start; // Re-read this line next burst. |
| 303 | break; |
| 304 | } |
| 305 | |
| 306 | if ( FileCopier::RESULT_ERROR === $res ) { |
| 307 | ++$retries; |
| 308 | $offset = $line_start; // Retry this file next burst. |
| 309 | if ( $retries >= self::MAX_RETRIES ) { |
| 310 | $cp->set( |
| 311 | array( |
| 312 | 'manifest_offset' => $offset, |
| 313 | 'current_file' => $rel, |
| 314 | 'file_offset' => 0, |
| 315 | 'file_size' => 0, |
| 316 | 'file_mtime' => 0, |
| 317 | 'file_resnapshots' => 0, |
| 318 | 'files_done' => $files_done, |
| 319 | 'bytes_done' => $bytes_done, |
| 320 | 'retries' => $retries, |
| 321 | ) |
| 322 | ); |
| 323 | return $this->result( |
| 324 | 'error', |
| 325 | /* translators: %s: file path. */ |
| 326 | sprintf( __( 'Failed to copy %s after repeated attempts.', 'wp-module-staging' ), $rel ) |
| 327 | ); |
| 328 | } |
| 329 | break; // Try again on the next burst. |
| 330 | } |
| 331 | |
| 332 | // DONE or SKIP: advance past this entry. A DONE file is fully copied now (whether or not it |
| 333 | // resumed mid-file), so count its whole size once. Directory markers are not counted in |
| 334 | // files_total, so do not count them in files_done either. |
| 335 | if ( ! $is_dir ) { |
| 336 | if ( FileCopier::RESULT_DONE === $res ) { |
| 337 | $bytes_done += (int) $size; |
| 338 | } |
| 339 | ++$files_done; |
| 340 | } |
| 341 | $current_file = ''; |
| 342 | $file_offset = 0; |
| 343 | $file_resnapshots = 0; |
| 344 | $retries = 0; |
| 345 | $offset = $line_end; |
| 346 | |
| 347 | if ( microtime( true ) >= $deadline ) { |
| 348 | break; |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | fclose( $handle ); |
| 353 | |
| 354 | $snapshot_size = 0; |
| 355 | $snapshot_mtime = 0; |
| 356 | $snapshot_resnapshots = 0; |
| 357 | if ( FileCopier::RESULT_PARTIAL === $res && '' !== $current_file ) { |
| 358 | $snapshot_size = (int) $size; |
| 359 | $snapshot_mtime = (int) $mtime; |
| 360 | $snapshot_resnapshots = (int) $file_resnapshots; |
| 361 | } |
| 362 | |
| 363 | $cp->set( |
| 364 | array( |
| 365 | 'manifest_offset' => $offset, |
| 366 | 'current_file' => $current_file, |
| 367 | 'file_offset' => $file_offset, |
| 368 | 'file_size' => $snapshot_size, |
| 369 | 'file_mtime' => $snapshot_mtime, |
| 370 | 'file_resnapshots' => $snapshot_resnapshots, |
| 371 | 'files_done' => $files_done, |
| 372 | 'bytes_done' => $bytes_done, |
| 373 | 'retries' => $retries, |
| 374 | ) |
| 375 | ); |
| 376 | |
| 377 | return $this->result( CopyCheckpoint::PHASE_DONE === $cp->get( 'phase' ) ? 'success' : 'running' ); |
| 378 | } |
| 379 | |
| 380 | /** |
| 381 | * Apply the destination suffix to the first path segment only |
| 382 | * (e.g. "uploads/2021/x.jpg" + "-new" => "uploads-new/2021/x.jpg"). |
| 383 | * |
| 384 | * @param string $rel Relative path. |
| 385 | * @return string |
| 386 | */ |
| 387 | private function withSuffix( $rel ) { |
| 388 | $suffix = (string) $this->config['dest_suffix']; |
| 389 | if ( '' === $suffix ) { |
| 390 | return $rel; |
| 391 | } |
| 392 | $slash = strpos( $rel, '/' ); |
| 393 | if ( false === $slash ) { |
| 394 | return $rel . $suffix; |
| 395 | } |
| 396 | return substr( $rel, 0, $slash ) . $suffix . substr( $rel, $slash ); |
| 397 | } |
| 398 | |
| 399 | /** |
| 400 | * Build the standard return envelope. |
| 401 | * |
| 402 | * @param string $status running|success|error. |
| 403 | * @param string $message Optional message. |
| 404 | * @return array |
| 405 | */ |
| 406 | private function result( $status, $message = '' ) { |
| 407 | $out = array( |
| 408 | 'status' => $status, |
| 409 | 'progress' => $this->checkpoint->progress(), |
| 410 | ); |
| 411 | if ( '' !== $message ) { |
| 412 | $out['message'] = $message; |
| 413 | } |
| 414 | return $out; |
| 415 | } |
| 416 | |
| 417 | /** |
| 418 | * True once the whole tree is copied. |
| 419 | * |
| 420 | * @return bool |
| 421 | */ |
| 422 | public function isDone() { |
| 423 | $this->checkpoint->load(); |
| 424 | return CopyCheckpoint::PHASE_DONE === $this->checkpoint->get( 'phase' ); |
| 425 | } |
| 426 | |
| 427 | /** |
| 428 | * Remove the job's manifest, cursor, and lock (after a successful hand-off or a discard). |
| 429 | */ |
| 430 | public function cleanup() { |
| 431 | $this->manifest->delete(); |
| 432 | $this->checkpoint->delete(); |
| 433 | if ( is_file( $this->lock_path ) ) { |
| 434 | @unlink( $this->lock_path ); |
| 435 | } |
| 436 | } |
| 437 | } |