Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.78% covered (warning)
77.78%
154 / 198
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
CopyEngine
77.78% covered (warning)
77.78%
154 / 198
37.50% covered (danger)
37.50%
3 / 8
83.83
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 advance
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
8.60
 runBurst
85.71% covered (warning)
85.71%
36 / 42
0.00% covered (danger)
0.00%
0 / 1
13.49
 runCopyPhase
75.68% covered (warning)
75.68%
84 / 111
0.00% covered (danger)
0.00%
0 / 1
34.00
 withSuffix
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 result
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 isDone
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 cleanup
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
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
8namespace 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 */
25class 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}