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 / diagnostics / CheckpointIntentStore.php

CheckpointIntentStore.php in 404 Solution trunk, at includes/diagnostics/CheckpointIntentStore.php

214 lines 8.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Persists pre-operation checkpoint intent without touching WordPress paths.
9 *
10 * The sink is install-scoped and lives directly in the existing system temp
11 * directory. Appends use O_APPEND and never wait for a lock; the best-effort
12 * lock is used only to keep the bounded current/rotated pair orderly.
13 */
14 final class ABJ_404_Solution_CheckpointIntentStore {
15
16 /**
17 * Retain at least the ordinary checkpoint journal's measured session.
18 *
19 * This sink is deliberately independent of the ordinary writer, so the
20 * value stays literal instead of loading that class before the first
21 * fixed-temp append. CheckpointIntentStoreTest pins the two retention
22 * windows together. A smaller window lets sibling AJAX traffic rotate an
23 * unmatched pre-directory intent away while the same session remains in
24 * the ordinary journal and before a support request can rank it.
25 */
26 const MAX_BYTES = 4194304;
27
28 /**
29 * Append one intent record. Never throws.
30 *
31 * @param array<string, mixed> $record
32 * @return array<string, mixed>
33 */
34 public static function append(array $record): array {
35 $startedNs = self::monotonicNanoseconds();
36 $requestId = self::recordString($record, 'request_id', 'unknown00');
37 $event = self::recordString($record, 'intended_event', 'unknown');
38 try {
39 $paths = self::pathMap();
40 if ($paths === null) {
41 self::reportFailure('AJAX checkpoint intent system temp directory is unavailable.');
42 return self::result('failed', 'temp_directory_unavailable',
43 $requestId, $event, $startedNs);
44 }
45 $json = json_encode($record, JSON_UNESCAPED_SLASHES);
46 if (!is_string($json)) {
47 self::reportFailure('AJAX checkpoint intent JSON encoding failed.');
48 return self::result('failed', 'json_encode_failed',
49 $requestId, $event, $startedNs);
50 }
51 $outcome = self::appendLine($paths, $json . "\n");
52 return self::result($outcome['status'], $outcome['reason'],
53 $requestId, $event, $startedNs);
54 } catch (Throwable $e) {
55 self::reportFailure('AJAX checkpoint intent append failed: ' . $e->getMessage());
56 return self::result('failed', 'unexpected_failure',
57 $requestId, $event, $startedNs);
58 }
59 }
60
61 /**
62 * Existing fallback files, oldest first, for support collection.
63 *
64 * @return array<int, string>
65 */
66 public static function paths(): array {
67 return array_values(array_filter(self::candidatePaths(), static function (string $path): bool {
68 clearstatcache(true, $path);
69 return is_file($path);
70 }));
71 }
72
73 /**
74 * Deterministic fallback candidates for cleanup and failure verification.
75 *
76 * @return array<int, string>
77 */
78 public static function candidatePaths(): array {
79 $paths = self::pathMap();
80 return $paths === null ? array() : array($paths['rotated'], $paths['current']);
81 }
82
83 /**
84 * @param array{current: string, rotated: string, lock: string} $paths
85 * @return array{status: string, reason: string}
86 */
87 private static function appendLine(array $paths, string $line): array {
88 // Tracked by the request-scoped descriptor rather than stat()ed per
89 // record. See ABJ_404_Solution_DiagnosticAppendStream for why: this
90 // sink takes one write per checkpoint, and a checkpoint-heavy request
91 // writes thousands.
92 $size = ABJ_404_Solution_DiagnosticAppendStream::sizeOf($paths['current']);
93 if (($size + strlen($line)) > self::MAX_BYTES) {
94 self::rotate($paths, strlen($line));
95 }
96 return self::writeLine($paths['current'], $line);
97 }
98
99 /** @param array{current: string, rotated: string, lock: string} $paths */
100 private static function rotate(array $paths, int $incomingBytes): void {
101 self::clearLastError();
102 $lock = @fopen($paths['lock'], 'cb');
103 if ($lock === false) {
104 self::reportFailure('AJAX checkpoint intent rotation lock could not be opened: '
105 . $paths['lock'] . self::lastErrorSuffix());
106 return;
107 }
108 try {
109 if (!@flock($lock, LOCK_EX | LOCK_NB)) {
110 return;
111 }
112 clearstatcache(true, $paths['current']);
113 $size = @filesize($paths['current']);
114 if (!is_int($size) || ($size + $incomingBytes) <= self::MAX_BYTES) {
115 return;
116 }
117 if (is_file($paths['rotated']) && !@unlink($paths['rotated'])) {
118 self::reportFailure('AJAX checkpoint intent rotated file could not be deleted: '
119 . $paths['rotated'] . self::lastErrorSuffix());
120 return;
121 }
122 if (is_file($paths['current']) && !@rename($paths['current'], $paths['rotated'])) {
123 self::reportFailure('AJAX checkpoint intent file could not be rotated: '
124 . $paths['current'] . self::lastErrorSuffix());
125 }
126 } finally {
127 // Whatever happened above, the held descriptor may now name the
128 // rotated file. Drop it so the next intent re-resolves the path.
129 ABJ_404_Solution_DiagnosticAppendStream::invalidate($paths['current']);
130 @flock($lock, LOCK_UN);
131 @fclose($lock);
132 }
133 }
134
135 /** @return array{status: string, reason: string} */
136 private static function writeLine(string $path, string $line): array {
137 self::clearLastError();
138 $result = ABJ_404_Solution_DiagnosticAppendStream::append($path, $line);
139 if ($result['status'] === 'complete') {
140 return array('status' => 'complete', 'reason' => '');
141 }
142 if ($result['reason'] === 'open_failed') {
143 self::reportFailure('AJAX checkpoint intent file could not be opened: '
144 . $path . self::lastErrorSuffix());
145 return array('status' => 'failed', 'reason' => 'intent_open_failed');
146 }
147 self::reportFailure('AJAX checkpoint intent append/flush failed: '
148 . $path . self::lastErrorSuffix());
149 return array('status' => 'failed', 'reason' => 'intent_append_failed');
150 }
151
152 /** @return array{current: string, rotated: string, lock: string}|null */
153 private static function pathMap(): ?array {
154 $directory = rtrim((string)sys_get_temp_dir(), '/\\');
155 if ($directory === '' || !is_dir($directory)) {
156 return null;
157 }
158 $scope = defined('ABSPATH') ? (string)ABSPATH : __DIR__;
159 $digest = strtr(hash('sha256', $scope), '0123456789', 'abcdefghij');
160 $stem = $directory . DIRECTORY_SEPARATOR . 'abj-checkpoint-intent-' . $digest;
161 return array(
162 'current' => $stem . '.jsonl',
163 'rotated' => $stem . '.old.jsonl',
164 'lock' => $stem . '.lock',
165 );
166 }
167
168 /**
169 * @return array<string, mixed>
170 */
171 private static function result(
172 string $status,
173 string $reason,
174 string $requestId,
175 string $event,
176 int $startedNs
177 ): array {
178 $result = array('status' => $status, 'request_id' => $requestId,
179 'event' => $event, 'elapsed_us' => self::elapsedMicroseconds($startedNs));
180 if ($reason !== '') {
181 $result['reason'] = $reason;
182 }
183 return $result;
184 }
185
186 /** @param array<string, mixed> $record */
187 private static function recordString(array $record, string $key, string $fallback): string {
188 return isset($record[$key]) && is_string($record[$key]) ? $record[$key] : $fallback;
189 }
190
191 private static function clearLastError(): void {
192 if (function_exists('error_clear_last')) {
193 error_clear_last();
194 }
195 }
196
197 private static function lastErrorSuffix(): string {
198 $error = error_get_last();
199 return is_array($error) ? ' (' . $error['message'] . ')' : '';
200 }
201
202 private static function monotonicNanoseconds(): int {
203 return function_exists('hrtime') ? (int)hrtime(true) : 0;
204 }
205
206 private static function elapsedMicroseconds(int $startedNs): int {
207 return max(0, (int)round((self::monotonicNanoseconds() - $startedNs) / 1000));
208 }
209
210 private static function reportFailure(string $message): void {
211 abj404_logPhpFallback('ajax-checkpoint', $message);
212 }
213 }
214