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 / redirects / RedirectsRetentionService.php

RedirectsRetentionService.php in 404 Solution trunk, at includes/redirects/RedirectsRetentionService.php

362 lines 14.4 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__ . '/RedirectsRetentionPolicy.php';
8 require_once __DIR__ . '/RedirectsCleanupRepository.php';
9
10 /**
11 * Scheduled-maintenance workflow for the redirects table.
12 *
13 * Owns the redirect/log retention pruning, auto-redirect expiry, junk
14 * auto-trash, orphan cleanup, and the coordinated cron run. Previously fronted
15 * by RedirectsRetentionServiceInterface, but that
16 * interface had a single implementer and no narrowing consumer, so it was
17 * inlined to remove the unrealized abstraction.
18 */
19 class ABJ_404_Solution_RedirectsRetentionService {
20
21 /** @var ABJ_404_Solution_DatabaseCore */
22 private $dbCore;
23
24 /** @var ABJ_404_Solution_DatabaseConnectionManager */
25 private $connectionManager;
26
27 /** @var ABJ_404_Solution_Functions */
28 private $f;
29
30 /** @var ABJ_404_Solution_Logging */
31 private $logger;
32
33 /** @var ABJ_404_Solution_RedirectsRetentionPolicy */
34 private $retentionPolicy;
35
36 /** @var ABJ_404_Solution_RedirectsCleanupRepository */
37 private $cleanupRepository;
38
39 /**
40 * @param ABJ_404_Solution_DatabaseCore $dbCore
41 * @param ABJ_404_Solution_RedirectsRepositoryInterface $redirectsRepo
42 * @param ABJ_404_Solution_Functions|null $functions
43 * @param ABJ_404_Solution_Logging|null $logging
44 * @param ABJ_404_Solution_DatabaseConnectionManager|null $connectionManager
45 * @param ABJ_404_Solution_RedirectsRetentionPolicy|null $retentionPolicy
46 * @param ABJ_404_Solution_RedirectsCleanupRepository|null $cleanupRepository
47 */
48 public function __construct(
49 ABJ_404_Solution_DatabaseCore $dbCore,
50 ABJ_404_Solution_RedirectsRepositoryInterface $redirectsRepo,
51 $functions = null,
52 $logging = null,
53 $connectionManager = null,
54 ?ABJ_404_Solution_RedirectsRetentionPolicy $retentionPolicy = null,
55 ?ABJ_404_Solution_RedirectsCleanupRepository $cleanupRepository = null
56 ) {
57 $this->dbCore = $dbCore;
58 $this->f = $functions !== null ? $functions : abj_service('functions');
59 $this->logger = $logging !== null ? $logging : abj_service('logging');
60 $this->connectionManager = $connectionManager !== null ? $connectionManager : $dbCore->connectionManager();
61 $this->retentionPolicy = $retentionPolicy !== null
62 ? $retentionPolicy
63 : new ABJ_404_Solution_RedirectsRetentionPolicy();
64 $this->cleanupRepository = $cleanupRepository !== null
65 ? $cleanupRepository
66 : new ABJ_404_Solution_RedirectsCleanupRepository(
67 $dbCore,
68 $redirectsRepo,
69 $this->f,
70 $this->logger,
71 $this->retentionPolicy
72 );
73 }
74
75 /** @return int Number of orphaned auto redirects removed. */
76 public function cleanupOrphanedAutoRedirects(): int {
77 return $this->cleanupRepository->cleanupOrphanedAutoRedirects();
78 }
79
80 /**
81 * @param array<string, mixed> $options
82 * @param int $now
83 * @param string $optionKey
84 * @param string $statusList
85 * @param string $debugMessageType
86 * @return int
87 */
88 public function deleteOldRedirectsByType($options, $now, $optionKey, $statusList, $debugMessageType) {
89 return $this->cleanupRepository->deleteOldRedirectsByType($options, $now, $optionKey, $statusList, $debugMessageType);
90 }
91
92 /**
93 * @param int $daysToKeep
94 * @param int $now
95 * @return int
96 */
97 public function deleteOldLogsByAge(int $daysToKeep, int $now): int {
98 return $this->cleanupRepository->deleteOldLogsByAge($daysToKeep, $now);
99 }
100
101 /** @return string Human-readable summary of the cron run. */
102 function deleteOldRedirectsCron() {
103 $viewRead = abj_service('view_read_service');
104 $abj404logic = abj_service('plugin_logic');
105
106 $options = abj_service('options_repository')->getOptions(true);
107 $now = abj_clock()->now();
108 $manually_fired = $this->isManualMaintenanceRun();
109
110 $upgradesEtc = abj_service('database_upgrades');
111 $upgradesEtc->components()->bootstrapUpgrade()->createDatabaseTables(false);
112
113 $this->connectionManager->ensureConnection();
114
115 $this->deleteTempExportFile($abj404logic);
116
117 $duplicateRowsDeleted = $this->removeDuplicatesCron();
118 $deletions = $this->deleteConfiguredOldRedirects($options, $now);
119 $orphanedCount = $this->cleanupOrphanedAutoRedirects();
120 $junkTrashedCount = $this->autoTrashJunkCapturedUrls($options);
121 $oldLogRowsDeletedBySize = $this->deleteOldLogsBySize($viewRead, $options);
122
123 $logsSizeBytes = $viewRead->getLogDiskUsage();
124 $logSizeMB = round($logsSizeBytes / (1024 * 1000), 2);
125
126 $renamed = $this->limitDebugFileSize();
127 $renamed = $renamed ? "true" : "false";
128
129 $oldLogRowsDeleted = $deletions['logRowsDeletedByAge'] + $oldLogRowsDeletedBySize;
130
131 $message = "deleteOldRedirectsCron. Old captured URLs removed: " .
132 $deletions['capturedURLs'] . ", Old automatic redirects removed: " . $deletions['autoRedirects'] .
133 ", Old manual redirects removed: " . $deletions['manualRedirects'] .
134 ", Orphaned auto redirects removed: " . $orphanedCount .
135 ", Junk URLs auto-trashed: " . $junkTrashedCount .
136 ", Old log lines removed: " . $oldLogRowsDeleted .
137 " (age: " . $deletions['logRowsDeletedByAge'] . ", size: " . $oldLogRowsDeletedBySize . ")" .
138 ", New log size: " . $logSizeMB . "MB" .
139 ", Duplicate rows deleted: " . $duplicateRowsDeleted . ", Debug file size limited: " .
140 $renamed;
141
142 $message = $this->appendAdminNotificationMessage($message, $options, $manually_fired, $abj404logic);
143 $message = $this->appendDeveloperLogMessage($message, $options);
144
145 $abj404permalinkCache = abj_service('permalink_cache');
146 $rowsUpdated = $abj404permalinkCache->updatePermalinkCache(15);
147 $message .= ", Permlink cache rows updated: " . $rowsUpdated;
148
149 $manually_fired_String = ($manually_fired) ? 'true' : 'false';
150 $message .= ", User initiated: " . $manually_fired_String;
151
152 $this->logger->infoMessage($message);
153
154 $upgradesEtc = abj_service('database_upgrades');
155 $upgradesEtc->components()->bootstrapUpgrade()->createDatabaseTables();
156
157 $this->dbCore->queryAndGetResults("optimize table {wp_abj404_redirects}");
158
159 $upgradesEtc->components()->pluginUpdateUpgrade()->updatePluginCheck();
160
161 return $message;
162 }
163
164 private function isManualMaintenanceRun(): bool {
165 $manuallyFired = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('manually_fired', 'false');
166 return $this->f->strtolower($manuallyFired) == 'true';
167 }
168
169 /**
170 * @param mixed $abj404logic
171 * @return void
172 */
173 private function deleteTempExportFile($abj404logic): void {
174 $tempFile = null;
175 if (is_object($abj404logic) && method_exists($abj404logic, 'importExport')) {
176 $importExport = $abj404logic->importExport();
177 if (is_object($importExport) && method_exists($importExport, 'getExportFilename')) {
178 $tempFile = $importExport->getExportFilename();
179 }
180 }
181 if (is_string($tempFile) && $tempFile !== '' && file_exists($tempFile)) {
182 ABJ_404_Solution_FileSystemService::safeUnlink($tempFile);
183 }
184 }
185
186 /**
187 * @param array<string, mixed> $options
188 * @param int $now
189 * @return array{capturedURLs: int, autoRedirects: int, manualRedirects: int, logRowsDeletedByAge: int}
190 */
191 private function deleteConfiguredOldRedirects(array $options, int $now): array {
192 $deleted = array(
193 'capturedURLs' => 0,
194 'autoRedirects' => 0,
195 'manualRedirects' => 0,
196 'logRowsDeletedByAge' => 0,
197 );
198
199 if ($this->retentionPolicy->daysFromOptions($options, 'capture_deletion') > 0) {
200 $statusList = ABJ404_STATUS_CAPTURED . ", " . ABJ404_STATUS_IGNORED . ", " . ABJ404_STATUS_LATER;
201 $deleted['capturedURLs'] = $this->deleteOldRedirectsByType($options, $now, 'capture_deletion', $statusList, 'Captured 404');
202 $deleted['logRowsDeletedByAge'] = $this->deleteOldLogsByAge(
203 $this->retentionPolicy->daysFromOptions($options, 'capture_deletion'),
204 $now
205 );
206 }
207
208 if ($this->retentionPolicy->daysFromOptions($options, 'auto_deletion') > 0) {
209 $deleted['autoRedirects'] = $this->deleteOldRedirectsByType(
210 $options,
211 $now,
212 'auto_deletion',
213 (string)ABJ404_STATUS_AUTO,
214 'Automatic redirect'
215 );
216 }
217
218 if ($this->retentionPolicy->daysFromOptions($options, 'manual_deletion') > 0) {
219 $statusList = ABJ404_STATUS_MANUAL . ", " . ABJ404_STATUS_REGEX;
220 $deleted['manualRedirects'] = $this->deleteOldRedirectsByType(
221 $options,
222 $now,
223 'manual_deletion',
224 $statusList,
225 'Manual redirect'
226 );
227 }
228
229 return $deleted;
230 }
231
232 /**
233 * @param ABJ_404_Solution_ViewReadServiceInterface $viewRead
234 * @param array<string, mixed> $options
235 * @return int
236 */
237 private function deleteOldLogsBySize($viewRead, array $options): int {
238 $logsSizeBytes = $viewRead->getLogDiskUsage();
239 $maxLogSizeRaw = array_key_exists('maximum_log_disk_usage', $options) ? $options['maximum_log_disk_usage'] : 100;
240 $maxLogSizeBytes = (is_int($maxLogSizeRaw) || is_float($maxLogSizeRaw) || is_string($maxLogSizeRaw))
241 ? (int)$maxLogSizeRaw * 1024 * 1000
242 : 100 * 1024 * 1000;
243
244 if ($logsSizeBytes <= $maxLogSizeBytes) {
245 return 0;
246 }
247
248 $totalLogLines = $viewRead->getLogsCount(0);
249 $averageSizePerLine = max($logsSizeBytes, 1) / max($totalLogLines, 1);
250 $logLinesToKeep = ceil($maxLogSizeBytes / $averageSizePerLine);
251 $logLinesToDelete = max($totalLogLines - $logLinesToKeep, 0);
252 if ($logLinesToDelete <= 0) {
253 return 0;
254 }
255
256 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/deleteOldLogs.sql");
257 $query = $this->f->str_replace('{lines_to_delete}', (string)$logLinesToDelete, $query);
258 $results = $this->dbCore->queryAndGetResults($query);
259 $oldLogRowsDeletedBySizeRaw = $results['rows_affected'] ?? 0;
260 return (is_int($oldLogRowsDeletedBySizeRaw) || is_float($oldLogRowsDeletedBySizeRaw) || is_string($oldLogRowsDeletedBySizeRaw))
261 ? (int)$oldLogRowsDeletedBySizeRaw
262 : 0;
263 }
264
265 /**
266 * @param string $message
267 * @param array<string, mixed> $options
268 * @param bool $manuallyFired
269 * @param mixed $abj404logic
270 * @return string
271 */
272 private function appendAdminNotificationMessage(string $message, array $options, bool $manuallyFired, $abj404logic): string {
273 $adminEmailVal = array_key_exists('admin_notification_email', $options) ? $options['admin_notification_email'] : '';
274 $adminEmail = is_string($adminEmailVal) ? $adminEmailVal : '';
275 if ($this->f->strlen(trim($adminEmail)) <= 5) {
276 return $message . ', Admin email notification option turned off.';
277 }
278
279 if ($manuallyFired) {
280 return $message . ', The admin email notification option is skipped for user initiated maintenance runs.';
281 }
282
283 if (!is_object($abj404logic) || !method_exists($abj404logic, 'pageOrdering')) {
284 $this->logger->warn('Admin email notification skipped: plugin logic pageOrdering unavailable.');
285 return $message . ', Admin email notification option unavailable.';
286 }
287
288 $pageOrdering = $abj404logic->pageOrdering();
289 if (!is_object($pageOrdering) || !method_exists($pageOrdering, 'emailCaptured404Notification')) {
290 $this->logger->warn('Admin email notification skipped: page ordering email sender unavailable.');
291 return $message . ', Admin email notification option unavailable.';
292 }
293
294 return $message . ', ' . $pageOrdering->emailCaptured404Notification();
295 }
296
297 /**
298 * @param string $message
299 * @param array<string, mixed> $options
300 * @return string
301 */
302 private function appendDeveloperLogMessage(string $message, array $options): string {
303 if (!isset($options['send_error_logs']) || $options['send_error_logs'] != '1') {
304 return $message;
305 }
306
307 // Drain any pending crash beacon first: a fatal/OOM that could not phone
308 // home at the time is the highest-value signal, and it is independent of
309 // whether there is also a fresh error line to email this run.
310 if ($this->logger->drainCrashBeaconIfNecessary()) {
311 $message .= ", Crash beacon reported to developer.";
312 }
313
314 if ($this->logger->emailErrorLogIfNecessary()) {
315 return $message . ", Log file emailed to developer.";
316 }
317
318 if ($this->logger->sendHeartbeatIfDueWeekly()) {
319 return $message . ", Heartbeat log emailed to developer.";
320 }
321
322 return $message;
323 }
324
325 /** @return bool Whether the debug log file was rotated. */
326 function limitDebugFileSize(): bool {
327 $renamed = false;
328
329 $mbFileSize = $this->logger->getDebugFileSize() / 1024 / 1000;
330 if ($mbFileSize > 10) {
331 $this->logger->limitDebugFileSize();
332 $renamed = true;
333 }
334
335 return $renamed;
336 }
337
338 /** @return int Number of duplicate redirect rows removed. */
339 function removeDuplicatesCron(): int {
340 return $this->cleanupRepository->removeDuplicatesCron();
341 }
342
343 /**
344 * @param array<string, mixed> $options Plugin options array.
345 * @return int Number of captured URLs auto-trashed.
346 */
347 function autoTrashJunkCapturedUrls(array $options): int {
348 return $this->cleanupRepository->autoTrashJunkCapturedUrls($options);
349 }
350
351 /**
352 * Move auto-created redirects to trash if they are older than the configured expiration.
353 *
354 * @return int Number of redirects moved to trash.
355 */
356 public function expireOldAutoRedirects(): int {
357 $options = abj_service('options_repository')->getOptions();
358 $days = $this->retentionPolicy->daysFromOptions(is_array($options) ? $options : array(), 'auto_302_expiration_days');
359 return $this->cleanupRepository->expireOldAutoRedirects($days, abj_clock()->now());
360 }
361 }
362