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 / feedback / ErrorEmailDedupeState.php

ErrorEmailDedupeState.php in 404 Solution trunk, at includes/feedback/ErrorEmailDedupeState.php

152 lines 6.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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