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

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

302 lines 11.7 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 * Select diagnostic journal files without dropping known failure evidence.
9 *
10 * Candidate files are ordered as one oldest-first stream. Request lifecycle
11 * state is folded across EVERY candidate before any file is capped, so a
12 * request whose start and terminal records landed in different files is still
13 * classified once. Files that contain a known or server-detected failure are
14 * pinned. Files that cannot be classified safely are pinned too and carry a
15 * bounded reason in the primary support POST. Only proven ordinary context is
16 * subject to the recent-file cap.
17 *
18 * This class scans files but never reads an excerpt or ranks records. Those
19 * remain ABJ_404_Solution_DiagnosticJournalExcerpt's responsibility.
20 *
21 * // allow-no-test-found: covered through the real support AJAX and primary POST in SupportRequestAjaxTest
22 */
23 final class ABJ_404_Solution_DiagnosticJournalFileSelector {
24
25 /** Ordinary recent files retained after known-failure files are pinned. */
26 const MAX_RECENT_FILES = 8;
27
28 /** Detail bounds for the primary-POST file-selection manifest. */
29 const MAX_MANIFEST_DROPPED_FILE_NAMES = 32;
30 const MAX_MANIFEST_DROPPED_REQUEST_IDS = 128;
31 const MAX_MANIFEST_CLASSIFICATION_ISSUES = 32;
32
33 /**
34 * Choose files only after identifying which ones hold failure evidence.
35 *
36 * All known-failure, server-failure, and unclassifiable files are pinned,
37 * even when there are more than MAX_RECENT_FILES of them. The remaining
38 * allowance is filled with the newest proven-ordinary files.
39 *
40 * @param array<int, string> $paths Candidate files in the caller's oldest-first order.
41 * @param array<string, bool> $knownFailingIds Cross-journal failed request IDs.
42 * @return array{paths: array<int, string>, manifest: array<string, mixed>}
43 */
44 public static function select(array $paths, array $knownFailingIds = array()): array {
45 $files = self::classifyFiles($paths, $knownFailingIds);
46 $knownFailureFiles = 0;
47 $serverFailureFiles = 0;
48 $classificationIssueFiles = 0;
49 $pinnedFiles = 0;
50 foreach ($files as $file) {
51 if ($file['knownFailure']) {
52 $knownFailureFiles++;
53 }
54 if ($file['serverFailure']) {
55 $serverFailureFiles++;
56 }
57 if ($file['classificationIssues'] !== array()) {
58 $classificationIssueFiles++;
59 }
60 if ($file['pinned']) {
61 $pinnedFiles++;
62 }
63 }
64 $selectedIndexes = self::selectedIndexes($files, $pinnedFiles);
65 return self::selectionResult(
66 $files,
67 $selectedIndexes,
68 $knownFailureFiles,
69 $serverFailureFiles,
70 $classificationIssueFiles,
71 $pinnedFiles
72 );
73 }
74
75 /**
76 * @param array<int, string> $paths
77 * @param array<string, bool> $knownFailingIds
78 * @return array<int, array{path: string, modified: int, order: int,
79 * requestIds: array<int, string>, knownFailure: bool, serverFailure: bool,
80 * classificationIssues: array<int, string>, pinned: bool}>
81 */
82 private static function classifyFiles(array $paths, array $knownFailingIds): array {
83 $files = self::oldestFirstExistingFiles($paths);
84 $requestGroups = array();
85 foreach ($files as $index => $file) {
86 $facts = self::requestFactsInFile(
87 $file['path'],
88 $knownFailingIds,
89 $requestGroups
90 );
91 $files[$index]['requestIds'] = $facts['requestIds'];
92 $files[$index]['knownFailure'] = $facts['knownFailure'];
93 $files[$index]['classificationIssues'] = $facts['classificationIssues'];
94 }
95
96 $serverFailingIds = array();
97 foreach ($requestGroups as $requestId => $group) {
98 if ($group->isFailing()) {
99 $serverFailingIds[(string)$requestId] = true;
100 }
101 }
102 foreach ($files as $index => $file) {
103 $serverFailure = false;
104 foreach ($file['requestIds'] as $requestId) {
105 if (isset($serverFailingIds[$requestId])) {
106 $serverFailure = true;
107 break;
108 }
109 }
110 $files[$index]['serverFailure'] = $serverFailure;
111 $files[$index]['pinned'] = $file['knownFailure']
112 || $serverFailure
113 || $file['classificationIssues'] !== array();
114 }
115 return $files;
116 }
117
118 /**
119 * @param array<int, array{pinned: bool}> $files
120 * @return array<int, bool>
121 */
122 private static function selectedIndexes(array $files, int $pinnedFiles): array {
123 $ordinarySlots = max(0, self::MAX_RECENT_FILES - $pinnedFiles);
124 $ordinaryIndexes = array();
125 foreach ($files as $index => $file) {
126 if (!$file['pinned']) {
127 $ordinaryIndexes[] = $index;
128 }
129 }
130 $keptOrdinary = $ordinarySlots === 0
131 ? array() : array_slice($ordinaryIndexes, -$ordinarySlots);
132 $selectedIndexes = array_fill_keys($keptOrdinary, true);
133 foreach ($files as $index => $file) {
134 if ($file['pinned']) {
135 $selectedIndexes[$index] = true;
136 }
137 }
138 return $selectedIndexes;
139 }
140
141 /**
142 * @param array<int, array{path: string, requestIds: array<int, string>,
143 * classificationIssues: array<int, string>}> $files
144 * @param array<int, bool> $selectedIndexes
145 * @return array{paths: array<int, string>, manifest: array<string, mixed>}
146 */
147 private static function selectionResult(
148 array $files,
149 array $selectedIndexes,
150 int $knownFailureFiles,
151 int $serverFailureFiles,
152 int $classificationIssueFiles,
153 int $pinnedFiles
154 ): array {
155 $selectedPaths = array();
156 $droppedNames = array();
157 $droppedRequestIds = array();
158 $seenDroppedRequestIds = array();
159 $classificationIssues = array();
160 foreach ($files as $index => $file) {
161 if ($file['classificationIssues'] !== array()) {
162 $classificationIssues[] = array(
163 'name' => basename($file['path']),
164 'reasons' => $file['classificationIssues'],
165 );
166 }
167 if (isset($selectedIndexes[$index])) {
168 $selectedPaths[] = $file['path'];
169 continue;
170 }
171 $droppedNames[] = basename($file['path']);
172 foreach ($file['requestIds'] as $requestId) {
173 if (!isset($seenDroppedRequestIds[$requestId])) {
174 $seenDroppedRequestIds[$requestId] = true;
175 $droppedRequestIds[] = $requestId;
176 }
177 }
178 }
179
180 return array(
181 'paths' => $selectedPaths,
182 'manifest' => array(
183 'policy' => 'failure_and_uncertain_evidence_then_newest',
184 'max_recent_files' => self::MAX_RECENT_FILES,
185 'existing_files' => count($files),
186 'selected_files' => count($selectedPaths),
187 'known_failure_files' => $knownFailureFiles,
188 'server_failure_files' => $serverFailureFiles,
189 'classification_issue_files' => $classificationIssueFiles,
190 'pinned_files' => $pinnedFiles,
191 'classification_issues' => array_slice(
192 $classificationIssues, 0, self::MAX_MANIFEST_CLASSIFICATION_ISSUES),
193 'classification_issues_omitted' => max(
194 0, count($classificationIssues) - self::MAX_MANIFEST_CLASSIFICATION_ISSUES),
195 'dropped_files' => count($droppedNames),
196 'dropped_file_names' => array_slice(
197 $droppedNames, 0, self::MAX_MANIFEST_DROPPED_FILE_NAMES),
198 'dropped_file_names_omitted' => max(
199 0, count($droppedNames) - self::MAX_MANIFEST_DROPPED_FILE_NAMES),
200 'dropped_request_ids' => array_slice(
201 $droppedRequestIds, 0, self::MAX_MANIFEST_DROPPED_REQUEST_IDS),
202 'dropped_request_ids_omitted' => max(
203 0, count($droppedRequestIds) - self::MAX_MANIFEST_DROPPED_REQUEST_IDS),
204 ),
205 );
206 }
207
208 /**
209 * Existing paths ordered by mtime, with caller order breaking ties.
210 *
211 * @param array<int, string> $paths
212 * @return array<int, array{path: string, modified: int, order: int}>
213 */
214 private static function oldestFirstExistingFiles(array $paths): array {
215 $files = array();
216 foreach (array_values($paths) as $order => $path) {
217 $modified = @filemtime($path);
218 if (is_int($modified)) {
219 $files[] = array('path' => $path, 'modified' => $modified, 'order' => $order);
220 }
221 }
222 usort($files, static function (array $left, array $right): int {
223 if ($left['modified'] === $right['modified']) {
224 return $left['order'] <=> $right['order'];
225 }
226 return $left['modified'] <=> $right['modified'];
227 });
228 return $files;
229 }
230
231 /**
232 * Fold one file into the shared request lifecycle index.
233 *
234 * @param array<string, bool> $knownFailingIds
235 * @param array<string, ABJ_404_Solution_DiagnosticRequestGroup> $requestGroups
236 * @return array{requestIds: array<int, string>, knownFailure: bool,
237 * classificationIssues: array<int, string>}
238 */
239 private static function requestFactsInFile(
240 string $path,
241 array $knownFailingIds,
242 array &$requestGroups
243 ): array {
244 $handle = @fopen($path, 'rb');
245 if ($handle === false) {
246 self::reportFailure('Diagnostic journal could not be scanned: ' . $path);
247 return array(
248 'requestIds' => array(),
249 'knownFailure' => false,
250 'classificationIssues' => array('unreadable'),
251 );
252 }
253 $requestIds = array();
254 $knownFailure = false;
255 $classificationIssues = array();
256 $nonEmptyLines = 0;
257 try {
258 while (($line = @fgets($handle)) !== false) {
259 if (trim($line) === '') {
260 continue;
261 }
262 $nonEmptyLines++;
263 $record = json_decode($line, true);
264 if (!is_array($record)) {
265 $classificationIssues['invalid_json'] = true;
266 continue;
267 }
268 $requestId = is_scalar($record['request_id'] ?? null)
269 ? (string)$record['request_id']
270 : '';
271 if ($requestId === '') {
272 $classificationIssues['missing_request_id'] = true;
273 continue;
274 }
275 $requestIds[$requestId] = true;
276 if (isset($knownFailingIds[$requestId])) {
277 $knownFailure = true;
278 }
279 if (!isset($requestGroups[$requestId])) {
280 $requestGroups[$requestId] =
281 new ABJ_404_Solution_DiagnosticRequestGroup($requestId, true);
282 }
283 $requestGroups[$requestId]->applyRecord($record);
284 }
285 } finally {
286 @fclose($handle);
287 }
288 if ($nonEmptyLines === 0) {
289 $classificationIssues['no_records'] = true;
290 }
291 return array(
292 'requestIds' => array_keys($requestIds),
293 'knownFailure' => $knownFailure,
294 'classificationIssues' => array_keys($classificationIssues),
295 );
296 }
297
298 private static function reportFailure(string $message): void {
299 abj404_logPhpFallback('ajax-trace', $message);
300 }
301 }
302