*/ private $hookCounts = array(); /** @var array{src: string, calls: int|null, reads: int|null, writes: int|null, hits: int|null, misses: int|null, ms: float|null} */ private $cacheSnapshot; /** @var ABJ_404_Solution_CacheMetricsProbeTracer|null */ private $cacheMetricsProbe = null; /** @var ABJ_404_Solution_RowRenderOperationTracer|null */ private $operationTracer = null; /** * Open a progress-tracked row loop. Returns a live tracker on an * instrumented admin-AJAX request and an inert one everywhere else, so * call sites need no conditional and the front-end 404 path pays nothing * beyond one static call per table render. * * @param string $label Loop identity, e.g. 'redirects_rows'. * @param int $totalRows Number of rows the loop is about to format. */ public static function begin(string $label, int $totalRows): self { $progress = new self($label, $totalRows); if ($progress->requestId === '') { return $progress; } try { ABJ_404_Solution_AjaxCheckpointLogger::recordFrequent($progress->requestId, 'row_loop_start', array( 'loop' => $progress->label, 'rows' => $progress->total, 'every' => $progress->interval, )); } catch (Throwable $e) { self::reportFailure('row loop start failed: ' . $e->getMessage()); } return $progress; } private function __construct(string $label, int $totalRows) { $this->label = substr($label, 0, 64); $this->total = max(0, $totalRows); $this->interval = max(1, (int)ceil($this->total / self::MAX_PROGRESS_RECORDS)); $this->startedAt = abj_clock()->nowFloat(); $this->activityStartedAt = $this->startedAt; $this->requestId = self::resolveRequestId(); if ($this->requestId !== '') { $this->hookCounts = self::currentHookCounts(); $this->cacheMetricsProbe = new ABJ_404_Solution_CacheMetricsProbeTracer($this->requestId); $this->cacheSnapshot = $this->currentCacheSnapshot('initial'); $this->operationTracer = ABJ_404_Solution_RowRenderOperationTracer::begin($this->requestId); } } /** * Announce the row that is about to be formatted. Never throws. * * @param array $row The row being formatted. Only its * primary key is read, and only as a hash. */ public function tick(array $row): void { $this->index++; if ($this->requestId === '' || $this->emitted >= self::MAX_PROGRESS_RECORDS) { if ($this->operationTracer !== null) { $this->operationTracer->enterRow(); } return; } // Rows 1, 1+interval, 1+2*interval ... so the FIRST row is always // announced: a loop that hangs immediately is otherwise reported as a // loop that never started, which points at the wrong half of the stage. if (($this->index - 1) % $this->interval !== 0) { if ($this->operationTracer !== null) { $this->operationTracer->enterRow(); } return; } $this->emitted++; try { $record = array( 'loop' => $this->label, 'row' => $this->index, 'rows' => $this->total, 'rid' => self::hashedRowKey($row), 'ms' => self::elapsedMs($this->startedAt), ); ABJ_404_Solution_AjaxCheckpointLogger::recordFrequent( $this->requestId, 'row_loop_progress', array_merge($record, $this->activityFields(max(0, $this->index - 1), 'progress')) ); } catch (Throwable $e) { self::reportFailure('row loop progress failed: ' . $e->getMessage()); } if ($this->operationTracer !== null) { $this->operationTracer->enterRow(); } } /** * Close the loop, recording how many rows it actually formatted. Never * throws. The absence of this record next to a present `row_loop_start` is * itself the finding: the loop was entered and did not come back. */ public function finish(): void { if ($this->requestId === '') { return; } if ($this->operationTracer !== null) { $this->operationTracer->finish(); } try { $record = array( 'loop' => $this->label, 'rows' => $this->total, 'rows_done' => $this->index, 'ms' => self::elapsedMs($this->startedAt), ); if ($this->emitted < self::MAX_PROGRESS_RECORDS) { $record = array_merge($record, $this->activityFields($this->index, 'finish')); } ABJ_404_Solution_AjaxCheckpointLogger::recordFrequent( $this->requestId, 'row_loop_end', $record ); } catch (Throwable $e) { self::reportFailure('row loop end failed: ' . $e->getMessage()); } } /** Rows formatted so far. Test-visible accounting; production reads the journal. */ public function rowsSeen(): int { return $this->index; } private static function resolveRequestId(): string { try { return ABJ_404_Solution_AjaxDiagnosticRequestPolicy::instrumentedRequestIdFromGlobalContext(); } catch (Throwable $e) { self::reportFailure('row loop arming failed: ' . $e->getMessage()); return ''; } } /** * A short SHA-256 prefix of the row's primary key, or '' when the row has * no usable key. * * Hashed rather than emitted plainly because the journal's standing * guarantee is that it carries no row-level site data, and a stable hash * is enough for what this field is for: telling whether two attempts * stopped on the SAME row. * * @param array $row */ private static function hashedRowKey(array $row): string { foreach (self::ROW_KEY_COLUMNS as $column) { if (isset($row[$column]) && is_scalar($row[$column]) && (string)$row[$column] !== '') { return substr(hash('sha256', (string)$row[$column]), 0, 12); } } return ''; } private static function elapsedMs(float $startedAt): int { return max(0, (int)round((abj_clock()->nowFloat() - $startedAt) * 1000)); } /** * Snapshot request-local hook counters and object-cache metrics as deltas * for the rows completed since the previous bounded checkpoint. * * Hook counts are temporal evidence, not callback timers. `chunk_ms` is * the only honest wall-time boundary: WordPress exposes trigger counters * and the active hook stack, but not foreign callback durations. * * @return array */ private function activityFields(int $completedRows, string $phase): array { $sampledAt = abj_clock()->nowFloat(); $currentHooks = self::currentHookCounts(); $hookDeltas = array(); foreach ($currentHooks as $hook => $count) { $delta = $count - ($this->hookCounts[$hook] ?? 0); if ($delta > 0) { $hookDeltas[$hook] = $delta; } } uksort($hookDeltas, static function (string $left, string $right) use ($hookDeltas): int { $byCount = $hookDeltas[$right] <=> $hookDeltas[$left]; return ($byCount !== 0) ? $byCount : strcmp($left, $right); }); $hookTop = array(); foreach (array_slice($hookDeltas, 0, 1, true) as $hook => $calls) { $redactedHook = self::redactedHookName($hook); $hookTop[$redactedHook] = ($hookTop[$redactedHook] ?? 0) + $calls; } $currentCache = $this->currentCacheSnapshot($phase); $sameCacheSource = $currentCache['src'] === $this->cacheSnapshot['src']; $cacheCalls = $sameCacheSource ? self::numericDelta($currentCache['calls'], $this->cacheSnapshot['calls']) : null; $cacheMilliseconds = $sameCacheSource ? self::numericDelta($currentCache['ms'], $this->cacheSnapshot['ms']) : null; $fields = array( 'chunk_rows' => max(0, $completedRows - $this->sampledRows), 'chunk_ms' => max(0, (int)round(($sampledAt - $this->activityStartedAt) * 1000)), 'hook_active' => self::activeHookName(), 'hook_calls' => array_sum($hookDeltas), 'hook_top' => $hookTop, 'cache_src' => $currentCache['src'], 'cache_calls' => ($cacheCalls === null) ? null : (int)$cacheCalls, 'cache_ms' => ($cacheMilliseconds === null) ? null : round($cacheMilliseconds, 3), ); $this->activityStartedAt = $sampledAt; $this->sampledRows = $completedRows; $this->hookCounts = $currentHooks; $this->cacheSnapshot = $currentCache; return $fields; } /** @return array */ private static function currentHookCounts(): array { $counts = array(); foreach (array('wp_filters', 'wp_actions') as $globalName) { $source = $GLOBALS[$globalName] ?? null; if (!is_array($source)) { continue; } foreach ($source as $hook => $count) { if (is_numeric($count) && (int)$count >= 0) { $name = (string)$hook; $counts[$name] = ($counts[$name] ?? 0) + (int)$count; } } } return $counts; } private static function activeHookName(): string { $stack = $GLOBALS['wp_current_filter'] ?? null; if (!is_array($stack) || empty($stack)) { return ''; } $active = end($stack); return is_string($active) ? self::redactedHookName($active) : ''; } /** * Keep conventional static hook names actionable. Hash dynamic names that * could contain a URL, email, user value, or another site-specific token. */ private static function redactedHookName(string $hook): string { if (preg_match('/^[A-Za-z_][A-Za-z0-9_.:-]{0,63}$/', $hook) === 1) { return preg_replace('/[0-9]+/', '#', $hook) ?? ''; } return 'hook#' . substr(hash('sha256', $hook), 0, 12); } /** * Read a durably bracketed cumulative cache snapshot. * * @return array{src: string, calls: int|null, reads: int|null, writes: int|null, hits: int|null, misses: int|null, ms: float|null} */ private function currentCacheSnapshot(string $phase): array { $cache = $GLOBALS['wp_object_cache'] ?? null; return $this->cacheMetricsProbe !== null ? $this->cacheMetricsProbe->snapshot($cache, $phase) : array( 'src' => 'none', 'calls' => null, 'reads' => null, 'writes' => null, 'hits' => null, 'misses' => null, 'ms' => null, ); } /** * @param float|int|null $current * @param float|int|null $previous */ private static function numericDelta($current, $previous): ?float { if ($current === null || $previous === null) { return null; } $delta = (float)$current - (float)$previous; return ($delta >= 0) ? $delta : null; } private static function reportFailure(string $message): void { abj404_logPhpFallback('ajax-row-loop', $message); } }