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

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

501 lines 22.8 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 require_once __DIR__ . '/DecisiveRecordContractEvaluator.php';
8 require_once __DIR__ . '/DecisiveRecordDiscriminatorContract.php';
9 require_once __DIR__ . '/DecisiveRecordAuthorizationFamilies.php';
10
11 /**
12 * The canonical catalog of decisive tracer records, and the single source the
13 * completeness gates and the support-reservation policy derive from. (Bruno
14 * timeout cause matrix, gap-hunt iteration 6, convergence of gaps O1 + O2.)
15 * Three durability consumers -- both completeness gates and
16 * ABJ_404_Solution_RequiredCheckpointEvidence -- used to hardcode their own
17 * required and reserved record types. Omissions stayed invisible until the
18 * next gap hunt (GF/c444, c473, c479, c489), allowing a failing session
19 * to drop the record carrying its discriminator (the G1 shape).
20 * The fix mirrors ABJ_404_Solution_DiagnosticModuleManifest: coverage is
21 * DERIVED, not maintained by memory. This class is the one list; the two gates
22 * and the reservation policy read their expectations from it, and
23 * DecisiveRecordManifestTest source-scans every *Tracer in includes/diagnostics
24 * and FAILS if any record type a tracer can emit is absent here. A diagnostics
25 * tracer that gains a record next month cannot ship un-gated and un-reserved:
26 * the meta-test forces its enrollment before the completeness gate can pass.
27 *
28 * Each family declares:
29 * - emitter: the tracer class whose source the meta-test scans for the events.
30 * - events: the journal `event` names in the family.
31 * - presence:
32 * PRESENCE_ALWAYS the record (or, for start/end pairs, the pair) is
33 * emitted for every canonical ordinary table request,
34 * so both completeness gates must require it.
35 * PRESENCE_CONDITIONAL the record only appears under a specific condition
36 * (a persistent object cache, external work inside a
37 * row, a callback registered on an option hook). Its
38 * absence is not a hole because an always-present
39 * SENTINEL record states the condition's outcome
40 * (see `sentinel`), so the gates must not require it.
41 * - reserve: a {start,end} event pair whose START must survive bounded support
42 * ranking even when its END never reached disk (the hung operation
43 * whose completion the worker died before writing). Null when the
44 * family has no unmatched-start hazard.
45 */
46 final class ABJ_404_Solution_DecisiveRecordManifest {
47
48 /** Bumped when the catalog's shape changes, so an old reader stays valid. */
49 const SCHEMA_VERSION = 4;
50
51 const PRESENCE_ALWAYS = 'always-present';
52 const PRESENCE_CONDITIONAL = 'conditional-with-sentinel';
53
54 /**
55 * The authoritative catalog, keyed by family name.
56 *
57 * Order is not significant. New records added by later gap-hunt iterations
58 * (translation prelude, excluded-callback breadcrumbs, sort-write option
59 * records) enroll here, and the meta-test forces their coverage so the
60 * gap-hunt loop converges.
61 *
62 * @var array<string, array{
63 * emitter: string,
64 * events: array<int, string>,
65 * presence: string,
66 * reserve: array{start: string, end: string}|null,
67 * sentinel: string|null
68 * }>
69 */
70 private const RECORDS = array(
71 // Registry lookup, traversal, reflection, mutation, and restoration
72 // happen before callback and table-operation boundaries can identify
73 // themselves. The lifecycle start is therefore written first and
74 // advanced with the same operation id before each callback position.
75 // An unmatched start distinguishes instrumentation self-interference
76 // from a foreign callback or the surrounding table operation. The same
77 // start/end pair also brackets every atomic add/remove action/filter a
78 // diagnostic runs to register or remove its own hook entry (phase
79 // registration / removal), so a stall inside WordPress's registry
80 // mutation path is reserved and attributed just like a traversal.
81 'hook_instrumentation_lifecycle' => array(
82 'emitter' => 'ABJ_404_Solution_HookInstrumentationLifecycleTracer',
83 'events' => array(
84 'hook_instrumentation_lifecycle_start',
85 'hook_instrumentation_lifecycle_end',
86 ),
87 'presence' => self::PRESENCE_ALWAYS,
88 'reserve' => array(
89 'start' => 'hook_instrumentation_lifecycle_start',
90 'end' => 'hook_instrumentation_lifecycle_end',
91 ),
92 'sentinel' => null,
93 ),
94 // The medium-high Redis / Object-Cache-Pro-during-rate-limiter
95 // discriminator. backend_selection runs on EVERY table request via
96 // Ajax_Php::consumeRateLimit, so the start/end pair is always present;
97 // the three cache commands only run when a persistent object cache is
98 // selected, and when it is not, the always-present backend_selection
99 // record's result field reads 'database_fallback' -- the sentinel that
100 // makes their absence a stated fact rather than a hole.
101 'rate_limit_operation' => array(
102 'emitter' => 'ABJ_404_Solution_RateLimitOperationTracer',
103 'events' => array('rate_limit_operation_start', 'rate_limit_operation_end'),
104 'presence' => self::PRESENCE_ALWAYS,
105 'reserve' => array(
106 'start' => 'rate_limit_operation_start',
107 'end' => 'rate_limit_operation_end',
108 ),
109 'sentinel' => 'backend_selection result=database_fallback when no persistent object cache; '
110 . 'cache_add_initial/cache_increment/cache_add_fallback are the conditional commands',
111 ),
112 // Diagnostics-owned cache capability, metrics, and counter reads.
113 // Conditional because there is no third-party boundary when WordPress
114 // has no object-cache object; row-loop activity's cache_src=none is the
115 // sentinel. A third-party method or magic property that never returns
116 // leaves its start unmatched and must survive support ranking.
117 'cache_metrics_probe' => array(
118 'emitter' => 'ABJ_404_Solution_CacheMetricsProbeTracer',
119 'events' => array('cache_metrics_probe_start', 'cache_metrics_probe_end'),
120 'presence' => self::PRESENCE_CONDITIONAL,
121 'reserve' => array(
122 'start' => 'cache_metrics_probe_start',
123 'end' => 'cache_metrics_probe_end',
124 ),
125 'sentinel' => 'row_loop_progress/row_loop_end.cache_src',
126 ),
127 // The matrix-cause-37 foreign-option-callback census. Emitted for every
128 // option storage write (even the hook-registry-unavailable branch
129 // writes the record), so it is always present and both gates require it.
130 'option_hook_instrumentation' => array(
131 'emitter' => 'ABJ_404_Solution_OptionPersistenceTracer',
132 'events' => array('option_hook_instrumentation'),
133 'presence' => self::PRESENCE_ALWAYS,
134 'reserve' => null,
135 'sentinel' => null,
136 ),
137 // The fine-grained option-persistence boundaries (normalization, read,
138 // storage write, cache refresh). Always emitted while the tracer is
139 // active during ajaxUpdatePaginationLinks. A boundary that hangs leaves
140 // its start unmatched, so the start is reserved.
141 'option_operation' => array(
142 'emitter' => 'ABJ_404_Solution_OptionPersistenceTracer',
143 'events' => array('option_operation_start', 'option_operation_end'),
144 'presence' => self::PRESENCE_ALWAYS,
145 'reserve' => array(
146 'start' => 'option_operation_start',
147 'end' => 'option_operation_end',
148 ),
149 'sentinel' => null,
150 ),
151 // Each foreign callback registered on an option lifecycle hook, wrapped
152 // for per-callback attribution. Conditional on such callbacks existing;
153 // the always-present option_hook_instrumentation record's
154 // callbacks_attributed count is the census that states how many there were.
155 // A foreign callback that hangs (the literal matrix-cause-37 symptom)
156 // leaves its start unmatched, so the start is reserved.
157 'option_hook_callback' => array(
158 'emitter' => 'ABJ_404_Solution_OptionPersistenceTracer',
159 'events' => array('option_hook_callback_start', 'option_hook_callback_end'),
160 'presence' => self::PRESENCE_CONDITIONAL,
161 'reserve' => array(
162 'start' => 'option_hook_callback_start',
163 'end' => 'option_hook_callback_end',
164 ),
165 'sentinel' => 'option_hook_instrumentation.callbacks_attributed',
166 ),
167 // External work (a cache call or a hook callback) inside a rendered
168 // table row. Conditional on the row performing such work; a row that
169 // never returns leaves its start unmatched, so the start is reserved.
170 'row_operation' => array(
171 'emitter' => 'ABJ_404_Solution_RowRenderOperationTracer',
172 'events' => array('row_operation_start', 'row_operation_end'),
173 'presence' => self::PRESENCE_CONDITIONAL,
174 'reserve' => array(
175 'start' => 'row_operation_start',
176 'end' => 'row_operation_end',
177 ),
178 'sentinel' => 'row_operation_instrumentation.status',
179 ),
180 // The census that row-render attribution was installed. Always emitted
181 // when the row-render tracer runs for a table request.
182 'row_operation_instrumentation' => array(
183 'emitter' => 'ABJ_404_Solution_RowRenderOperationTracer',
184 'events' => array('row_operation_instrumentation'),
185 'presence' => self::PRESENCE_ALWAYS,
186 'reserve' => null,
187 'sentinel' => null,
188 ),
189 // The record budget for row operations was exhausted. Conditional.
190 'row_operation_capped' => array(
191 'emitter' => 'ABJ_404_Solution_RowRenderOperationTracer',
192 'events' => array('row_operation_capped'),
193 'presence' => self::PRESENCE_CONDITIONAL,
194 'reserve' => null,
195 'sentinel' => 'row_operation_instrumentation.status',
196 ),
197 // A malformed hook registry entry could not be attributed. Conditional;
198 // reference signatures are attributed by markers and do not use this.
199 'row_operation_unavailable' => array(
200 'emitter' => 'ABJ_404_Solution_RowRenderOperationTracer',
201 'events' => array('row_operation_unavailable'),
202 'presence' => self::PRESENCE_CONDITIONAL,
203 'reserve' => null,
204 'sentinel' => 'row_operation_instrumentation.status',
205 ),
206 // Every canonical table render resolves this fixed pre-row operation
207 // list. A load or resolver stall leaves its start unmatched.
208 'table_prelude_operation' => array(
209 'emitter' => 'ABJ_404_Solution_TableRendererPreludeTracer',
210 'events' => array('table_prelude_operation_start', 'table_prelude_operation_end'),
211 'presence' => self::PRESENCE_ALWAYS,
212 'reserve' => array(
213 'start' => 'table_prelude_operation_start',
214 'end' => 'table_prelude_operation_end',
215 ),
216 'sentinel' => null,
217 ),
218 // The callback census is always emitted even when the hook registry
219 // or the WordPress JIT translation loader is unavailable.
220 'table_prelude_instrumentation' => array(
221 'emitter' => 'ABJ_404_Solution_TableRendererPreludeTracer',
222 'events' => array('table_prelude_instrumentation'),
223 'presence' => self::PRESENCE_ALWAYS,
224 'reserve' => null,
225 'sentinel' => null,
226 ),
227 // Locale/translation callbacks only exist when another component has
228 // registered them; the census states how many were wrapped.
229 'table_prelude_hook_callback' => array(
230 'emitter' => 'ABJ_404_Solution_TableRendererPreludeTracer',
231 'events' => array(
232 'table_prelude_hook_callback_start',
233 'table_prelude_hook_callback_end',
234 ),
235 'presence' => self::PRESENCE_CONDITIONAL,
236 'reserve' => array(
237 'start' => 'table_prelude_hook_callback_start',
238 'end' => 'table_prelude_hook_callback_end',
239 ),
240 'sentinel' => 'table_prelude_instrumentation.callbacks_attributed',
241 ),
242 'table_prelude_hook_callback_capped' => array(
243 'emitter' => 'ABJ_404_Solution_TableRendererPreludeTracer',
244 'events' => array('table_prelude_hook_callback_capped'),
245 'presence' => self::PRESENCE_CONDITIONAL,
246 'reserve' => null,
247 'sentinel' => 'table_prelude_instrumentation.max_callback_records',
248 ),
249 // The response-control filter dispatches on the instrumented
250 // response tail (gap-hunt iterations 8 and 9).
251 // getAndClearAjaxBufferedOutput()
252 // dispatches abj404_should_manage_output_buffer and
253 // sendJsonResponseAndExit() dispatches abj404_should_exit before the
254 // flush; DetachAbExperiment::assignNextAttempt() dispatches
255 // abj404_should_run_detach_ab_diagnostic after it; WordPress
256 // status_header() dispatches its named filter before core header
257 // emission. All four run on every ajaxUpdatePaginationLinks response,
258 // so the bracket pair is always
259 // present. The start is written before any registry access, so a worker
260 // killed inside a foreign callback still names the boundary -- the start
261 // is reserved. The end carries the callbacks_attributed census that is
262 // the sentinel for the conditional per-callback family below.
263 'response_control_filter_dispatch' => array(
264 'emitter' => 'ABJ_404_Solution_ResponseControlFilterTracer',
265 'events' => array(
266 'response_control_filter_dispatch_start',
267 'response_control_filter_dispatch_end',
268 ),
269 'presence' => self::PRESENCE_ALWAYS,
270 'reserve' => array(
271 'start' => 'response_control_filter_dispatch_start',
272 'end' => 'response_control_filter_dispatch_end',
273 ),
274 'sentinel' => null,
275 ),
276 // Each foreign callback registered on a response-control filter or on
277 // WordPress's global `all` hook, wrapped for per-callback attribution.
278 // Conditional on such callbacks existing; the always-present
279 // response_control_filter_dispatch_end record's callbacks_attributed
280 // count is the census stating how many there were. A callback that hangs
281 // (the literal gap symptom) leaves its start unmatched, so it is reserved.
282 'response_control_filter_callback' => array(
283 'emitter' => 'ABJ_404_Solution_ResponseControlFilterTracer',
284 'events' => array(
285 'response_control_filter_callback_start',
286 'response_control_filter_callback_end',
287 ),
288 'presence' => self::PRESENCE_CONDITIONAL,
289 'reserve' => array(
290 'start' => 'response_control_filter_callback_start',
291 'end' => 'response_control_filter_callback_end',
292 ),
293 'sentinel' => 'response_control_filter_dispatch_end.callbacks_attributed',
294 ),
295 // The full detach A/B resolution after echo_end and before
296 // finish_request. Every instrumented response resolves a mode, including
297 // stable/disabled and no-session requests, so this pair is always
298 // present. A killed filter callback or transient operation leaves the
299 // outer start unmatched and support-reserved.
300 'detach_ab_resolution' => array(
301 'emitter' => 'ABJ_404_Solution_DetachAbResolutionTracer',
302 'events' => array(
303 'detach_ab_resolution_start',
304 'detach_ab_resolution_end',
305 ),
306 'presence' => self::PRESENCE_ALWAYS,
307 'reserve' => array(
308 'start' => 'detach_ab_resolution_start',
309 'end' => 'detach_ab_resolution_end',
310 ),
311 'sentinel' => null,
312 ),
313 // The transient read/write only run after enablement with a usable
314 // session and API. detach_ab_resolution_end.counter_status states why
315 // they are absent on disabled, no-session, or unavailable-API paths.
316 'detach_ab_operation' => array(
317 'emitter' => 'ABJ_404_Solution_DetachAbResolutionTracer',
318 'events' => array(
319 'detach_ab_operation_start',
320 'detach_ab_operation_end',
321 ),
322 'presence' => self::PRESENCE_CONDITIONAL,
323 'reserve' => array(
324 'start' => 'detach_ab_operation_start',
325 'end' => 'detach_ab_operation_end',
326 ),
327 'sentinel' => 'detach_ab_resolution_end.counter_status',
328 ),
329 );
330
331 /**
332 * The full catalog.
333 *
334 * @return array<string, array{emitter: string, events: array<int, string>, presence: string, reserve: array{start: string, end: string}|null, sentinel: string|null}>
335 */
336 public static function records(): array {
337 return self::recordsCatalog();
338 }
339
340 /**
341 * Every journal `event` name enrolled, across all families.
342 *
343 * @return array<int, string>
344 */
345 public static function allEvents(): array {
346 $events = array();
347 foreach (self::recordsCatalog() as $family) {
348 foreach ($family['events'] as $event) {
349 $events[$event] = true;
350 }
351 }
352 return array_keys($events);
353 }
354 /**
355 * Events belonging to always-present families. Both completeness gates must
356 * require every one of these; a build that stops emitting one can come back
357 * evidence-free for that discriminator, which is the whole failure mode.
358 *
359 * @return array<int, string>
360 */
361 public static function alwaysPresentEvents(): array {
362 return self::eventsWithPresence(self::PRESENCE_ALWAYS);
363 }
364
365 /**
366 * Events belonging to conditional families. Gates must NOT require these;
367 * their always-present sentinel records the condition's outcome instead.
368 *
369 * @return array<int, string>
370 */
371 public static function conditionalEvents(): array {
372 return self::eventsWithPresence(self::PRESENCE_CONDITIONAL);
373 }
374
375 /**
376 * The start/end pairs whose unmatched start the support-reservation policy
377 * must preserve. Keyed matching is by request_id + operation_id, the field
378 * every enrolled tracer stamps onto both records of a pair.
379 *
380 * @return array<int, array{start: string, end: string}>
381 */
382 public static function reservedOperationPairs(): array {
383 $pairs = array();
384 foreach (self::recordsCatalog() as $family) {
385 if (is_array($family['reserve'])) {
386 $pairs[] = $family['reserve'];
387 }
388 }
389 return $pairs;
390 }
391
392 /**
393 * The start events whose unmatched instance is reserved.
394 *
395 * @return array<int, string>
396 */
397 public static function reservedStartEvents(): array {
398 return array_map(
399 static fn(array $pair): string => $pair['start'],
400 self::reservedOperationPairs()
401 );
402 }
403
404 /**
405 * The tracer classes that emit enrolled records. The meta-test scans each
406 * of these plus any *Tracer file it finds on disk.
407 *
408 * @return array<int, string>
409 */
410 public static function emitterClasses(): array {
411 $classes = array();
412 foreach (self::recordsCatalog() as $family) {
413 $classes[$family['emitter']] = true;
414 }
415 return array_keys($classes);
416 }
417
418 /**
419 * Events attributed to a given emitter class.
420 *
421 * @return array<int, string>
422 */
423 public static function eventsForEmitter(string $emitter): array {
424 $events = array();
425 foreach (self::recordsCatalog() as $family) {
426 if ($family['emitter'] === $emitter) {
427 foreach ($family['events'] as $event) {
428 $events[$event] = true;
429 }
430 }
431 }
432 return array_keys($events);
433 }
434
435 /**
436 * Discriminator-field and conditional-presence contracts used by every
437 * principal beta-cut gate.
438 *
439 * @return array<string, array<string, mixed>>
440 */
441 public static function discriminatorContracts(): array {
442 return ABJ_404_Solution_DecisiveRecordDiscriminatorContract::contracts();
443 }
444
445 /**
446 * @param array<int, array<string, mixed>> $records
447 * @param array<int, string> $profiles
448 * @param array<string, mixed> $facts
449 * @return array<int, string>
450 */
451 public static function contractViolations(
452 array $records,
453 array $profiles,
454 array $facts = array()
455 ): array {
456 return ABJ_404_Solution_DecisiveRecordContractEvaluator::violations(
457 self::discriminatorContracts(),
458 $records,
459 $profiles,
460 $facts
461 );
462 }
463
464 /**
465 * @return array<int, string>
466 */
467 private static function eventsWithPresence(string $presence): array {
468 $events = array();
469 foreach (self::recordsCatalog() as $family) {
470 if ($family['presence'] !== $presence) {
471 continue;
472 }
473 foreach ($family['events'] as $event) {
474 $events[$event] = true;
475 }
476 }
477 return array_keys($events);
478 }
479
480 /**
481 * @return array<string, array{emitter: string, events: array<int, string>, presence: string, reserve: array{start: string, end: string}|null, sentinel: string|null}>
482 */
483 private static function recordsCatalog(): array {
484 return array_merge(
485 self::RECORDS,
486 ABJ_404_Solution_DecisiveRecordAuthorizationFamilies::records(self::PRESENCE_ALWAYS),
487 ABJ_404_Solution_DecisiveRecordRenderFamilies::records(
488 self::PRESENCE_ALWAYS, self::PRESENCE_CONDITIONAL),
489 ABJ_404_Solution_DecisiveRecordRenderOptionIoFamilies::records(
490 self::PRESENCE_ALWAYS, self::PRESENCE_CONDITIONAL),
491 ABJ_404_Solution_DecisiveRecordStatusCountFamilies::records(
492 self::PRESENCE_CONDITIONAL),
493 ABJ_404_Solution_DecisiveRecordQueryFilterFamilies::records(
494 self::PRESENCE_ALWAYS, self::PRESENCE_CONDITIONAL),
495 ABJ_404_Solution_DecisiveRecordShutdownFamilies::records(self::PRESENCE_ALWAYS,
496 self::PRESENCE_CONDITIONAL),
497 ABJ_404_Solution_DecisiveRecordFailureFamilies::records(self::PRESENCE_CONDITIONAL)
498 );
499 }
500 }
501