Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.18% covered (success)
91.18%
31 / 34
70.00% covered (success)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
File
91.18% covered (success)
91.18%
31 / 34
70.00% covered (success)
70.00%
7 / 10
22.33
0.00% covered (danger)
0.00%
0 / 1
 __construct
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getFolder
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 recordPath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 write
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 read
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 all
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 readRecord
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 delete
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 purgeUndecodable
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 recordFiles
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Queue\Registry\Adapter;
16
17use Pop\Queue\Registry\AbstractRegistry;
18use Pop\Queue\Registry\Exception;
19use Pop\Queue\Registry\WorkerRecord;
20
21/**
22 * File registry class
23 *
24 * One JSON file per worker, in a single flat folder.
25 *
26 * @category   Pop
27 * @package    Pop\Queue
28 * @author     Nick Sagona, III <nick@popphp.org>
29 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
30 * @license    https://www.popphp.org/license     New BSD License
31 * @version    3.0.0
32 */
33class File extends AbstractRegistry
34{
35
36    /**
37     * Folder holding the record files
38     * @var string
39     */
40    protected string $folder;
41
42    /**
43     * Constructor
44     *
45     * @param  string $folder
46     * @throws Exception
47     */
48    public function __construct(string $folder)
49    {
50        if (!file_exists($folder)) {
51            throw new Exception("Error: The folder '" . $folder . "' does not exist.");
52        }
53        if (!is_writable($folder)) {
54            throw new Exception("Error: The folder '" . $folder . "' is not writable.");
55        }
56
57        $this->folder = $folder;
58    }
59
60    /**
61     * Get the folder
62     *
63     * @return string
64     */
65    public function getFolder(): string
66    {
67        return $this->folder;
68    }
69
70    /**
71     * Path of the file backing a given worker ID
72     *
73     * IDs contain ':' and hostnames may contain '.', so the filename is a
74     * sanitized form. The sanitized form alone is not injective ('a.b' and
75     * 'a_b' both map to 'a_b'), so a short hash of the original ID is
76     * appended to keep distinct IDs in distinct files while the readable
77     * part stays useful for anyone browsing the folder.
78     *
79     * @param  string $id
80     * @return string
81     */
82    protected function recordPath(string $id): string
83    {
84        return $this->folder . DIRECTORY_SEPARATOR . 'worker-' .
85            preg_replace('/[^A-Za-z0-9_\-]/', '_', $id) . '-' . substr(sha1($id), 0, 8) . '.json';
86    }
87
88    public function write(WorkerRecord $record): void
89    {
90        file_put_contents($this->recordPath($record->getId()), $this->encode($record));
91    }
92
93    public function read(string $id): ?WorkerRecord
94    {
95        return $this->readRecord($this->recordPath($id));
96    }
97
98    public function all(): array
99    {
100        $records = [];
101
102        foreach ($this->recordFiles() as $file) {
103            // readRecord() returns null for a corrupt/truncated/unreadable file,
104            // which is skipped rather than allowed to derail enumeration.
105            $record = $this->readRecord($this->folder . DIRECTORY_SEPARATOR . $file);
106            if ($record !== null) {
107                $records[$record->getId()] = $record;
108            }
109        }
110
111        return $records;
112    }
113
114    /**
115     * Read and decode a record file, or null if it is unreadable or unusable
116     *
117     * The false-check is what keeps an unreadable file on the same graceful
118     * path as an undecodable one. file_get_contents() returns false, not '',
119     * when the read itself fails - and a worker registry is read while other
120     * workers are pruning it, so a file that passes file_exists() and is then
121     * unlinked before the read lands is an ordinary race, not corruption.
122     * Under declare(strict_types=1) handing that false to decode(?string)
123     * would be a TypeError, turning a routine race into a crash that takes
124     * out enumeration for every other worker in the registry.
125     *
126     * @param  string $path
127     * @return ?WorkerRecord
128     */
129    protected function readRecord(string $path): ?WorkerRecord
130    {
131        if (!file_exists($path)) {
132            return null;
133        }
134
135        // Suppressed deliberately: a registry read losing a race to a concurrent
136        // prune is expected operation, not a fault worth emitting a warning for.
137        // The false return is what this method acts on.
138        $payload = @file_get_contents($path);
139
140        return ($payload !== false) ? $this->decode($payload) : null;
141    }
142
143    public function delete(string $id): void
144    {
145        $path = $this->recordPath($id);
146        if (file_exists($path)) {
147            unlink($path);
148        }
149    }
150
151    protected function purgeUndecodable(): int
152    {
153        $removed = 0;
154
155        foreach ($this->recordFiles() as $file) {
156            $path = $this->folder . DIRECTORY_SEPARATOR . $file;
157
158            // Counting the unlink rather than the decode keeps the tally honest
159            // when another worker prunes the same registry concurrently: a file
160            // that vanished between the listing and here is already purged, and
161            // claiming it twice would overstate what this call actually did.
162            if (($this->readRecord($path) === null) && @unlink($path)) {
163                $removed++;
164            }
165        }
166
167        return $removed;
168    }
169
170    /**
171     * The record filenames in the folder
172     *
173     * @return array
174     */
175    protected function recordFiles(): array
176    {
177        if (!is_dir($this->folder)) {
178            return [];
179        }
180
181        return array_values(array_filter(scandir($this->folder), function($value) {
182            return (str_starts_with($value, 'worker-') && str_ends_with($value, '.json'));
183        }));
184    }
185
186}