| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* How many bytes this site's admin-table response encodes, measured by |
| 9 |
* building one right now. |
| 10 |
* |
| 11 |
* The third member of the response-size family, and the only one that does not |
| 12 |
* read a journal. ABJ_404_Solution_EncodedTableResponseSize says how many bytes |
| 13 |
* the server RECORDED encoding for a session's last table response; |
| 14 |
* ABJ_404_Solution_DeliveredTableResponseSize says how many the browser |
| 15 |
* reported receiving. Both are recoveries of a number some earlier request |
| 16 |
* left behind. This one produces the number itself, from the same builder the |
| 17 |
* table endpoint uses, so a site that recorded nothing still has a real size. |
| 18 |
* |
| 19 |
* Which is also why it does NOT live beside those two in includes/diagnostics/. |
| 20 |
* They record evidence; this one RENDERS an admin table response and then |
| 21 |
* measures it, and rendering is the presentation layer's job -- deptrac's |
| 22 |
* Diagnostics layer forbids exactly this dependency, and it is right to. The |
| 23 |
* family is split by what each member does, not by the question they share. |
| 24 |
* |
| 25 |
* That gap is why it exists. Support report 2026-08-27 (beadoctor.metaapply.io, |
| 26 |
* Azure App Service, plugin 4.3.4) ran the full ten-step canary ladder and came |
| 27 |
* back with `encoded_size {bytes: null, source: "unavailable"}`, so the size |
| 28 |
* probes fell back to the client's default and `sizeOrDeliveryCausal` was left |
| 29 |
* UNTESTED on the one report it was built to answer. The recorded number was |
| 30 |
* not lost, mis-keyed or rotated away: it was never written. |
| 31 |
* ABJ_404_Solution_AjaxResponseEmitter scopes its post-encode size record to |
| 32 |
* ABJ_404_Solution_AjaxDiagnosticRequestPolicy::diagnosticRequestId(), and that |
| 33 |
* returns '' for `ajaxUpdatePaginationLinks` unless `debug_mode` is on -- which |
| 34 |
* is the 4.3.3 performance gate working as designed, and which the ladder's own |
| 35 |
* self-arming cannot undo retroactively for a request that has already failed. |
| 36 |
* A number recovered from journals can therefore only ever exist on sites that |
| 37 |
* were already instrumented before the failure; a number measured here exists |
| 38 |
* on every site that runs the ladder. |
| 39 |
* |
| 40 |
* It is a MEASUREMENT of this site's table, not a reconstruction of the failed |
| 41 |
* response, and it never pretends otherwise: the source string says `measured`, |
| 42 |
* the caller keeps the recorded absence alongside it, and a build that cannot |
| 43 |
* run returns a named absence rather than a plausible number. Two honest |
| 44 |
* differences from the response the endpoint emits, both immaterial at the KB |
| 45 |
* granularity the size ladder works at, both stated rather than hidden: |
| 46 |
* `part=table` only (no counts or pagination block), and without the |
| 47 |
* `requestId`/`retryCount` scalars the handler appends. |
| 48 |
* |
| 49 |
* The build is the ordinary foreground one, so it warms the same caches an |
| 50 |
* admin table load warms and mutates nothing a user owns: it renders ONE page |
| 51 |
* of rows (bounded by the stored per-page option, not by the table's size), |
| 52 |
* writes no options, and returns HTML that is measured and dropped. |
| 53 |
* |
| 54 |
* Cost is deliberate and bounded. This is one real table build, on a request |
| 55 |
* the browser fires at most once an hour and only after a real table request |
| 56 |
* has already failed (view_updater_canary_cooldown.js), inside a step whose |
| 57 |
* own client timeout is 15 seconds. On a site where the table build is itself |
| 58 |
* the stall, that timeout expires and the ladder's trace carries the build's |
| 59 |
* own stage record showing where it stopped -- which is a decisive finding, not |
| 60 |
* a cost. |
| 61 |
*/ |
| 62 |
final class ABJ_404_Solution_MeasuredTableResponseSize { |
| 63 |
|
| 64 |
/** A real table build ran and its encoded size is the number returned. */ |
| 65 |
const SOURCE_MEASURED = 'measured_table_build'; |
| 66 |
|
| 67 |
/** The subpage has no table renderer, so there is no table to measure. */ |
| 68 |
const SOURCE_SUBPAGE_UNSUPPORTED = 'measure_subpage_unsupported'; |
| 69 |
|
| 70 |
/** The view or view-read service is not resolvable on this request. */ |
| 71 |
const SOURCE_SERVICES_UNAVAILABLE = 'measure_services_unavailable'; |
| 72 |
|
| 73 |
/** The build threw. The size is unknown; that the build fails is the finding. */ |
| 74 |
const SOURCE_BUILD_FAILED = 'measure_build_failed'; |
| 75 |
|
| 76 |
/** The build returned something the response encoder could not turn into bytes. */ |
| 77 |
const SOURCE_ENCODE_FAILED = 'measure_encode_failed'; |
| 78 |
|
| 79 |
/** |
| 80 |
* The client gives up on this step at 15 seconds |
| 81 |
* (view_updater_canary_ladder.js), so the build's own per-query budget is |
| 82 |
* held below that. The shared foreground budget is 20 seconds, which is |
| 83 |
* correct for the table endpoint and wrong here: it would leave a query |
| 84 |
* holding a worker for five seconds after the only party waiting on the |
| 85 |
* answer has already stopped listening. |
| 86 |
*/ |
| 87 |
const CLIENT_DEADLINE_SECONDS = 15; |
| 88 |
|
| 89 |
/** Leaves room inside the deadline for the render and the encode. */ |
| 90 |
const QUERY_BUDGET_SECONDS = 10; |
| 91 |
|
| 92 |
/** |
| 93 |
* Build this subpage's table response part and return its encoded size. |
| 94 |
* |
| 95 |
* Runs OUTSIDE any caller-opened stage on purpose. The builder opens its |
| 96 |
* own stage (`table_redirects` / `table_captured` / `table_logs`) and |
| 97 |
* ABJ_404_Solution_AjaxRequestTrace stages are flat, so wrapping this in |
| 98 |
* another stage would only mark the caller's stage `superseded`. Left |
| 99 |
* unwrapped, the ladder's own trace gains a real table-build stage that is |
| 100 |
* directly comparable, stage for stage, against the failing request's -- |
| 101 |
* which is what the ladder's trace exists for. |
| 102 |
* |
| 103 |
* @param array<string, mixed> $context Mutated in place by the build's stages. |
| 104 |
* @return array{bytes: int|null, source: string} |
| 105 |
*/ |
| 106 |
public static function forSubpage(string $subpage, array &$context): array { |
| 107 |
if (!ABJ_404_Solution_AdminTableResponseParts::rendersTablePart($subpage)) { |
| 108 |
return self::noSize(self::SOURCE_SUBPAGE_UNSUPPORTED); |
| 109 |
} |
| 110 |
try { |
| 111 |
$view = function_exists('abj_service_optional') ? abj_service_optional('view') : null; |
| 112 |
$viewReadService = function_exists('abj_service_optional') |
| 113 |
? abj_service_optional('view_read_service') : null; |
| 114 |
if (!is_object($view) || !is_object($viewReadService)) { |
| 115 |
return self::noSize(self::SOURCE_SERVICES_UNAVAILABLE); |
| 116 |
} |
| 117 |
$tableResponse = ABJ_404_Solution_AdminTableResponseParts::build( |
| 118 |
array( |
| 119 |
'part' => 'table', |
| 120 |
'subpage' => $subpage, |
| 121 |
'view' => $view, |
| 122 |
'viewReadService' => $viewReadService, |
| 123 |
'queryTimeoutSeconds' => self::QUERY_BUDGET_SECONDS, |
| 124 |
), |
| 125 |
$context |
| 126 |
); |
| 127 |
// Through the SAME encoder the endpoint emits with, not a raw |
| 128 |
// json_encode(). A payload the encoder recovers -- substituted |
| 129 |
// invalid UTF-8, a partial-output fallback -- is one the endpoint |
| 130 |
// really would have delivered, and measuring it with json_encode() |
| 131 |
// reports `false` and throws away the size on exactly the degraded |
| 132 |
// sites this measurement exists to explain. An error envelope is |
| 133 |
// the one strategy that is NOT a table, so its bytes are not the |
| 134 |
// number being asked for. |
| 135 |
$encoded = ABJ_404_Solution_JsonResponseEncoder::encode($tableResponse); |
| 136 |
if (!$encoded->carriesPayload() || strlen($encoded->json()) === 0) { |
| 137 |
return self::noSize(self::SOURCE_ENCODE_FAILED); |
| 138 |
} |
| 139 |
return array('bytes' => strlen($encoded->json()), 'source' => self::SOURCE_MEASURED); |
| 140 |
} catch (Throwable $e) { |
| 141 |
// Unconditional; abj404_logPhpFallback() is defined at plugin |
| 142 |
// entry, before any class here can be autoloaded. |
| 143 |
abj404_logPhpFallback( |
| 144 |
'ajax-checkpoint', 'Table response-size measurement failed: ' . $e->getMessage()); |
| 145 |
return self::noSize(self::SOURCE_BUILD_FAILED); |
| 146 |
} |
| 147 |
} |
| 148 |
|
| 149 |
/** |
| 150 |
* The no-size answer, carrying the reason it is the answer. |
| 151 |
* |
| 152 |
* @return array{bytes: int|null, source: string} |
| 153 |
*/ |
| 154 |
private static function noSize(string $reason): array { |
| 155 |
return array('bytes' => null, 'source' => $reason); |
| 156 |
} |
| 157 |
} |
| 158 |
|