| 1 |
<?php |
| 2 |
|
| 3 |
|
| 4 |
if (!defined('ABSPATH')) { |
| 5 |
exit; |
| 6 |
} |
| 7 |
|
| 8 |
/** |
| 9 |
* Persistence layer for the "last sent error line" dedupe pointer used by |
| 10 |
* ABJ_404_Solution_Logging::emailErrorLogIfNecessary(). |
| 11 |
* |
| 12 |
* Owns the round-trip across three storage locations: |
| 13 |
* |
| 14 |
* 1. The on-disk sentinel file (abj404_debug_sent_line.txt) -- authoritative |
| 15 |
* across requests and survives cron restarts. |
| 16 |
* 2. The logging state store (the last-sent-line scalar inside the |
| 17 |
* abj404_settings row) -- fallback when the sentinel file is absent or |
| 18 |
* unreadable, and the only durable copy on hosts with ephemeral |
| 19 |
* filesystems. Reached ONLY through ABJ_404_Solution_LoggingStateStore, |
| 20 |
* whose raw accessors never re-enter the settings normalize-and-log |
| 21 |
* pipeline (the 4.3.0 logging<->options recursion guard). |
| 22 |
* 3. Request-local statics -- prevent a single PHP request that triggers |
| 23 |
* multiple emailErrorLogIfNecessary() calls (e.g. via shutdown handlers) |
| 24 |
* from emitting duplicate sends before the on-disk pointer has caught up. |
| 25 |
* |
| 26 |
* Pure persistence: no policy, no presentation, no email dispatch. Returns |
| 27 |
* the current pointer, decides whether a candidate error-line is already |
| 28 |
* recorded, and writes the pointer forward. |
| 29 |
*/ |
| 30 |
class ABJ_404_Solution_ErrorEmailDedupeState { |
| 31 |
|
| 32 |
/** @var int Latest error-log line emailed during this PHP request. */ |
| 33 |
private static $lastSentErrorLineThisRequest = 0; |
| 34 |
/** @var string Latest error signature emailed during this PHP request. */ |
| 35 |
private static $lastSentErrorSignatureThisRequest = ''; |
| 36 |
/** @var string Debug file path associated with the request-local dedupe state. */ |
| 37 |
private static $lastSentDebugFilePathThisRequest = ''; |
| 38 |
|
| 39 |
/** |
| 40 |
* Recursion-safe accessor for the durable last-sent-line scalar. Owns the |
| 41 |
* storage key; reads/writes only via its raw accessors so the dedupe |
| 42 |
* pointer never passes through the settings normalize pipeline. |
| 43 |
* |
| 44 |
* @var ABJ_404_Solution_LoggingStateStore |
| 45 |
*/ |
| 46 |
private $loggingStateStore; |
| 47 |
|
| 48 |
/** @param ABJ_404_Solution_LoggingStateStore $loggingStateStore */ |
| 49 |
public function __construct($loggingStateStore) { |
| 50 |
$this->loggingStateStore = $loggingStateStore; |
| 51 |
} |
| 52 |
|
| 53 |
/** |
| 54 |
* Read the latest known "last sent error line" pointer from disk, falling |
| 55 |
* back to the durable copy in the logging state store. Request-local |
| 56 |
* statics are merged in isAlreadySent() so a fresh read from this method |
| 57 |
* always reflects only the durable state. |
| 58 |
* |
| 59 |
* Precedence: the on-disk sentinel file first (when present and >= 1), else |
| 60 |
* the logging state store's last-sent-line scalar, else -1. |
| 61 |
* |
| 62 |
* @param string $sentinelFilePath |
| 63 |
* @return int Last-sent line number, or -1 when no record exists. |
| 64 |
*/ |
| 65 |
public function readSentLine(string $sentinelFilePath): int { |
| 66 |
$sentLine = -1; |
| 67 |
if (file_exists($sentinelFilePath)) { |
| 68 |
$sentLine = absint( |
| 69 |
ABJ_404_Solution_FileSystemService::readFileContents($sentinelFilePath, false)); |
| 70 |
} |
| 71 |
if ($sentLine < 1) { |
| 72 |
$sentLine = $this->loggingStateStore->getLastSentLine(); |
| 73 |
} |
| 74 |
return $sentLine; |
| 75 |
} |
| 76 |
|
| 77 |
/** |
| 78 |
* Decide whether the latest-found error line has already been emailed. |
| 79 |
* |
| 80 |
* Combines the durable pointer (sentLine) with the request-local high- |
| 81 |
* water marks. Two requests for the same debug file path within a single |
| 82 |
* PHP process are deduped on either: |
| 83 |
* |
| 84 |
* - the line number having already advanced past the latest, OR |
| 85 |
* - the error signature exactly matching the last one we sent (which |
| 86 |
* catches cases where the log file has been rotated and line numbers |
| 87 |
* reset but the same recurring error is still on top). |
| 88 |
* |
| 89 |
* @param int $sentLine Durable pointer (from readSentLine()). |
| 90 |
* @param array{num: int, line: string|null, total_error_count?: int} $latestErrorLineFound |
| 91 |
* @param string $debugFilePath |
| 92 |
* @return bool true if the latest-found line has already been sent. |
| 93 |
*/ |
| 94 |
public function isAlreadySent(int $sentLine, array $latestErrorLineFound, string $debugFilePath): bool { |
| 95 |
$latestNum = (int)($latestErrorLineFound['num'] ?? -1); |
| 96 |
$latestSignature = (string)($latestErrorLineFound['line'] ?? ''); |
| 97 |
|
| 98 |
$effectiveSentLine = $sentLine; |
| 99 |
if (self::$lastSentDebugFilePathThisRequest === $debugFilePath) { |
| 100 |
$effectiveSentLine = max($effectiveSentLine, self::$lastSentErrorLineThisRequest); |
| 101 |
} |
| 102 |
if ($latestNum <= $effectiveSentLine) { |
| 103 |
return true; |
| 104 |
} |
| 105 |
if (self::$lastSentDebugFilePathThisRequest === $debugFilePath |
| 106 |
&& $latestSignature !== '' |
| 107 |
&& $latestSignature === self::$lastSentErrorSignatureThisRequest) { |
| 108 |
return true; |
| 109 |
} |
| 110 |
return false; |
| 111 |
} |
| 112 |
|
| 113 |
/** |
| 114 |
* Record the just-sent error line forward across all three layers: |
| 115 |
* logging state store, sentinel file, request-local statics. |
| 116 |
* |
| 117 |
* @param string $sentinelFilePath |
| 118 |
* @param string $debugFilePath |
| 119 |
* @param array{num: int, line: string|null, total_error_count?: int} $latestErrorLineFound |
| 120 |
* @return bool false if the sentinel file write failed verification |
| 121 |
* (caller should bail before dispatching mail to avoid a |
| 122 |
* dedupe-pointer regression on the next cron tick); true |
| 123 |
* otherwise. |
| 124 |
*/ |
| 125 |
public function recordSent(string $sentinelFilePath, string $debugFilePath, array $latestErrorLineFound): bool { |
| 126 |
$latestNum = (int)($latestErrorLineFound['num'] ?? -1); |
| 127 |
$latestSignature = (string)($latestErrorLineFound['line'] ?? ''); |
| 128 |
|
| 129 |
self::$lastSentErrorLineThisRequest = $latestNum; |
| 130 |
self::$lastSentErrorSignatureThisRequest = $latestSignature; |
| 131 |
self::$lastSentDebugFilePathThisRequest = $debugFilePath; |
| 132 |
|
| 133 |
$this->loggingStateStore->setLastSentLine($latestNum); |
| 134 |
|
| 135 |
@file_put_contents($sentinelFilePath, (string)$latestNum); |
| 136 |
$fileContents = @file_get_contents($sentinelFilePath); |
| 137 |
return ($fileContents === (string)$latestNum); |
| 138 |
} |
| 139 |
|
| 140 |
/** |
| 141 |
* Test seam: reset the request-local statics. Called from test setUp() to |
| 142 |
* keep ParaTest workers' Logging::emailErrorLogIfNecessary() runs from |
| 143 |
* leaking dedupe state across tests in the same worker. Not used in |
| 144 |
* production. |
| 145 |
*/ |
| 146 |
public static function resetRequestLocalsForTesting(): void { |
| 147 |
self::$lastSentErrorLineThisRequest = 0; |
| 148 |
self::$lastSentErrorSignatureThisRequest = ''; |
| 149 |
self::$lastSentDebugFilePathThisRequest = ''; |
| 150 |
} |
| 151 |
} |
| 152 |
|