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 / ajax / MeasuredTableResponseSize.php

MeasuredTableResponseSize.php in 404 Solution trunk, at includes/ajax/MeasuredTableResponseSize.php

158 lines 8.1 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 * 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