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

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

380 lines 17.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 * Read side of the AJAX checkpoint journal: everything the support-collection
9 * pipeline is served from it.
10 *
11 * Split out of ABJ_404_Solution_AjaxCheckpointLogger, which is now the
12 * checkpoint lifecycle owner and nothing else. The dependency stays
13 * one-directional by design: this reader discovers early intents through
14 * CheckpointIntentStore and resolves the normal journal through the logger;
15 * neither writer calls back into the reader, so a bug in support collection
16 * cannot take checkpoint recording down with it.
17 *
18 * Consumers: ABJ_404_Solution_SupportEvidenceExcerpt (the bounded support
19 * payload), ABJ_404_Solution_DetachAbEvidence (verdicts read straight off the
20 * journal), and ABJ_404_Solution_DeveloperLogMailer (the unbounded archive).
21 */
22 final class ABJ_404_Solution_CheckpointJournalReader {
23
24 /** Recorder calls slower than this keep their full phase map in support. */
25 const RECORDER_PHASE_DETAIL_THRESHOLD_US = 5000;
26
27 /**
28 * Share of the support payload's excerpt field this journal may claim.
29 *
30 * Sized against a measured session, not chosen for tidiness: one table
31 * request costs 26-27 records, so 32 KB (the previous value, further
32 * halved by an even per-file split) bought about ONE request while a
33 * failing session is six failing attempts plus a canary ladder plus polls.
34 * The 18 KB above that base used the excerpt contract's remaining section
35 * headroom plus 16 KB reallocated from the generic debug-log tail, so eight
36 * bounded hook/cache activity samples fit across every prioritized attempt
37 * without eliding a failing request's last pre-stall boundary; 4 KB of it
38 * was then reallocated to the per-failing-session diagnostics block
39 * (ABJ_404_Solution_FailingSessionSupportSection::MAX_FAILING_SESSION_DIAG_BYTES),
40 * a computed conclusion about the correct session that is worth more than
41 * the raw checkpoint tail it replaces. The receipt reconstruction section
42 * is funded from the generic sanitized log tail instead: reducing this
43 * checkpoint floor by another 4 KB evicts the first failing request in the
44 * measured worst-case session. A further 3 KB then funded the
45 * stranded-request block
46 * (ABJ_404_Solution_StrandedRequestSupportSection::MAX_STRANDED_DIAG_BYTES),
47 * which is the one section that CANNOT be funded from a journal: it is a
48 * reading of live registry state, so unlike every byte spent here it cannot
49 * be rotated or elided away before the admin clicks send. A further 3 KB
50 * then funded the canonical hook census
51 * (ABJ_404_Solution_CanonicalSuppressionSupportSection::MAX_CANONICAL_SUPPRESSION_BYTES),
52 * funded here for the same reason: it is a front-end reading of live hook
53 * state, recorded once and never re-derivable from any journal, and it is
54 * the difference between answering "what suppressed canonicalization on
55 * your site" out of the report and writing to the site owner to ask them to
56 * go run a command. The remaining
57 * budget is still far above the whole-failing-session floor
58 * SupportExcerptBudgetContractTest pins, and the per-section budgets are
59 * proven to sum inside the report contract by that same test.
60 */
61 const MAX_SUPPORT_EXCERPT_BYTES = 139264;
62
63 /**
64 * Bounded recent checkpoint lines for the support-request payload.
65 *
66 * Without this the checkpoints are written and never read by anyone: the
67 * support payload carried only the stage trace, so a request that died
68 * BEFORE its first stage -- the exact beta.1 failure -- reached the
69 * developer as an empty excerpt. Every pre-stage boundary (auth, rate
70 * limit, trace construction, service resolution) and every post-stage
71 * boundary (encode, echo, each ob close, flush, finish-request, exit) is
72 * recorded only here, so this is the channel that makes "nothing after
73 * authorized" a readable fact instead of an absence.
74 *
75 * The rotated file is included: a session busy enough to rotate is a
76 * session whose oldest evidence is still the most interesting.
77 *
78 * @param array<string, bool> $knownFailingIds Requests condemned across every
79 * journal, so this excerpt and the stage-trace one rank identically. This
80 * journal already contains its own verdicts; passing the union in is what
81 * makes the two agree rather than each ranking off what it happens to hold.
82 * @param array{paths: array<int, string>, manifest: array<string, mixed>}|null $fileSelection
83 * Shared selection plan used by the primary support manifest.
84 */
85 public static function readRecentForSupport(
86 array $knownFailingIds = array(),
87 ?array $fileSelection = null
88 ): string {
89 $source = self::supportCollectionSource();
90 $paths = $source['paths'];
91 if ($paths === array()) {
92 return '';
93 }
94 $required = self::requiredSupportEvidence($paths);
95 $requiredBlock = $required === ''
96 ? ''
97 : "Required AJAX checkpoint evidence (JSONL):\n" . $required;
98 $rankedBudget = self::MAX_SUPPORT_EXCERPT_BYTES
99 - ($requiredBlock === '' ? 0 : strlen($requiredBlock) + 1);
100 $activePath = ABJ_404_Solution_DurableOperationRecorder::activePath(
101 $source['directory']
102 );
103 $rankedPaths = array_values(array_filter(
104 $paths,
105 static fn(string $path): bool => $path !== $activePath
106 ));
107 $rankedSelection = self::withoutPathFromSelection($fileSelection, $activePath);
108 $activeLines = $activePath === '' ? array()
109 : ABJ_404_Solution_ActiveOperationBreadcrumbs::compactSupportLines(
110 ABJ_404_Solution_DiagnosticJournalExcerpt::readAllLines(array($activePath))
111 );
112 $activeClosedIds =
113 ABJ_404_Solution_CheckpointIntentCorrelation::closedCheckpointIds($activeLines);
114 $ranked = ABJ_404_Solution_DiagnosticJournalExcerpt::compose(
115 $rankedPaths,
116 max(0, $rankedBudget),
117 "Recent AJAX request checkpoints (JSONL):\n",
118 $knownFailingIds,
119 static function (array $lines) use ($activeClosedIds): array {
120 return self::compactForSupport($lines, $activeClosedIds);
121 },
122 $rankedSelection
123 );
124 if ($requiredBlock === '') {
125 return $ranked;
126 }
127 return $ranked === '' ? $requiredBlock : $requiredBlock . "\n" . $ranked;
128 }
129
130 /**
131 * Latest complete record of each evidence type that must bypass ordinary
132 * request-group ranking.
133 *
134 * The selector owns the record schemas. This reader owns byte accounting
135 * and applies the same support compaction used by the ranked remainder.
136 *
137 * @param array<int, string> $paths
138 */
139 private static function requiredSupportEvidence(array $paths): string {
140 $lines = ABJ_404_Solution_DurableOperationRecorder::compactSupportLines(
141 ABJ_404_Solution_DiagnosticJournalExcerpt::readAllLines($paths)
142 );
143 $required = ABJ_404_Solution_RequiredCheckpointEvidence::select($lines);
144 return implode("\n", self::compactRoutinePhaseMaps($required));
145 }
146
147 /**
148 * The active-state file is reserved in full, so ranking it again would
149 * duplicate culprits and spend the lifecycle budget twice.
150 *
151 * @param array{paths: array<int, string>, manifest: array<string, mixed>}|null $selection
152 * @return array{paths: array<int, string>, manifest: array<string, mixed>}|null
153 */
154 private static function withoutPathFromSelection(?array $selection, string $excludedPath): ?array {
155 if ($selection === null || $excludedPath === '') {
156 return $selection;
157 }
158 $selection['paths'] = array_values(array_filter(
159 is_array($selection['paths'] ?? null) ? $selection['paths'] : array(),
160 static fn(string $path): bool => $path !== $excludedPath
161 ));
162 if (is_array($selection['manifest'] ?? null)) {
163 $selection['manifest']['selected_files'] = count($selection['paths']);
164 }
165 return $selection;
166 }
167
168 /**
169 * Drop only intents whose exact checkpoint_id has a terminal non-intent record.
170 * Unmatched and malformed intents remain: they are the evidence that
171 * enrichment or its final append never completed. Every total call cost
172 * remains. The excerpt keeps every slow/failed phase map plus the single
173 * slowest baseline in the session; routine maps are removed only from the
174 * bounded excerpt, never from the durable journal/archive.
175 *
176 * @param array<int, string> $lines
177 * @param array<string, bool> $additionalClosedIds Exact terminal records
178 * reserved outside the ranked lines, such as active-operation state.
179 * @return array<int, string>
180 */
181 private static function compactForSupport(array $lines, array $additionalClosedIds = array()): array {
182 $lines = ABJ_404_Solution_DurableOperationRecorder::compactSupportLines($lines);
183 $withoutClosedIntents = ABJ_404_Solution_CheckpointIntentCorrelation::withoutClosedIntents(
184 $lines,
185 array_merge(
186 ABJ_404_Solution_CheckpointIntentCorrelation::closedCheckpointIds($lines),
187 $additionalClosedIds
188 )
189 );
190 return self::compactRoutinePhaseMaps(
191 ABJ_404_Solution_CheckpointIntentCorrelation::withoutKeyedIntents(
192 $withoutClosedIntents
193 )
194 );
195 }
196
197 /**
198 * @param array<int, string> $lines
199 */
200 private static function slowestTelemetryIndex(array $lines): int {
201 $slowestIndex = -1;
202 $slowestTotalUs = -1;
203 foreach ($lines as $index => $line) {
204 $record = json_decode($line, true);
205 if (!is_array($record)) {
206 continue;
207 }
208 $previous = $record['previous_checkpoint_write'] ?? null;
209 $totalUs = is_array($previous) && is_numeric($previous['total_us'] ?? null)
210 ? (int)$previous['total_us'] : -1;
211 if ($totalUs > $slowestTotalUs) {
212 $slowestIndex = $index;
213 $slowestTotalUs = $totalUs;
214 }
215 }
216 return $slowestIndex;
217 }
218
219 /**
220 * @param array<int, string> $lines
221 * @return array<int, string>
222 */
223 private static function compactRoutinePhaseMaps(array $lines): array {
224 $slowestIndex = self::slowestTelemetryIndex($lines);
225 $decodedByIndex = array();
226 $hostPressureSnapshotByRequest = array();
227 foreach ($lines as $index => $line) {
228 $record = json_decode($line, true);
229 $decodedByIndex[$index] = $record;
230 }
231 foreach ($decodedByIndex as $index => $record) {
232 if (!is_array($record)) {
233 continue;
234 }
235 if (($record['event'] ?? '') === 'query_probe') {
236 // Support needs source + shape hash, not even redacted SQL
237 // text. This also keeps one unmatched probe per failed
238 // request from displacing that request's lifecycle.
239 unset($record['sql']);
240 }
241 $record = ABJ_404_Solution_HostPressureSampler::compactRepeatedHostPressureSnapshots(
242 $record,
243 $hostPressureSnapshotByRequest
244 );
245 if (!is_array($record['previous_checkpoint_write'] ?? null)) {
246 $encoded = json_encode($record, JSON_UNESCAPED_SLASHES);
247 if (is_string($encoded)) {
248 $lines[$index] = $encoded;
249 }
250 continue;
251 }
252 $previous = $record['previous_checkpoint_write'];
253 $totalUs = is_numeric($previous['total_us'] ?? null)
254 ? (int)$previous['total_us'] : -1;
255 $isSlowest = $slowestIndex === $index;
256 $isSlow = $totalUs >= self::RECORDER_PHASE_DETAIL_THRESHOLD_US;
257 $failed = ($previous['status'] ?? '') !== 'complete'
258 || (($previous['intent_status'] ?? 'complete') !== 'complete');
259 if (!$isSlowest && !$isSlow && !$failed && isset($previous['phases_us'])) {
260 unset($record['previous_checkpoint_write']['phases_us']);
261 }
262 // Closed intents are gone at this point, so their correlation IDs
263 // have completed their only job. Keep IDs on unmatched intents,
264 // but do not spend bounded support bytes repeating them here.
265 unset($record['checkpoint_id']);
266 unset($record['previous_checkpoint_write']['checkpoint_id']);
267 $encoded = json_encode($record, JSON_UNESCAPED_SLASHES);
268 if (is_string($encoded)) {
269 $lines[$index] = $encoded;
270 }
271 }
272 return $lines;
273 }
274
275 /**
276 * What readRecentForSupport() will look at, whether or not any of it
277 * exists, for ABJ_404_Solution_DiagnosticCollectionManifest. The candidate
278 * list is the reader's own, so the manifest can never describe a different
279 * set of files than the one that was actually read.
280 *
281 * The directory is reported even when it turned out to be unusable: which
282 * path this channel tried is exactly the fact a wrong-node or unwritable
283 * uploads directory is diagnosed from.
284 *
285 * The fixed fallback paths remain available even when the trace directory
286 * cannot be resolved or created.
287 *
288 * @return array{channel: string, directory: string, usable: bool, paths: array<int, string>}
289 */
290 public static function supportCollectionSource(): array {
291 $fallbackPaths = class_exists('ABJ_404_Solution_CheckpointIntentStore')
292 ? ABJ_404_Solution_CheckpointIntentStore::paths()
293 : array();
294 try {
295 $directory = self::journalDirectory();
296 $journalUsable = $directory !== '';
297 $paths = $journalUsable
298 ? array_merge(
299 $fallbackPaths,
300 self::supportExcerptPaths($directory),
301 class_exists('ABJ_404_Solution_ActiveOperationBreadcrumbs')
302 ? array(ABJ_404_Solution_ActiveOperationBreadcrumbs::path($directory))
303 : array()
304 )
305 : $fallbackPaths;
306 return array(
307 'channel' => 'ajax_checkpoints',
308 'directory' => $journalUsable ? $directory
309 : ABJ_404_Solution_AjaxCheckpointLogger::resolveDirectoryPath(),
310 'usable' => $journalUsable || $fallbackPaths !== array(),
311 'paths' => array_values(array_unique($paths)),
312 );
313 } catch (Throwable $e) {
314 self::reportFailure('AJAX checkpoint support source resolution failed: ' . $e->getMessage());
315 return array('channel' => 'ajax_checkpoints', 'directory' => '',
316 'usable' => $fallbackPaths !== array(), 'paths' => $fallbackPaths);
317 }
318 }
319
320 /**
321 * Rotated file then current journal: oldest first, the order the excerpt
322 * reader breaks mtime ties on. File names come from the writer, the one
323 * owner of the journal's on-disk contract.
324 *
325 * @param string $directory With a trailing separator.
326 * @return array<int, string>
327 */
328 private static function supportExcerptPaths(string $directory): array {
329 return array(
330 $directory . ABJ_404_Solution_CheckpointJournalWriter::ROTATED_FILE,
331 $directory . ABJ_404_Solution_CheckpointJournalWriter::CHECKPOINT_FILE,
332 );
333 }
334
335 /**
336 * Existing journal files, for a channel that carries them WHOLE.
337 *
338 * The support excerpt is bounded by a byte budget and a ranking, and a
339 * budget decision must never again be the single point of loss for a
340 * session we only get once. The developer log archive has no such bound,
341 * so it carries both journals in full alongside the debug logs.
342 *
343 * @return array<int, string>
344 */
345 public static function supportArchivePaths(): array {
346 $directory = self::journalDirectory();
347 if ($directory === '') {
348 return array();
349 }
350 $paths = array();
351 foreach (array(ABJ_404_Solution_CheckpointJournalWriter::CHECKPOINT_FILE,
352 ABJ_404_Solution_CheckpointJournalWriter::ROTATED_FILE) as $name) {
353 if (@is_file($directory . $name)) {
354 $paths[] = $directory . $name;
355 }
356 }
357 return $paths;
358 }
359
360 /**
361 * The journal directory via the writer's resolution, or '' when the
362 * writer class itself is unreachable. A corrupt install can be missing
363 * any plugin file (the safe autoloader returns silently for a missing
364 * class; see the error-18 work), and the read side must degrade to
365 * "nothing to read" rather than fatal the support request.
366 */
367 private static function journalDirectory(): string {
368 return class_exists('ABJ_404_Solution_AjaxCheckpointLogger')
369 ? ABJ_404_Solution_AjaxCheckpointLogger::resolveDirectory()
370 : '';
371 }
372
373 private static function reportFailure(string $message): void {
374 // Unconditional: abj404_logPhpFallback() is defined at plugin entry
375 // (404-solution.php), before any class here can be autoloaded, so a
376 // raw error_log() second sink would be unreachable dead weight.
377 abj404_logPhpFallback('ajax-checkpoint', $message);
378 }
379 }
380