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

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

421 lines 14.2 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 * Coordinates two-sink lifecycle evidence around foreign operations.
9 *
10 * The complete privacy-safe identity reaches the fixed system-temp sink
11 * before WordPress path resolution. The existing journal or active-operation
12 * store remains the second sink. A final fixed-sink state acknowledges whether
13 * foreign work was armed or completed and records the second sink outcome.
14 *
15 * This class owns sequencing only. CheckpointIntentStore, CheckpointJournalWriter,
16 * CheckpointRecordFactory, and ActiveOperationBreadcrumbs remain the persistence
17 * implementations.
18 *
19 * allow-no-test-found: exercised through the real AJAX table handler in tests/AjaxRowProgressAttributionTest.php and tests/TableRendererPreludeTracerTest.php
20 */
21 final class ABJ_404_Solution_DurableOperationRecorder {
22
23 /** @var bool Prevent directory filters from recursively recording themselves. */
24 private static $recordingActiveOperation = false;
25
26 /** @var int */
27 private static $checkpointSequence = 0;
28
29 /**
30 * Privacy-safe scalar fields allowed in the system-temp operation state.
31 */
32 private const SAFE_FIELDS = array(
33 'operation_id',
34 'operation',
35 'session_state',
36 'part',
37 'payload_key',
38 'transient_key',
39 'cache_backend',
40 'cache_backend_class',
41 'cache_capabilities',
42 'counter_status',
43 'mode',
44 'diagnostic_enabled',
45 'error_class',
46 'error_code',
47 'error',
48 'source',
49 'phase',
50 'boundary',
51 'state',
52 'hook',
53 'callback',
54 'locale',
55 'priority',
56 'status',
57 'elapsed_ms',
58 'result',
59 'armed',
60 'first_sink_status',
61 'second_sink_status',
62 );
63
64 /**
65 * Persist cache-probe identity before uploads work and acknowledge arming.
66 *
67 * @param array<string, mixed> $fields
68 * @return string Stable checkpoint identity carried through completion.
69 */
70 public static function recordStart(
71 string $requestId,
72 string $event,
73 array $fields
74 ): string {
75 $checkpointId = self::newCheckpointId();
76 $safeFields = self::selectSafeFields($fields);
77 $intent = self::appendFixedState(
78 $requestId,
79 $event,
80 $checkpointId,
81 'intent',
82 $safeFields
83 );
84 $journal = self::appendJournalRecord(
85 $requestId,
86 $event,
87 $checkpointId,
88 array_merge($safeFields, array(
89 'operation_checkpoint_id' => $checkpointId,
90 'armed' => true,
91 'first_sink_status' => self::writeStatus($intent),
92 ))
93 );
94 self::appendFixedState(
95 $requestId,
96 $event,
97 $checkpointId,
98 'armed',
99 array_merge($safeFields, array(
100 'armed' => true,
101 'first_sink_status' => self::writeStatus($intent),
102 'second_sink_status' => self::writeStatus($journal),
103 ))
104 );
105 return $checkpointId;
106 }
107
108 /**
109 * Persist that foreign work returned using its original correlation key.
110 *
111 * @param array<string, mixed> $fields
112 */
113 public static function recordEnd(
114 string $requestId,
115 string $event,
116 string $checkpointId,
117 array $fields
118 ): void {
119 if ($checkpointId === '') {
120 return;
121 }
122 $completion = self::appendFixedState(
123 $requestId,
124 $event,
125 $checkpointId,
126 'complete',
127 self::selectSafeFields($fields)
128 );
129 self::appendJournalRecord(
130 $requestId,
131 $event,
132 $checkpointId,
133 array_merge($fields, array(
134 'operation_checkpoint_id' => $checkpointId,
135 'first_sink_status' => self::writeStatus($completion),
136 ))
137 );
138 }
139
140 /**
141 * Replace one fixed-size post-cap operation state. Never throws.
142 *
143 * @param array<string, mixed> $fields
144 */
145 public static function recordActiveOperation(
146 string $requestId,
147 string $boundary,
148 string $state,
149 array $fields
150 ): void {
151 // Both collaborators are guarded: on a partially corrupt install this
152 // path must degrade to recording nothing, never fatal on the redaction
153 // catalog after clearing the persistence class.
154 if ($requestId === '' || self::$recordingActiveOperation
155 || !class_exists('ABJ_404_Solution_ActiveOperationBreadcrumbs')
156 || !class_exists('ABJ_404_Solution_ActiveOperationBoundaryManifest')) {
157 return;
158 }
159 self::$recordingActiveOperation = true;
160 try {
161 $safeFields = ABJ_404_Solution_ActiveOperationBoundaryManifest::selectFields(
162 $boundary,
163 $fields
164 );
165 $checkpointId = self::activeCheckpointId(
166 $requestId,
167 $boundary,
168 is_string($safeFields['operation_id'] ?? null)
169 ? $safeFields['operation_id']
170 : ''
171 );
172 $identity = array_merge(
173 array('boundary' => $boundary, 'state' => $state),
174 $safeFields
175 );
176 $intent = self::appendFixedState(
177 $requestId,
178 'active_operation_breadcrumb',
179 $checkpointId,
180 'intent',
181 $identity
182 );
183 $result = self::replaceActiveState(
184 $requestId,
185 $checkpointId,
186 $identity,
187 $state === 'active',
188 self::writeStatus($intent)
189 );
190 self::appendFixedState(
191 $requestId,
192 'active_operation_breadcrumb',
193 $checkpointId,
194 $state === 'active' ? 'armed' : 'complete',
195 array_merge($identity, array(
196 'armed' => $state === 'active',
197 'first_sink_status' => self::writeStatus($intent),
198 'second_sink_status' => self::writeStatus($result),
199 ))
200 );
201 } catch (Throwable $e) {
202 self::reportFailure('active-operation record failed: ' . $e->getMessage());
203 } finally {
204 self::$recordingActiveOperation = false;
205 }
206 }
207
208 /**
209 * Keep only the latest durable and active state for each operation identity.
210 *
211 * @param array<int, string> $lines
212 * @return array<int, string>
213 */
214 public static function compactSupportLines(array $lines): array {
215 $latestIndexes = array();
216 foreach ($lines as $index => $line) {
217 $record = json_decode($line, true);
218 if (!is_array($record) || ($record['event'] ?? '') !== 'durable_operation_state') {
219 continue;
220 }
221 $requestId = is_scalar($record['request_id'] ?? null)
222 ? (string)$record['request_id'] : '';
223 $checkpointId = is_scalar($record['operation_checkpoint_id'] ?? null)
224 ? (string)$record['operation_checkpoint_id'] : '';
225 if ($requestId !== '' && $checkpointId !== '') {
226 $latestIndexes[$requestId . '|' . $checkpointId] = $index;
227 }
228 }
229 $compacted = $lines;
230 if ($latestIndexes !== array()) {
231 $keep = array_fill_keys(array_values($latestIndexes), true);
232 $compacted = array_values(array_filter(
233 $lines,
234 static function (string $line, int $index) use ($keep): bool {
235 $record = json_decode($line, true);
236 return !is_array($record)
237 || ($record['event'] ?? '') !== 'durable_operation_state'
238 || isset($keep[$index]);
239 },
240 ARRAY_FILTER_USE_BOTH
241 ));
242 }
243 return class_exists('ABJ_404_Solution_ActiveOperationBreadcrumbs')
244 ? ABJ_404_Solution_ActiveOperationBreadcrumbs::compactSupportLines($compacted)
245 : $compacted;
246 }
247
248 /** Active-state path for support collection, or empty when unavailable. */
249 public static function activePath(string $directory): string {
250 return $directory !== '' && class_exists('ABJ_404_Solution_ActiveOperationBreadcrumbs')
251 ? ABJ_404_Solution_ActiveOperationBreadcrumbs::path($directory)
252 : '';
253 }
254
255 /**
256 * @param array<string, mixed> $fields
257 * @return array<string, mixed>
258 */
259 private static function appendJournalRecord(
260 string $requestId,
261 string $event,
262 string $checkpointId,
263 array $fields
264 ): array {
265 try {
266 $directory = ABJ_404_Solution_AjaxCheckpointLogger::resolveDirectoryPath();
267 if ($directory === '') {
268 return self::failure('directory_unavailable');
269 }
270 if (!class_exists('ABJ_404_Solution_FileSystemService')
271 || !ABJ_404_Solution_FileSystemService::createDirectoryWithErrorMessages($directory)) {
272 return self::failure('directory_create_failed');
273 }
274 return ABJ_404_Solution_CheckpointJournalWriter::append(
275 $directory,
276 array_merge(
277 $fields,
278 self::frequentRecord($requestId, $event, $checkpointId)
279 )
280 );
281 } catch (Throwable $e) {
282 self::reportFailure('journal record failed: ' . $e->getMessage());
283 return self::failure('unexpected_failure');
284 }
285 }
286
287 /**
288 * @param array<string, mixed> $identity
289 * @return array<string, mixed>
290 */
291 private static function replaceActiveState(
292 string $requestId,
293 string $checkpointId,
294 array $identity,
295 bool $armed,
296 string $firstSinkStatus
297 ): array {
298 try {
299 $directory = ABJ_404_Solution_AjaxCheckpointLogger::resolveDirectoryPath();
300 if ($directory === '') {
301 return self::failure('directory_unavailable');
302 }
303 return ABJ_404_Solution_ActiveOperationBreadcrumbs::replace(
304 $directory,
305 array_merge(
306 self::frequentRecord(
307 $requestId,
308 'active_operation_breadcrumb',
309 $checkpointId
310 ),
311 $identity,
312 array(
313 'operation_checkpoint_id' => $checkpointId,
314 'armed' => $armed,
315 'first_sink_status' => $firstSinkStatus,
316 )
317 )
318 );
319 } catch (Throwable $e) {
320 self::reportFailure('active-operation write failed: ' . $e->getMessage());
321 return self::failure('unexpected_failure');
322 }
323 }
324
325 /**
326 * @param array<string, mixed> $fields
327 * @return array<string, mixed>
328 */
329 private static function appendFixedState(
330 string $requestId,
331 string $operationEvent,
332 string $checkpointId,
333 string $operationState,
334 array $fields
335 ): array {
336 return ABJ_404_Solution_CheckpointIntentStore::append(array_merge(
337 self::frequentRecord($requestId, 'durable_operation_state', $checkpointId),
338 array(
339 'operation_event' => $operationEvent,
340 'operation_state' => $operationState,
341 'operation_checkpoint_id' => $checkpointId,
342 ),
343 self::selectSafeFields($fields)
344 ));
345 }
346
347 /** @return array<string, mixed> */
348 private static function frequentRecord(
349 string $requestId,
350 string $event,
351 string $checkpointId
352 ): array {
353 return ABJ_404_Solution_CheckpointRecordFactory::frequent(array(
354 'ts' => self::nowFloat(),
355 'hrtime_ns' => function_exists('hrtime') ? (int)hrtime(true) : null,
356 'request_id' => $requestId,
357 'event' => $event,
358 'checkpoint_id' => $checkpointId,
359 'pid' => ABJ_404_Solution_PhpRuntimeCapabilityAdapter::processId(),
360 ));
361 }
362
363 /**
364 * @param array<string, mixed> $fields
365 * @return array<string, mixed>
366 */
367 private static function selectSafeFields(array $fields): array {
368 $safe = array();
369 foreach (self::SAFE_FIELDS as $field) {
370 if (array_key_exists($field, $fields)
371 && (is_scalar($fields[$field]) || $fields[$field] === null)) {
372 $safe[$field] = $fields[$field];
373 }
374 }
375 return $safe;
376 }
377
378 /** @param array<string, mixed> $write */
379 private static function writeStatus(array $write): string {
380 return ($write['status'] ?? '') === 'complete' ? 'complete' : 'failed';
381 }
382
383 /** @return array{status: string, reason: string} */
384 private static function failure(string $reason): array {
385 return array('status' => 'failed', 'reason' => $reason);
386 }
387
388 private static function activeCheckpointId(
389 string $requestId,
390 string $boundary,
391 string $operationId
392 ): string {
393 $identity = hash('sha256', $requestId . '|' . $boundary . '|' . $operationId);
394 return 'op-' . substr(strtr($identity, '0123456789', 'ghijklmnop'), 0, 20);
395 }
396
397 private static function newCheckpointId(): string {
398 self::$checkpointSequence++;
399 $identity = dechex(
400 ABJ_404_Solution_PhpRuntimeCapabilityAdapter::processNumericToken()
401 ) . '-'
402 . dechex(function_exists('hrtime') ? (int)hrtime(true) : 0) . '-'
403 . dechex(self::$checkpointSequence);
404 return strtr($identity, '0123456789abcdef', 'ghijklmnopqrstuv');
405 }
406
407 private static function nowFloat(): ?float {
408 if (function_exists('abj_clock')) {
409 return abj_clock()->nowFloat();
410 }
411 if (class_exists('ABJ_404_Solution_SystemClock')) {
412 return (new ABJ_404_Solution_SystemClock())->nowFloat();
413 }
414 return null;
415 }
416
417 private static function reportFailure(string $message): void {
418 abj404_logPhpFallback('durable-operation-recorder', $message);
419 }
420 }
421