Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.50% covered (warning)
87.50%
77 / 88
63.64% covered (warning)
63.64%
7 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
CopyCheckpoint
87.50% covered (warning)
87.50%
77 / 88
63.64% covered (warning)
63.64%
7 / 11
43.12
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 defaults
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 load
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
 encodePaths
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
8
 decodePaths
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 save
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
12.10
 delete
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 set
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 toArray
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 progress
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Durable, resumable cursor for a chunked content copy.
4 *
5 * @package NewfoldLabs\WP\Module\Staging
6 */
7
8namespace NewfoldLabs\WP\Module\Staging\Copy;
9
10/**
11 * Persists where a copy job is, so a burst that is interrupted (SIGKILL, request timeout, closed tab)
12 * resumes from the last saved point instead of starting over.
13 *
14 * The cursor is written to disk with a temp-file + rename, and always AFTER the bytes it describes
15 * have been flushed. That ordering is the whole safety property: a crash can only ever cause the next
16 * run to REPEAT a little work, never to SKIP a file that was not actually copied.
17 *
18 * This class is pure PHP (no WordPress), so it can be unit-tested directly against a temp dir.
19 */
20class CopyCheckpoint {
21
22    /**
23     * Enumerating the source tree into the manifest.
24     */
25    const PHASE_ENUMERATE = 'enumerate';
26
27    /**
28     * Copying manifest entries into the destination.
29     */
30    const PHASE_COPY = 'copy';
31
32    /**
33     * All files copied and verified.
34     */
35    const PHASE_DONE = 'done';
36
37    /**
38     * Absolute path to the cursor file on disk.
39     *
40     * @var string
41     */
42    private $path;
43
44    /**
45     * Cursor state. See defaults() for the shape.
46     *
47     * @var array
48     */
49    private $state;
50
51    /**
52     * Construct a checkpoint bound to a cursor file path.
53     *
54     * @param string $path Absolute path to the cursor JSON file.
55     */
56    public function __construct( $path ) {
57        $this->path  = $path;
58        $this->state = self::defaults();
59    }
60
61    /**
62     * The initial cursor for a fresh job.
63     *
64     * @return array
65     */
66    private static function defaults() {
67        return array(
68            // A unique id for this job, stamped by the engine when it seeds a fresh cursor. Carried for
69            // diagnostics/log correlation. A stale cursor cannot be mistaken for the current job: the
70            // controller's setup phase calls CopyEngine::cleanup() before each run (see
71            // ContentCopyController::runSetup), so the cursor is always deleted and reseeded, never
72            // resumed across jobs.
73            'job_id'          => '',
74            'phase'           => self::PHASE_ENUMERATE,
75            // Enumerate phase: directories still to walk (relative to the source root). Seeded by the
76            // engine with the content roots (uploads, themes, plugins).
77            'pending_dirs'    => array(),
78            // Enumerate phase: the directory currently being walked (relative to the source root, or null
79            // when none is mid-walk) and the NAME of the last entry already processed in it (or null for
80            // "start from the beginning"). A single huge directory resumes after that name instead of
81            // being re-walked (and re-appended to the manifest) every burst. Resuming by name, not index,
82            // tolerates the directory's contents changing between bursts: nothing <= the last done name is
83            // re-processed, and no existing entry that sorts after it is skipped.
84            'walk_dir'        => null,
85            'walk_last'       => null,
86            // True only once the walk has finished. The copy phase must never start on a manifest whose
87            // walk did not complete (a crash mid-walk would otherwise look "done" with an empty worklist).
88            'walk_complete'   => false,
89            // Copy phase: byte offset into the manifest file of the next line to process.
90            'manifest_offset' => 0,
91            // Copy phase: the entry currently in flight and how many of its bytes are already on disk,
92            // so a single file larger than one budget resumes mid-file instead of restarting.
93            'current_file'    => '',
94            'file_offset'     => 0,
95            // When the source changed after enumeration, FileCopier re-snapshots size + mtime; these fields
96            // persist that snapshot across bursts so multi-chunk resume does not compare against the stale
97            // manifest on every advance (which would reset offset to 0 forever). file_resnapshots bounds
98            // full restarts when a file keeps growing across bursts (e.g. an active debug.log).
99            'file_size'        => 0,
100            'file_mtime'       => 0,
101            'file_resnapshots' => 0,
102            // Progress + accounting.
103            'files_done'       => 0,
104            'bytes_done'       => 0,
105            'files_total'      => 0,
106            'bytes_total'      => 0,
107            'retries'          => 0,
108        );
109    }
110
111    /**
112     * Load the cursor from disk if present; otherwise keep the fresh defaults.
113     *
114     * @return bool True if an existing cursor was loaded.
115     */
116    public function load() {
117        if ( ! is_file( $this->path ) ) {
118            return false;
119        }
120        $raw = file_get_contents( $this->path );
121        if ( false === $raw || '' === $raw ) {
122            return false;
123        }
124        $decoded = json_decode( $raw, true );
125        if ( ! is_array( $decoded ) ) {
126            return false;
127        }
128        // Merge over defaults so an older/partial cursor still has every key, then decode the paths.
129        $this->state = self::decodePaths( array_merge( self::defaults(), $decoded ) );
130        return true;
131    }
132
133    /**
134     * Encode the path-bearing fields of a state array to base64 for safe JSON storage.
135     *
136     * @param array $state State with raw paths.
137     * @return array State with encoded paths.
138     */
139    private static function encodePaths( array $state ) {
140        if ( isset( $state['current_file'] ) ) {
141            $state['current_file'] = base64_encode( (string) $state['current_file'] );
142        }
143        // walk_dir / walk_last are null when no directory is mid-walk; only encode an actual path/name.
144        if ( isset( $state['walk_dir'] ) && null !== $state['walk_dir'] ) {
145            $state['walk_dir'] = base64_encode( (string) $state['walk_dir'] );
146        }
147        if ( isset( $state['walk_last'] ) && null !== $state['walk_last'] ) {
148            $state['walk_last'] = base64_encode( (string) $state['walk_last'] );
149        }
150        if ( isset( $state['pending_dirs'] ) && is_array( $state['pending_dirs'] ) ) {
151            $state['pending_dirs'] = array_map( 'base64_encode', $state['pending_dirs'] );
152        }
153        return $state;
154    }
155
156    /**
157     * Reverse encodePaths(): decode the path-bearing fields back to raw bytes.
158     *
159     * @param array $state State with encoded paths.
160     * @return array State with raw paths.
161     */
162    private static function decodePaths( array $state ) {
163        if ( isset( $state['current_file'] ) ) {
164            $state['current_file'] = (string) base64_decode( (string) $state['current_file'] );
165        }
166        // A null walk_dir / walk_last (nothing mid-walk) round-trips as null, not an empty decoded string.
167        if ( isset( $state['walk_dir'] ) && null !== $state['walk_dir'] ) {
168            $state['walk_dir'] = (string) base64_decode( (string) $state['walk_dir'] );
169        }
170        if ( isset( $state['walk_last'] ) && null !== $state['walk_last'] ) {
171            $state['walk_last'] = (string) base64_decode( (string) $state['walk_last'] );
172        }
173        if ( isset( $state['pending_dirs'] ) && is_array( $state['pending_dirs'] ) ) {
174            $state['pending_dirs'] = array_map(
175                function ( $v ) {
176                    return (string) base64_decode( (string) $v );
177                },
178                $state['pending_dirs']
179            );
180        }
181        return $state;
182    }
183
184    /**
185     * Persist the cursor atomically (temp file + rename), so a crash mid-write cannot leave a
186     * truncated cursor that would be unreadable on resume.
187     *
188     * @return bool
189     */
190    public function save() {
191        $dir = dirname( $this->path );
192        if ( ! is_dir( $dir ) && ! mkdir( $dir, 0700, true ) && ! is_dir( $dir ) ) {
193            return false;
194        }
195        $json = wp_json_encode( self::encodePaths( $this->state ) );
196        if ( false === $json ) {
197            return false;
198        }
199        $tmp     = $this->path . '.tmp-' . getmypid();
200        $written = file_put_contents( $tmp, $json, LOCK_EX );
201        // A short write (full disk / over quota) must NOT be promoted: a truncated cursor would be
202        // unreadable on resume, and the engine would then restart and potentially mis-count as done.
203        if ( false === $written || $written < strlen( $json ) ) {
204            @unlink( $tmp );
205            return false;
206        }
207        if ( ! rename( $tmp, $this->path ) ) {
208            @unlink( $tmp );
209            return false;
210        }
211        return true;
212    }
213
214    /**
215     * Remove the cursor file (job finished or discarded).
216     */
217    public function delete() {
218        if ( is_file( $this->path ) ) {
219            @unlink( $this->path );
220        }
221    }
222
223    /**
224     * Get a cursor field.
225     *
226     * @param string $key     Field name.
227     * @param mixed  $default Value if the field is missing.
228     * @return mixed
229     */
230    public function get( $key, $default = null ) {
231        return array_key_exists( $key, $this->state ) ? $this->state[ $key ] : $default;
232    }
233
234    /**
235     * Set one or more cursor fields (does not persist; call save()).
236     *
237     * @param array $changes Field => value.
238     * @return CopyCheckpoint
239     */
240    public function set( array $changes ) {
241        $this->state = array_merge( $this->state, $changes );
242        return $this;
243    }
244
245    /**
246     * The whole cursor, for returning progress to the client.
247     *
248     * @return array
249     */
250    public function toArray() {
251        return $this->state;
252    }
253
254    /**
255     * A compact progress view for the status endpoint / UI.
256     *
257     * @return array
258     */
259    public function progress() {
260        $bytes_total = (int) $this->state['bytes_total'];
261        // bytes_done counts completed files; add the in-flight file's offset so the bar advances during
262        // a large file without being double-counted when that file finishes.
263        $bytes_done  = (int) $this->state['bytes_done'] + (int) $this->state['file_offset'];
264        $percent     = $bytes_total > 0
265            ? min( 100, (int) floor( ( $bytes_done / $bytes_total ) * 100 ) )
266            : ( self::PHASE_DONE === $this->state['phase'] ? 100 : 0 );
267        return array(
268            'phase'       => $this->state['phase'],
269            'files_done'  => (int) $this->state['files_done'],
270            'files_total' => (int) $this->state['files_total'],
271            'bytes_done'  => $bytes_done,
272            'bytes_total' => $bytes_total,
273            'percent'     => $percent,
274        );
275    }
276}