| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* The concurrent-control browser receipt: how it is journaled, and what makes |
| 9 |
* one complete enough to be believed. |
| 10 |
* |
| 11 |
* The concurrent control is a canary step launched BESIDE the first real table |
| 12 |
* attempt, under the same host conditions, so a table failure can be separated |
| 13 |
* from a host that was struggling for everyone at that instant. That only works |
| 14 |
* if the browser's account of the control comes back, and the browser can only |
| 15 |
* send it on a LATER request -- so the record arrives carried by one request, |
| 16 |
* describes a second, and is filed under a third. |
| 17 |
* |
| 18 |
* Those three joins, plus the browser's own observation, are what |
| 19 |
* `isCompleteJournalRecord()` checks. It is a contract with two consumers |
| 20 |
* outside this file (ABJ_404_Solution_DiagnosticCollectionManifest decides from |
| 21 |
* it whether required evidence is available; |
| 22 |
* ABJ_404_Solution_CanaryReceiptEvidence decides from it whether a ladder run |
| 23 |
* can be interpreted), which is why the record owns its own class rather than |
| 24 |
* living inside ABJ_404_Solution_ClientTransportReport -- that class reads and |
| 25 |
* bounds whatever the browser sent, and this one knows what this particular |
| 26 |
* record means. |
| 27 |
*/ |
| 28 |
final class ABJ_404_Solution_ConcurrentControlReceipt { |
| 29 |
|
| 30 |
/** The browser's own name for this report kind, on the wire. */ |
| 31 |
const KIND = 'concurrent_control_browser_receipt'; |
| 32 |
|
| 33 |
/** The event name it is journaled under. */ |
| 34 |
const JOURNAL_EVENT = 'concurrent_control_client_receipt'; |
| 35 |
|
| 36 |
/** |
| 37 |
* The overlap-state vocabulary, as the browser spells it. |
| 38 |
* |
| 39 |
* A wire vocabulary shared with view_updater_concurrent_control_evidence.js, |
| 40 |
* so it gets one definition here and ConcurrentControlReceiptTest pins these |
| 41 |
* values against that file. Read as bare strings in two separate PHP files, |
| 42 |
* the set was drift-prone in the worst direction: a renamed state does not |
| 43 |
* error, it silently makes every receipt incomplete, and the support payload |
| 44 |
* then reports "no concurrent control" for a run that had one. |
| 45 |
*/ |
| 46 |
const OVERLAP_COMPUTED = 'computed'; |
| 47 |
const OVERLAP_UNAVAILABLE = 'unavailable'; |
| 48 |
const OVERLAP_PENDING = 'pending'; |
| 49 |
|
| 50 |
/** |
| 51 |
* Every state the browser may report. PENDING is deliberately absent from |
| 52 |
* the accepted set in hasBrowserEvidence(): the table had not settled when |
| 53 |
* the receipt was built, so there is no overlap to reason about yet. |
| 54 |
* |
| 55 |
* @return array<int, string> |
| 56 |
*/ |
| 57 |
public static function everyOverlapState(): array { |
| 58 |
return array(self::OVERLAP_COMPUTED, self::OVERLAP_UNAVAILABLE, self::OVERLAP_PENDING); |
| 59 |
} |
| 60 |
|
| 61 |
/** |
| 62 |
* File the receipt beside that control's own server trace while retaining |
| 63 |
* which later request delivered it. |
| 64 |
* |
| 65 |
* The browser session is stamped on the record for the same reason the |
| 66 |
* canary step receipts carry it (see |
| 67 |
* Ajax_CanaryLadder::journalPriorStepReceipts): the reconstruction that |
| 68 |
* consumes this record must not have to resolve the session through a |
| 69 |
* second, separately-armed journal that can be empty. Hashed into the |
| 70 |
* `session_key` this journal already uses for `detach_ab_mode`, because |
| 71 |
* equality is the whole requirement. |
| 72 |
* |
| 73 |
* @param array{carrierRequestId: string, sessionId: string, report: array<string, mixed>} $receipt |
| 74 |
* One keyed bag rather than two adjacent strings. Both are opaque |
| 75 |
* identifiers of the same type, and swapping them type-checks: the record |
| 76 |
* would then be filed under the session, `carried_by` would name the |
| 77 |
* session, and `session_key` would be a perfectly valid hash of a request |
| 78 |
* ID. Every join in the record points somewhere plausible and wrong, and |
| 79 |
* because the joins are what the reconstruction reads, nothing downstream |
| 80 |
* can tell. `sessionId` is already bounded by |
| 81 |
* AjaxRequestLedger::readFields(). |
| 82 |
*/ |
| 83 |
public static function journal(array $receipt): void { |
| 84 |
$carrierRequestId = $receipt['carrierRequestId']; |
| 85 |
$report = $receipt['report']; |
| 86 |
$controlRequestId = self::ledgerIdOrEmpty($report['controlRequestId'] ?? ''); |
| 87 |
$controlForRequestId = self::ledgerIdOrEmpty($report['controlForRequestId'] ?? ''); |
| 88 |
ABJ_404_Solution_AjaxCheckpointLogger::record( |
| 89 |
$controlRequestId !== '' ? $controlRequestId : $carrierRequestId, |
| 90 |
self::JOURNAL_EVENT, |
| 91 |
array( |
| 92 |
'carried_by' => $carrierRequestId, |
| 93 |
'control_for_request_id' => $controlForRequestId, |
| 94 |
'control_request_id' => $controlRequestId, |
| 95 |
'session_key' => ABJ_404_Solution_DetachAbExperiment::sessionKey($receipt['sessionId']), |
| 96 |
'report' => $report, |
| 97 |
) |
| 98 |
); |
| 99 |
} |
| 100 |
|
| 101 |
/** |
| 102 |
* Is this raw browser report a concurrent-control receipt at all? |
| 103 |
* |
| 104 |
* @param array<mixed, mixed> $report |
| 105 |
*/ |
| 106 |
public static function isBrowserReceipt(array $report): bool { |
| 107 |
return ($report['kind'] ?? '') === self::KIND; |
| 108 |
} |
| 109 |
|
| 110 |
/** |
| 111 |
* Whether a journal record is complete enough to serve as the required |
| 112 |
* concurrent-control evidence in a support payload. |
| 113 |
* |
| 114 |
* Both halves must hold: the JOURNAL joins (this record can be tied to the |
| 115 |
* control request, its carrier, and the request it was a control FOR) and |
| 116 |
* the BROWSER evidence (the browser actually observed an outcome and an |
| 117 |
* overlap). A record with joins and no observation describes a probe that |
| 118 |
* ran and told us nothing, and counting it as evidence would let the |
| 119 |
* support payload claim a control it does not have. |
| 120 |
* |
| 121 |
* @param array<mixed, mixed> $record |
| 122 |
*/ |
| 123 |
public static function isCompleteJournalRecord(array $record): bool { |
| 124 |
$report = is_array($record['report'] ?? null) ? $record['report'] : array(); |
| 125 |
return self::hasJournalJoins($record) && self::hasBrowserEvidence($report); |
| 126 |
} |
| 127 |
|
| 128 |
/** @param array<mixed, mixed> $record */ |
| 129 |
private static function hasJournalJoins(array $record): bool { |
| 130 |
return ($record['envelope'] ?? '') === ABJ_404_Solution_CheckpointRecordFactory::ENVELOPE_FULL |
| 131 |
&& ($record['event'] ?? '') === self::JOURNAL_EVENT |
| 132 |
&& is_string($record['carried_by'] ?? null) |
| 133 |
&& $record['carried_by'] !== '' |
| 134 |
&& is_string($record['control_for_request_id'] ?? null) |
| 135 |
&& $record['control_for_request_id'] !== '' |
| 136 |
&& is_string($record['control_request_id'] ?? null) |
| 137 |
&& $record['control_request_id'] !== '' |
| 138 |
&& ($record['request_id'] ?? '') === $record['control_request_id']; |
| 139 |
} |
| 140 |
|
| 141 |
/** @param array<mixed, mixed> $report */ |
| 142 |
private static function hasBrowserEvidence(array $report): bool { |
| 143 |
$receipt = is_array($report['receipt'] ?? null) ? $report['receipt'] : array(); |
| 144 |
$overlap = is_array($report['overlap'] ?? null) ? $report['overlap'] : array(); |
| 145 |
$overlapState = $overlap['state'] ?? ''; |
| 146 |
$validOverlap = $overlapState === self::OVERLAP_UNAVAILABLE |
| 147 |
|| ($overlapState === self::OVERLAP_COMPUTED |
| 148 |
&& is_numeric($overlap['durationMs'] ?? null) |
| 149 |
&& (int)$overlap['durationMs'] >= 0); |
| 150 |
return self::isBrowserReceipt($report) |
| 151 |
&& $validOverlap |
| 152 |
&& is_string($receipt['resourceTimingState'] ?? null) |
| 153 |
&& $receipt['resourceTimingState'] !== ''; |
| 154 |
} |
| 155 |
|
| 156 |
/** |
| 157 |
* A client-supplied request id, or '' when it is not a ledger id. The |
| 158 |
* journal is joined on these, so a value that cannot be one is dropped |
| 159 |
* rather than reflected into a record other readers will try to match. |
| 160 |
* |
| 161 |
* @param mixed $value |
| 162 |
*/ |
| 163 |
private static function ledgerIdOrEmpty($value): string { |
| 164 |
$id = is_scalar($value) ? (string)$value : ''; |
| 165 |
return preg_match('/^[a-zA-Z0-9]{8,64}$/', $id) === 1 ? $id : ''; |
| 166 |
} |
| 167 |
} |
| 168 |
|