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

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

257 lines 11.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 * The detach A/B experiment's evidence, joined and decided server-side (Bruno
9 * timeout cause matrix, gap G9 / gap-hunt iteration 2 Codex gap #2).
10 *
11 * The experiment produces its two halves in two different places and never in
12 * the same record. ABJ_404_Solution_DetachAbExperiment::assignNextAttempt()
13 * decides whether a given table request detaches the connection, and the
14 * response tail journals that decision under the request's own id. Whether
15 * that same request ever COMPLETED is something only the browser can say, and
16 * it says so later, about an earlier attempt, on a different request
17 * (ABJ_404_Solution_ClientTransportReport). So the verdict the whole
18 * experiment exists to produce -- detach causal, transient, neither, or
19 * honestly inconclusive -- lives in neither record.
20 *
21 * Joining them was, until this class, a manual step performed by a human
22 * reading JSONL at analysis time. That is the same manual step that has
23 * already gone wrong once in this investigation, and it is the reason the
24 * decision rule (ABJ_404_Solution_DetachAbVerdict::fromAttempts())
25 * had no production caller at all.
26 *
27 * This class owns the join and nothing else: which mode records belong to one
28 * browser session, which browser verdict resolves each of them, and what the
29 * accounting around that looks like. It owns no decision rule (that stays in
30 * AjaxCanaryLadder, where the pure quadrant logic is already tested), no
31 * transport, and no formatting.
32 *
33 * Three properties are load-bearing rather than defensive:
34 *
35 * 1. Session scoping. The checkpoint journal is site-wide while the A/B
36 * attempt counter is per session and workload scope, so two admin tabs
37 * write independent sequences into one file. Tallying them together
38 * would invent ON/OFF pairs that never existed.
39 * 2. Workload matching. A part and payload fingerprint stay attached to
40 * every attempt, so faster counts requests cannot be paired with slower
41 * table requests and mistaken for a treatment effect.
42 * 3. Unknown is not failure. An attempt the browser has not reported on is
43 * excluded from the tally and counted separately. Treating silence as
44 * "did not complete" would manufacture a detach-causal verdict out of
45 * evidence that has merely not arrived yet, which is worse than no
46 * verdict at all.
47 */
48 final class ABJ_404_Solution_DetachAbEvidence {
49
50 /** The journal event this class writes its decision under. */
51 const VERDICT_EVENT = 'detach_ab_verdict';
52
53 /** The journal event ABJ_404_Solution_AjaxResponseEmitter writes each per-request mode under. */
54 const MODE_EVENT = 'detach_ab_mode';
55
56 /** The verdict was computed from this session's own joined evidence. */
57 const STATUS_COMPUTED = 'computed';
58
59 /** This build does not run the experiment, so there is nothing to decide. */
60 const STATUS_NOT_ARMED = 'not_armed';
61
62 /** The client sent no session id, so no attempt sequence can be scoped. */
63 const STATUS_NO_SESSION = 'no_session';
64
65 /** The journal could not be read or joined; the reason travels with the record. */
66 const STATUS_ERROR = 'error';
67
68 /**
69 * Attempts listed verbatim on the journaled record. One workload scope is
70 * ABJ_404_Solution_DetachAbExperiment::MAX_ATTEMPTS attempts.
71 * This copy has headroom for a restarted scope, but the TALLY is never
72 * bounded by it: only the human-readable copy is, so one long-lived
73 * session cannot turn a decision record into a large one.
74 */
75 const MAX_ATTEMPTS_ON_RECORD = 12;
76
77 /**
78 * The full decision for one browser session, ready to journal.
79 *
80 * Never throws and never returns a partial shape: every field is present
81 * on every path, and `status` says why the verdict is what it is. That is
82 * the same principle the mode record itself already follows -- 'inert' is
83 * recorded rather than skipped, so "the experiment did not run" is
84 * positive evidence rather than an absence a reader has to infer.
85 *
86 * Gated on the same two opt-ins the experiment itself requires (a build
87 * that arms it, and a session id to scope it to), so an ordinary released
88 * install never pays for the journal read: on those builds every mode
89 * record says 'inert' and there is provably nothing to join.
90 *
91 * @return array<string, mixed>
92 */
93 public static function verdictForSession(string $sessionId): array {
94 $sessionKey = ABJ_404_Solution_DetachAbExperiment::sessionKey($sessionId);
95 $record = self::emptyRecord($sessionKey);
96 try {
97 if ($sessionKey === '') {
98 $record['status'] = self::STATUS_NO_SESSION;
99 return $record;
100 }
101 if (!ABJ_404_Solution_DetachAbExperiment::isEnabled()) {
102 $record['status'] = self::STATUS_NOT_ARMED;
103 return $record;
104 }
105 $source = ABJ_404_Solution_CheckpointJournalReader::supportCollectionSource();
106 $lines = ABJ_404_Solution_DiagnosticJournalExcerpt::readAllLines($source['paths']);
107 return self::decide($record, self::attemptsIn($lines, $sessionKey), count($lines));
108 } catch (Throwable $e) {
109 $record['status'] = self::STATUS_ERROR;
110 $record['error'] = substr($e->getMessage(), 0, 200);
111 return $record;
112 }
113 }
114
115 /**
116 * The record every path starts from: a complete shape with a zero-evidence
117 * verdict already in it.
118 *
119 * The verdict is produced by the real rule rather than hand-written as a
120 * literal, so a "nothing to decide" record can never drift out of step with
121 * the shape a decided one has.
122 *
123 * @param string $sessionKey ABJ_404_Solution_DetachAbExperiment::sessionKey().
124 * @return array<string, mixed>
125 */
126 private static function emptyRecord(string $sessionKey): array {
127 return array(
128 'status' => self::STATUS_COMPUTED,
129 'session_key' => $sessionKey,
130 'build_channel' => ABJ_404_Solution_PluginReleaseChannel::currentChannel(),
131 'attempts' => array(),
132 'attempts_with_mode' => 0,
133 'attempts_resolved' => 0,
134 'attempts_unresolved' => 0,
135 'journal_lines_scanned' => 0,
136 'verdict' => ABJ_404_Solution_DetachAbVerdict::fromAttempts(array()),
137 );
138 }
139
140 /**
141 * Apply the decision rule to a completed join and fill in the accounting.
142 *
143 * Only attempts the browser actually resolved reach the rule; the rest are
144 * counted, listed with a null outcome, and otherwise ignored.
145 *
146 * @param array<string, mixed> $record
147 * @param array{attempts: array<int, ABJ_404_Solution_DetachAbAttempt>, unresolved: int,
148 * with_mode: int} $joined
149 * @return array<string, mixed>
150 */
151 private static function decide(array $record, array $joined, int $linesScanned): array {
152 $resolved = array();
153 $onRecord = array();
154 foreach ($joined['attempts'] as $attempt) {
155 if ($attempt->isResolved()) {
156 $resolved[] = $attempt;
157 }
158 if (count($onRecord) < self::MAX_ATTEMPTS_ON_RECORD) {
159 $onRecord[] = $attempt->toJournalArray();
160 }
161 }
162 $record['attempts'] = $onRecord;
163 $record['attempts_with_mode'] = $joined['with_mode'];
164 $record['attempts_resolved'] = count($resolved);
165 $record['attempts_unresolved'] = $joined['unresolved'];
166 $record['journal_lines_scanned'] = $linesScanned;
167 $record['verdict'] = ABJ_404_Solution_DetachAbVerdict::fromAttempts($resolved);
168 return $record;
169 }
170
171 /**
172 * One session's A/B attempts, each paired with the browser's verdict on it.
173 *
174 * Pure and side-effect free: it takes journal lines rather than reading
175 * them, so every join case (a foreign session, an unrecognised mode, a
176 * silent attempt, a duplicate report) is directly assertable without a
177 * journal on disk that happens to contain it.
178 *
179 * Attempt outcomes come from ABJ_404_Solution_DiagnosticClientVerdict, the
180 * one extractor that already resolves a browser report to the journal key
181 * its attempt was recorded under. Deriving a second keying here is exactly
182 * the defect that made the browser's verdicts land on orphan placeholder
183 * groups for an entire release.
184 *
185 * @param array<int, string> $lines JSONL lines, oldest first.
186 * @param string $sessionKey ABJ_404_Solution_DetachAbExperiment::sessionKey().
187 * @return array{attempts: array<int, ABJ_404_Solution_DetachAbAttempt>, unresolved: int,
188 * with_mode: int}
189 */
190 public static function attemptsIn(array $lines, string $sessionKey): array {
191 $parsed = self::modesInSession($lines, $sessionKey);
192 $outcomes = ABJ_404_Solution_DiagnosticClientVerdict::reportedOutcomesIn($lines);
193
194 $attempts = array();
195 $unresolved = 0;
196 foreach ($parsed as $requestId => $attempt) {
197 $requestId = (string)$requestId;
198 $resolved = array_key_exists($requestId, $outcomes);
199 if (!$resolved) {
200 $unresolved++;
201 }
202 // null, never false: "the browser has not said" and "the browser
203 // said it failed" are opposite findings, and only one of them
204 // belongs in the tally.
205 $attempts[] = $attempt->withOutcome($resolved ? (bool)$outcomes[$requestId] : null);
206 }
207 return array('attempts' => $attempts, 'unresolved' => $unresolved, 'with_mode' => count($parsed));
208 }
209
210 /**
211 * Request id to parsed attempt for one session, in journal order.
212 *
213 * This class owns which records belong to the session; what one record
214 * MEANS belongs to ABJ_404_Solution_DetachAbAttempt, which is why the
215 * mode vocabulary and the ordinal parse are not repeated here. Records
216 * that are not measurements (MODE_INERT, MODE_DEFAULT) are dropped by that
217 * parse rather than by a second copy of the rule.
218 *
219 * @param array<int, string> $lines
220 * @return array<string, ABJ_404_Solution_DetachAbAttempt>
221 */
222 private static function modesInSession(array $lines, string $sessionKey): array {
223 $modes = array();
224 foreach ($lines as $line) {
225 // Lines that cannot possibly match are rejected before the JSON
226 // decoder sees them: this pass runs over whole journals, and the
227 // mode records are a few dozen lines out of tens of thousands.
228 if (strpos($line, self::MODE_EVENT) === false) {
229 continue;
230 }
231 $record = json_decode($line, true);
232 if (!is_array($record) || ($record['event'] ?? '') !== self::MODE_EVENT) {
233 continue;
234 }
235 if (self::scalarField($record, 'session_key') !== $sessionKey) {
236 continue;
237 }
238 $attempt = ABJ_404_Solution_DetachAbAttempt::fromJournalRecord($record);
239 if ($attempt === null) {
240 continue;
241 }
242 $modes[$attempt->requestId()] = $attempt;
243 }
244 return $modes;
245 }
246
247 /**
248 * One record field as a string, or '' when it is absent or not scalar.
249 *
250 * @param array<array-key, mixed> $record
251 */
252 private static function scalarField(array $record, string $field): string {
253 $value = $record[$field] ?? null;
254 return is_scalar($value) ? (string)$value : '';
255 }
256 }
257