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

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

259 lines 11.0 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 * Shapes the three schema-versioned checkpoint journal record kinds.
9 *
10 * This class performs no journal or directory I/O. The logger owns lifecycle,
11 * phase timing, ordering, and failure handling; this factory owns the compact
12 * on-disk representation those operations persist.
13 */
14 final class ABJ_404_Solution_CheckpointRecordFactory {
15
16 const SCHEMA_VERSION = 8;
17 const ENVELOPE_FULL = 'full';
18 const ENVELOPE_FREQUENT = 'frequent';
19 const ENVELOPE_INTENT = 'intent';
20
21 /**
22 * getrusage() keys worth carrying on every full checkpoint, mapped to the
23 * compact names written to the journal.
24 *
25 * The full 17-key array was the single largest thing in the journal: 305
26 * of the 545 bytes an average record occupied, repeated on all 27 records
27 * of every request, most of it fields that are structurally zero on Linux
28 * (ixrss/idrss/isrss/nswap) or irrelevant to a stall (msgsnd/msgrcv/
29 * nsignals). What survives is what a stall is actually diagnosed with:
30 * the user/system CPU split (CPU burn vs blocked), resident memory,
31 * voluntary vs involuntary context switches (blocked-on-IO vs preempted,
32 * the signature of host-level throttling), page faults, and block IO.
33 */
34 const RUSAGE_FIELDS = array(
35 'maxrss' => 'ru_maxrss',
36 'minflt' => 'ru_minflt',
37 'majflt' => 'ru_majflt',
38 'nvcsw' => 'ru_nvcsw',
39 'nivcsw' => 'ru_nivcsw',
40 'inblock' => 'ru_inblock',
41 'oublock' => 'ru_oublock',
42 );
43
44 /**
45 * The most recent intent's CPU sample, so the next intent can report a
46 * delta instead of an absolute reading. Single most-recent slot rather
47 * than a per-request map (mirrors
48 * ABJ_404_Solution_AjaxCheckpointLogger::$previousWriteTelemetry): a
49 * request id mismatch means the sample belongs to a different request
50 * and the delta is reported as unavailable rather than leaked across the
51 * boundary, and nothing has to be evicted from an ever-growing map over
52 * an FPM worker's lifetime.
53 *
54 * @var array{request_id: string, utime_us: int, stime_us: int}|null
55 */
56 private static $previousIntentRusage = null;
57
58 /**
59 * @param array<string, mixed> $context
60 * @return array<string, mixed>
61 */
62 public static function full(array $context): array {
63 $record = array(
64 'schema_version' => self::SCHEMA_VERSION,
65 'envelope' => self::ENVELOPE_FULL,
66 'ts' => $context['ts'] ?? null,
67 'hrtime_ns' => $context['hrtime_ns'] ?? null,
68 'rusage' => self::resourceUsage(),
69 'host_pressure' => $context['host_pressure'] ?? array(
70 'status' => 'unavailable',
71 'reason' => 'sampler_result_unavailable',
72 ),
73 );
74 // Host-WIDE pressure above; THIS SITE's own concurrency next. A
75 // per-account worker cap (LiteSpeed/CloudLinux LVE) throttles a site
76 // whose box looks idle, so the two answer different questions and a
77 // record carrying only the first cannot tell them apart. The census
78 // owns the shape of its own contribution; see
79 // ABJ_404_Solution_SameSiteCensusReading::checkpointFields().
80 $record += class_exists('ABJ_404_Solution_SameSiteCensusReading')
81 ? ABJ_404_Solution_SameSiteCensusReading::checkpointFields()
82 : array('same_site_requests' => -1);
83 $record['previous_checkpoint_write'] = $context['previous_checkpoint_write'];
84 $record['request_id'] = $context['request_id'];
85 $record['event'] = $context['event'];
86 $record['checkpoint_id'] = $context['checkpoint_id'];
87 $record['pid'] = $context['pid'];
88 return $record;
89 }
90
91 /**
92 * @param array<string, mixed> $context
93 * @return array<string, mixed>
94 */
95 public static function intent(array $context): array {
96 $requestId = is_string($context['request_id'] ?? null) ? $context['request_id'] : '';
97 $delta = self::intentCpuDelta($requestId);
98 return array(
99 'schema_version' => self::SCHEMA_VERSION,
100 'envelope' => self::ENVELOPE_INTENT,
101 'hrtime_ns' => $context['hrtime_ns'] ?? null,
102 'request_id' => $context['request_id'],
103 'event' => 'checkpoint_intent',
104 'intended_event' => $context['event'],
105 'checkpoint_id' => $context['checkpoint_id'],
106 'pid' => $context['pid'],
107 'utime_delta_us' => $delta['utime_delta_us'],
108 'stime_delta_us' => $delta['stime_delta_us'],
109 );
110 }
111
112 /**
113 * @param array<string, mixed> $context
114 * @return array<string, mixed>
115 */
116 public static function frequent(array $context): array {
117 return array(
118 'schema_version' => self::SCHEMA_VERSION,
119 'envelope' => self::ENVELOPE_FREQUENT,
120 'ts' => $context['ts'] ?? null,
121 'hrtime_ns' => $context['hrtime_ns'] ?? null,
122 'request_id' => $context['request_id'],
123 'event' => $context['event'],
124 'checkpoint_id' => $context['checkpoint_id'],
125 'pid' => $context['pid'],
126 );
127 }
128
129 /**
130 * Shape the completed call telemetry embedded in the next full record.
131 *
132 * @param array<string, mixed> $context
133 * @return array<string, mixed>
134 */
135 public static function completedWriteTelemetry(array $context): array {
136 $write = is_array($context['write'] ?? null) ? $context['write'] : array();
137 $intent = is_array($context['intent'] ?? null) ? $context['intent'] : array();
138 $phases = is_array($context['phases_us'] ?? null) ? $context['phases_us'] : array();
139 $telemetry = array(
140 'status' => is_string($write['status'] ?? null) ? $write['status'] : 'failed',
141 'request_id' => $context['request_id'],
142 'event' => $context['event'],
143 'checkpoint_id' => $context['checkpoint_id'],
144 'elapsed_us' => is_numeric($phases['append'] ?? null) ? max(0, (int)$phases['append']) : 0,
145 'total_us' => is_numeric($context['total_us'] ?? null) ? max(0, (int)$context['total_us']) : 0,
146 'phases_us' => $phases,
147 );
148 return array_merge($telemetry, self::failureDetails($write, $intent));
149 }
150
151 /**
152 * @param array<string, mixed> $write
153 * @param array<string, mixed> $intent
154 * @return array<string, string>
155 */
156 private static function failureDetails(array $write, array $intent): array {
157 $details = array();
158 $intentStatus = is_string($intent['status'] ?? null) ? $intent['status'] : 'failed';
159 if ($intentStatus !== 'complete') {
160 $details['intent_status'] = $intentStatus;
161 }
162 if (is_string($write['reason'] ?? null) && $write['reason'] !== '') {
163 $details['reason'] = $write['reason'];
164 }
165 if (is_string($intent['reason'] ?? null) && $intent['reason'] !== '') {
166 $details['intent_reason'] = $intent['reason'];
167 }
168 return $details;
169 }
170
171 /**
172 * The diagnostic subset of getrusage(), or null where it is unavailable.
173 *
174 * Absolute counters rather than deltas against a previous record: the
175 * excerpt that carries these is allowed to drop records it cannot afford,
176 * and a delta chain with a hole in it is unreadable, while an absolute
177 * sample stays interpretable on its own. CPU times are folded into single
178 * microsecond fields so the tv_sec/tv_usec pairs do not have to be
179 * recombined by hand at read time.
180 *
181 * @return array<string, int>|null
182 */
183 private static function resourceUsage(): ?array {
184 $rusage = ABJ_404_Solution_PhpRuntimeCapabilityAdapter::resourceUsage();
185 if (!is_array($rusage)) {
186 return null;
187 }
188 $usage = array(
189 'utime_us' => self::microseconds($rusage, 'ru_utime'),
190 'stime_us' => self::microseconds($rusage, 'ru_stime'),
191 );
192 foreach (self::RUSAGE_FIELDS as $name => $key) {
193 if (isset($rusage[$key]) && is_numeric($rusage[$key])) {
194 $usage[$name] = (int)$rusage[$key];
195 }
196 }
197 return $usage;
198 }
199
200 /**
201 * One getrusage() tv_sec/tv_usec pair as microseconds.
202 *
203 * @param array<string, mixed> $rusage
204 */
205 private static function microseconds(array $rusage, string $prefix): int {
206 $seconds = isset($rusage[$prefix . '.tv_sec']) && is_numeric($rusage[$prefix . '.tv_sec'])
207 ? (int)$rusage[$prefix . '.tv_sec'] : 0;
208 $micros = isset($rusage[$prefix . '.tv_usec']) && is_numeric($rusage[$prefix . '.tv_usec'])
209 ? (int)$rusage[$prefix . '.tv_usec'] : 0;
210 return ($seconds * 1000000) + $micros;
211 }
212
213 /**
214 * User/system CPU microseconds burned since the previous intent record
215 * IN THIS REQUEST, or null on either field when there is no in-request
216 * predecessor to diff against (report 193: 833 intents and only 2 full
217 * records with rusage meant a 165-second gap could only be classified as
218 * spin-vs-blocked because those two full records happened to survive;
219 * every intent carrying its own CPU delta means the very next intent
220 * after any gap self-classifies it, with no full record required).
221 *
222 * Deltas rather than the absolute counters full() carries: an intent is
223 * written before EVERY checkpoint of any kind (record() and
224 * recordFrequent() both call this first), so the intent-to-intent delta
225 * already covers every gap in the stream at negligible incremental cost
226 * over the getrusage() call intent() already has to make. The absolute
227 * reading remains available every 27th-or-so record via full()'s own
228 * rusage field, so a reader can still recover a running total.
229 *
230 * @return array{utime_delta_us: int|null, stime_delta_us: int|null}
231 */
232 private static function intentCpuDelta(string $requestId): array {
233 $rusage = ABJ_404_Solution_PhpRuntimeCapabilityAdapter::resourceUsage();
234 if (!is_array($rusage)) {
235 return array('utime_delta_us' => null, 'stime_delta_us' => null);
236 }
237 $utimeUs = self::microseconds($rusage, 'ru_utime');
238 $stimeUs = self::microseconds($rusage, 'ru_stime');
239 $previous = self::$previousIntentRusage;
240 self::$previousIntentRusage = array(
241 'request_id' => $requestId,
242 'utime_us' => $utimeUs,
243 'stime_us' => $stimeUs,
244 );
245 if ($requestId === '' || $previous === null || $previous['request_id'] !== $requestId) {
246 return array('utime_delta_us' => null, 'stime_delta_us' => null);
247 }
248 return array(
249 'utime_delta_us' => max(0, $utimeUs - $previous['utime_us']),
250 'stime_delta_us' => max(0, $stimeUs - $previous['stime_us']),
251 );
252 }
253
254 /** Test-only: clear the in-process CPU-delta baseline between test cases. */
255 public static function resetForTests(): void {
256 self::$previousIntentRusage = null;
257 }
258 }
259