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 / CheckpointJournalWriter.php

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

241 lines 11.2 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 one checkpoint record with bounded lock acquisition.
9 *
10 * A held filesystem lock must never manufacture the timeout this diagnostic
11 * is measuring. Normal writes remain serialized for rotation safety; when
12 * the advisory lock exceeds its deadline, one O_APPEND emergency record is
13 * written without taking that same lock and the caller resumes immediately.
14 */
15 final class ABJ_404_Solution_CheckpointJournalWriter {
16
17 const CHECKPOINT_FILE = 'abj404_ajax_checkpoints.jsonl';
18 const ROTATED_FILE = 'abj404_ajax_checkpoints.old.jsonl';
19 const LOCK_FILE = 'abj404_ajax_checkpoints.lock';
20 /**
21 * One current plus one rotated file retain a measured worst-case session.
22 *
23 * Sized against the measurement, not chosen for tidiness, and raised once
24 * per schema that adds per-record volume -- because the failure mode of an
25 * undersized cap is not "less detail", it is rotation DELETING the first
26 * failures of a session before support extraction can ever rank them.
27 * Schema 3 added host pressure and recorder cost and forced the move from
28 * 512 KB to 1 MB. Schema 4 adds the intra-stage per-query and per-row
29 * channels, which take a table request from 26-27 records (~13-16 KB) to
30 * ~59 records (~30 KB); the worst-case session this project calibrates
31 * against -- six failing attempts, the seven-step canary ladder, and sixty
32 * detect-only polls, 69 requests in all -- measures 2,096,927 bytes, so a
33 * 1 MB cap rotated twice and deleted every one of the six failures.
34 *
35 * Retention worst case is ONE cap's worth (immediately after a rotation
36 * the current file is empty), so the cap itself, not cap*2, is what has to
37 * exceed a whole session. 4 MB leaves roughly 1.9x headroom over the
38 * measured one. SupportEvidenceWorstCaseVolumeTest is that measurement
39 * turned into a gate, and it fails if this drifts back under it.
40 */
41 const MAX_CHECKPOINT_BYTES = 4194304;
42 const LOCK_WAIT_TIMEOUT_US = 50000;
43
44 /**
45 * @param array<string, mixed> $record
46 * @return array<string, mixed> Measured write result for the next record.
47 */
48 public static function append(string $directory, array $record): array {
49 $startedNs = self::monotonicNanoseconds();
50 $event = is_string($record['event'] ?? null) ? $record['event'] : 'unknown';
51 $requestId = is_string($record['request_id'] ?? null) ? $record['request_id'] : 'unknown00';
52 $path = $directory . self::CHECKPOINT_FILE;
53 $json = json_encode($record, JSON_UNESCAPED_SLASHES);
54 if (!is_string($json)) {
55 self::reportFailure('AJAX checkpoint JSON encoding failed.');
56 return self::result(array('status' => 'failed', 'reason' => 'json_encode_failed',
57 'request_id' => $requestId, 'event' => $event, 'started_ns' => $startedNs));
58 }
59
60 $lockPath = $directory . self::LOCK_FILE;
61 $acquired = ABJ_404_Solution_DiagnosticAppendStream::acquireExclusive(
62 $lockPath,
63 self::LOCK_WAIT_TIMEOUT_US
64 );
65 if ($acquired['status'] === 'failed') {
66 self::reportFailure('AJAX checkpoint lock file could not be opened: ' . $lockPath);
67 return self::result(array('status' => 'failed', 'reason' => 'lock_open_failed',
68 'request_id' => $requestId, 'event' => $event, 'started_ns' => $startedNs));
69 }
70 $status = 'complete';
71 $reason = '';
72 try {
73 if ($acquired['status'] === 'lock_timeout') {
74 $status = 'lock_timeout';
75 $reason = 'lock_wait_exceeded';
76 $waitUs = self::elapsedMicroseconds($startedNs);
77 self::appendLockTimeoutRecord(array(
78 'path' => $path,
79 'record' => $record,
80 'blocked_event' => $event,
81 'wait_us' => $waitUs,
82 ));
83 self::reportFailure('AJAX checkpoint lock timed out after ' . $waitUs . 'us: ' . $path);
84 return self::result(array('status' => $status, 'reason' => $reason,
85 'request_id' => $requestId, 'event' => $event, 'started_ns' => $startedNs));
86 }
87 $outcome = self::appendUnderLock(array(
88 'directory' => $directory,
89 'path' => $path,
90 'line' => $json . "\n",
91 ));
92 $status = $outcome['status'];
93 $reason = $outcome['reason'];
94 } finally {
95 $released = ABJ_404_Solution_DiagnosticAppendStream::release($lockPath);
96 if ($released['status'] === 'failed') {
97 $status = 'failed';
98 $reason = 'unlock_failed';
99 self::reportFailure('AJAX checkpoint lock could not be released: ' . $path);
100 }
101 }
102 return self::result(array('status' => $status, 'reason' => $reason,
103 'request_id' => $requestId, 'event' => $event, 'started_ns' => $startedNs));
104 }
105
106 /**
107 * @param array{directory: string, path: string, line: string} $write
108 * @return array{status: string, reason: string}
109 */
110 private static function appendUnderLock(array $write): array {
111 $directory = $write['directory'];
112 $path = $write['path'];
113 $line = $write['line'];
114 $status = 'complete';
115 $reason = '';
116 // The size comes from the request-scoped descriptor rather than a
117 // filesize() per record: one stat at open, re-seeded whenever the
118 // descriptor is revalidated. See ABJ_404_Solution_DiagnosticAppendStream.
119 $size = ABJ_404_Solution_DiagnosticAppendStream::sizeOf($path);
120 if (($size + strlen($line)) > self::MAX_CHECKPOINT_BYTES) {
121 // A sibling may have rotated the held descriptor away from this
122 // path. Revalidate before a destructive decision: its stale size
123 // must not delete the retained generation and rotate a small live
124 // journal merely because the old inode was near the cap.
125 $size = ABJ_404_Solution_DiagnosticAppendStream::revalidatedSizeOf($path);
126 }
127 if (($size + strlen($line)) > self::MAX_CHECKPOINT_BYTES) {
128 $old = $directory . self::ROTATED_FILE;
129 if (@is_file($old) && !@unlink($old)) {
130 $status = 'failed';
131 $reason = 'rotated_file_delete_failed';
132 self::reportFailure('AJAX checkpoint rotated file could not be deleted: ' . $old);
133 }
134 if (@is_file($path) && !@rename($path, $old)) {
135 $status = 'failed';
136 $reason = 'rotation_rename_failed';
137 self::reportFailure('AJAX checkpoint file could not be rotated: ' . $path);
138 }
139 // The held descriptor now names the rotated file, so drop it: the
140 // record that triggered the rotation belongs in the new journal.
141 ABJ_404_Solution_DiagnosticAppendStream::invalidate($path);
142 }
143 $written = ABJ_404_Solution_DiagnosticAppendStream::append($path, $line);
144 if ($written['status'] !== 'complete') {
145 if ($written['reason'] === 'open_failed') {
146 self::reportFailure('AJAX checkpoint file could not be opened: ' . $path);
147 return array('status' => 'failed', 'reason' => 'journal_open_failed');
148 }
149 self::reportFailure('AJAX checkpoint append/flush failed: ' . $path);
150 return array('status' => 'failed', 'reason' => 'append_flush_failed');
151 }
152 return array('status' => $status, 'reason' => $reason);
153 }
154
155 /**
156 * The emergency record written when the advisory lock never came free.
157 *
158 * Deliberately opens its own descriptor rather than reusing the
159 * request-scoped one: this path exists because the ordinary write path is
160 * stuck, so it must not depend on that path's state. It is also rare by
161 * construction (once per stuck lock), so the open it pays for is not the
162 * per-record cost DiagnosticAppendStream removes.
163 *
164 * @param array{path: string, record: array<string, mixed>, blocked_event: string, wait_us: int} $timeout
165 */
166 private static function appendLockTimeoutRecord(array $timeout): void {
167 $path = $timeout['path'];
168 $blockedRecord = $timeout['record'];
169 $blockedEvent = $timeout['blocked_event'];
170 $waitUs = $timeout['wait_us'];
171 $timeoutRecord = $blockedRecord;
172 $timeoutRecord['event'] = 'lock_timeout';
173 $timeoutRecord['blocked_event'] = $blockedEvent;
174 $timeoutRecord['lock_wait_us'] = $waitUs;
175 $timeoutRecord['lock_timeout_us'] = self::LOCK_WAIT_TIMEOUT_US;
176 $json = json_encode($timeoutRecord, JSON_UNESCAPED_SLASHES);
177 if (!is_string($json)) {
178 self::reportFailure('AJAX checkpoint lock-timeout JSON encoding failed.');
179 return;
180 }
181 $handle = @fopen($path, 'ab');
182 if ($handle === false) {
183 self::reportFailure('AJAX checkpoint lock-timeout journal could not be opened: ' . $path);
184 return;
185 }
186 try {
187 $line = $json . "\n";
188 $written = @fwrite($handle, $line);
189 $flushed = @fflush($handle);
190 if ($written !== strlen($line) || !$flushed) {
191 self::reportFailure('AJAX checkpoint lock-timeout record could not be appended: ' . $path);
192 }
193 } finally {
194 @fclose($handle);
195 }
196 }
197
198 /**
199 * @param array{status: string, reason: string, request_id: string, event: string, started_ns: int} $state
200 * @return array<string, mixed>
201 */
202 private static function result(array $state): array {
203 $result = array(
204 'status' => $state['status'],
205 'request_id' => $state['request_id'],
206 'event' => $state['event'],
207 'elapsed_us' => self::elapsedMicroseconds($state['started_ns']),
208 );
209 if ($state['reason'] !== '') {
210 $result['reason'] = $state['reason'];
211 }
212 return $result;
213 }
214
215 /**
216 * A monotonic nanosecond counter, or 0 when the host has none.
217 *
218 * This measures the writer's OWN cost, so the reading has to be monotonic:
219 * a wall clock can step backwards under NTP and would report a write that
220 * took a full second as instantaneous. hrtime() has been core since PHP
221 * 7.3 and this plugin requires 7.4, so the only way it is missing is an
222 * explicit `disable_functions`. On such a host the honest answer is that
223 * the writer's cost is unmeasurable -- both readings return 0, so
224 * elapsedMicroseconds() reports 0 -- rather than a number fabricated from
225 * a different, non-monotonic time source. Keeping this class free of the
226 * clock service is deliberate: it is the last writer standing when the
227 * rest of the diagnostic stack is what is broken.
228 */
229 private static function monotonicNanoseconds(): int {
230 return function_exists('hrtime') ? (int)hrtime(true) : 0;
231 }
232
233 private static function elapsedMicroseconds(int $startedNs): int {
234 return max(0, (int)round((self::monotonicNanoseconds() - $startedNs) / 1000));
235 }
236
237 private static function reportFailure(string $message): void {
238 abj404_logPhpFallback('ajax-checkpoint', $message);
239 }
240 }
241