PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / php / FileSync.php

FileSync.php in 404 Solution trunk, at includes/php/FileSync.php

243 lines 8.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 class ABJ_404_Solution_FileSync {
9
10 /** How long an empty owner file must have sat before claimOwnerFile()
11 * treats it as abandoned rather than as a claim in progress. Only a fatal
12 * between the exclusive create and the write can produce one, so the grace
13 * period only has to outlast those few microseconds; it is seconds rather
14 * than milliseconds purely so a filesystem with coarse mtime granularity
15 * cannot make a live claim look abandoned.
16 * @var int */
17 const EMPTY_OWNER_FILE_GRACE_SECONDS = 5;
18
19 /** Upper bound for contention on the per-key mutation guard. */
20 const OWNER_MUTATION_GUARD_WAIT_MICROSECONDS = 250000;
21
22 /** @var self|null */
23 private static $instance = null;
24 /**
25 * Test seam: install or clear the cached singleton instance without
26 * private-field reflection. Pass null to reset between tests; pass a
27 * configured instance (or double) to install it. Mirrors the setInstance()
28 * contract on DataAccess / PluginLogic (M105 singleton-reset seam).
29 *
30 * @param self|null $instance
31 * @return void
32 */
33 public static function setInstance($instance) {
34 self::$instance = $instance;
35 }
36
37
38 /** @return self */
39 public static function getInstance(): self {
40 if (self::$instance == null) {
41 self::$instance = new ABJ_404_Solution_FileSync();
42 }
43
44 return self::$instance;
45 }
46
47 /**
48 * @param string $key
49 * @return string
50 */
51 function getSyncFilePath(string $key): string {
52 $filePath = abj404_getUploadsDir() . 'SYNC_FILE_' . $key . '.txt';
53 return $filePath;
54 }
55
56 /**
57 * @param string $key
58 * @return string
59 */
60 function getOwnerFromFile(string $key): string {
61 $filePath = $this->getSyncFilePath($key);
62
63 // Read and catch rather than check-then-read, so there is no TOCTOU
64 // window between "does it exist" and "read it".
65 try {
66 $contents = ABJ_404_Solution_FileSystemService::readFileContents($filePath, false);
67 return $contents;
68 } catch (Exception $e) {
69 // Empty means "no lock owner" to every caller, and for a missing
70 // file that is exactly right. For a file that EXISTS and could not
71 // be read it is dangerous: a permissions problem or a full disk
72 // then presents as an unlocked resource, two workers proceed at
73 // once, and nothing downstream can tell the difference.
74 //
75 // So report rather than decide. This class does file I/O; whether
76 // an unreadable lock file should stop the caller is lock policy,
77 // and lock policy lives in LockOwnerStore. Deciding here also meant
78 // writing a log line from inside a getter, which is its own problem
79 // (lint-hidden-write-getters) and a fair one: a get* that emits
80 // telemetry surprises every caller.
81 if (is_file($filePath)) {
82 throw $e;
83 }
84 return "";
85 }
86 }
87
88 /**
89 * Take ownership of $key, but only if nobody owns it yet.
90 *
91 * This is the whole mutual-exclusion primitive for file storage mode, and
92 * it is atomic by construction: fopen() with mode 'x' is O_CREAT|O_EXCL,
93 * which the kernel resolves for exactly one caller no matter how many
94 * arrive at once. Nothing here reads the current owner and then decides,
95 * because a read-then-write protocol has a window between the two steps
96 * where a second request can read the same "unowned" answer and both then
97 * write. That window is what handed two requests the same
98 * 'update_db_version' lock in error report 270.
99 *
100 * A file that exists but is EMPTY is a lock nobody can break: the owner
101 * reads as '', so the stale-lock check returns early and never deletes it,
102 * while this method keeps failing because the file is there. Only two
103 * things can produce one -- a write that failed after the create (disk
104 * full, quota) and a fatal in the microseconds between the create and the
105 * write -- and both are handled: the first by unlinking before returning,
106 * the second by reclaiming a stale empty file here rather than leaving the
107 * key wedged until someone clears the uploads directory by hand.
108 *
109 * @param array{key: string, owner: string} $claim
110 * @return bool true only if this call created the owner record.
111 */
112 function claimOwnerFile(array $claim): bool {
113 $key = $claim['key'];
114 $uniqueID = $claim['owner'];
115 $filePath = $this->getSyncFilePath($key);
116 return $this->withOwnerMutationGuard($filePath, function () use ($filePath, $uniqueID): bool {
117 if ($this->createOwnerFileExclusively(array(
118 'filePath' => $filePath,
119 'owner' => $uniqueID,
120 ))) {
121 return true;
122 }
123 if (!$this->reclaimAbandonedEmptyOwnerFile($filePath)) {
124 return false;
125 }
126 return $this->createOwnerFileExclusively(array(
127 'filePath' => $filePath,
128 'owner' => $uniqueID,
129 ));
130 });
131 }
132
133 /**
134 * One O_CREAT|O_EXCL attempt. Leaves no file behind on any failure path, so
135 * a caller that gets false can trust that it created nothing.
136 *
137 * @param array{filePath: string, owner: string} $claim
138 * @return bool
139 */
140 private function createOwnerFileExclusively(array $claim): bool {
141 $filePath = $claim['filePath'];
142 $uniqueID = $claim['owner'];
143 $handle = @fopen($filePath, 'xb');
144 if ($handle === false) {
145 return false;
146 }
147
148 $written = @fwrite($handle, $uniqueID);
149 $flushed = @fflush($handle);
150 @fclose($handle);
151
152 if ($written === false || $written !== strlen($uniqueID) || $flushed === false) {
153 // The record would read back as unowned while still blocking every
154 // later claim. Remove it and report the claim as lost, which is the
155 // safe direction: the caller skips its critical section.
156 ABJ_404_Solution_FileSystemService::safeUnlink($filePath);
157 return false;
158 }
159
160 return true;
161 }
162
163 /** Delete an owner file that is empty and old enough that no live request
164 * could still be between its create and its write.
165 *
166 * @param string $filePath
167 * @return bool true if a file was removed and a retry is worth attempting.
168 */
169 private function reclaimAbandonedEmptyOwnerFile(string $filePath): bool {
170 clearstatcache(true, $filePath);
171 if (!is_file($filePath) || filesize($filePath) !== 0) {
172 return false;
173 }
174
175 $modifiedAt = @filemtime($filePath);
176 if ($modifiedAt === false || (abj_clock()->now() - $modifiedAt) < self::EMPTY_OWNER_FILE_GRACE_SECONDS) {
177 return false;
178 }
179
180 ABJ_404_Solution_FileSystemService::safeUnlink($filePath);
181 clearstatcache(true, $filePath);
182 return !is_file($filePath);
183 }
184
185 /**
186 * @param array{key: string, owner: string} $release
187 * @return bool true only when the named owner was removed
188 */
189 function releaseLock(array $release): bool {
190 $key = $release['key'];
191 $uniqueID = $release['owner'];
192 $filePath = $this->getSyncFilePath($key);
193 return $this->withOwnerMutationGuard($filePath, function () use ($filePath, $uniqueID): bool {
194 try {
195 $currentOwner = ABJ_404_Solution_FileSystemService::readFileContents($filePath, false);
196 } catch (Exception $e) {
197 if (!is_file($filePath)) {
198 return false;
199 }
200 throw $e;
201 }
202 if ($currentOwner !== $uniqueID) {
203 return false;
204 }
205 ABJ_404_Solution_FileSystemService::safeUnlink($filePath);
206 clearstatcache(true, $filePath);
207 return !is_file($filePath);
208 });
209 }
210
211 /**
212 * Serialize owner-file replacement and conditional release for one key.
213 *
214 * @template T
215 * @param string $filePath
216 * @param callable(): T $operation
217 * @return T
218 */
219 private function withOwnerMutationGuard(string $filePath, callable $operation) {
220 $guardPath = $filePath . '.guard';
221 ABJ_404_Solution_FileSystemService::createDirectoryWithErrorMessages(dirname($guardPath));
222 $guard = @fopen($guardPath, 'c');
223 if ($guard === false) {
224 throw new RuntimeException('Could not open lock-owner mutation guard: ' . $guardPath);
225 }
226 try {
227 $attemptsRemaining = max(1, intdiv(self::OWNER_MUTATION_GUARD_WAIT_MICROSECONDS, 10000));
228 while (!@flock($guard, LOCK_EX | LOCK_NB)) {
229 $attemptsRemaining--;
230 if ($attemptsRemaining <= 0) {
231 throw new RuntimeException('Timed out acquiring lock-owner mutation guard: ' . $guardPath);
232 }
233 usleep(10000);
234 }
235 return $operation();
236 } finally {
237 @flock($guard, LOCK_UN);
238 @fclose($guard);
239 }
240 }
241
242 }
243