feat(cache): Tool lit/cache-clear zum gezielten Leeren des Caches

lit/cache-clear leert den Seiten-Cache von WP Super Cache für die ganze Website (scope all, Recht manage_options) oder einen Beitrag (scope post, Recht edit_post); Probelauf ohne confirm, Ausführen mit confirm: true. Das CapabilityGate sperrt das Tool, wenn WP Super Cache bricht; eine Sperre zur Laufzeit wird als Fehler gemeldet. Listener und Tool teilen die 30-s-Drosselung mit Nachlauf (SuperCachePurger); der Listener ignoriert die Einträge des Tools. Behoben vor dem Commit: die Verdrahtung des Purgers erfasste die Registry zu früh (immer gesperrt), jetzt per Referenz und mit Test über Plugin::integrations().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-06 19:05:21 +01:00
co-authored by Claude Opus 5.5
parent d3e5d4fac0
commit 1ac347e76f
12 changed files with 883 additions and 86 deletions
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\SuperCache;
use Closure;
use LIT\MCP\Log;
defined('ABSPATH') || exit;
/**
* The two purges of WP Super Cache, shared by SuperCacheListener (after every write) and lit/cache-clear: the
* capability check, the 30 s throttle of the whole-cache purge with its one trailing purge (TRAILING_HOOK) and the
* stamp (PURGED_AT). The state lives in the transient and the cron event, so any number of instances share it.
*/
final class SuperCachePurger
{
public const TRAILING_HOOK = 'lit_mcp_wpsc_trailing_purge';
/** Transient with the time of the last whole-cache purge (also in Uninstaller). */
public const PURGED_AT = 'lit_mcp_wpsc_purged_at';
/** Seconds between two whole-cache purges. */
private const THROTTLE = 30;
public const PURGED = 'purged';
public const THROTTLED = 'throttled';
public const LOCKED = 'locked';
private Closure $usable;
private Closure $cacheEnabled;
private Closure $purgePost;
private Closure $purgeAll;
private bool $warned = false;
/**
* Defaults are the real WPSC functions; closures only for tests.
*
* @param Closure(SuperCacheFeature): bool $usable Compatibility::usable().
* @param (Closure(): bool)|null $cacheEnabled WPSC caching switched on ($cache_enabled).
* @param (Closure(int): void)|null $purgePost wp_cache_post_edit($postId).
* @param (Closure(int): void)|null $purgeAll wp_cache_clear_cache($blogId), 0 = all sites.
*/
public function __construct(
Closure $usable,
?Closure $cacheEnabled = null,
?Closure $purgePost = null,
?Closure $purgeAll = null
) {
$this->usable = $usable;
$this->cacheEnabled = $cacheEnabled ?? static fn (): bool => ! empty($GLOBALS['cache_enabled']);
$this->purgePost = $purgePost ?? static function (int $id): void {
wp_cache_post_edit($id);
};
$this->purgeAll = $purgeAll ?? static function (int $blog): void {
wp_cache_clear_cache($blog);
};
}
public function cacheEnabled(): bool
{
return ($this->cacheEnabled)();
}
/** Whether a whole-cache purge now would only be postponed. */
public function throttled(): bool
{
return self::within($this->lastPurge());
}
private static function within(?int $last): bool
{
return $last !== null && time() - $last < self::THROTTLE;
}
/** Time of the last whole-cache purge; null = none within the lock time. Transients may come back as strings. */
private function lastPurge(): ?int
{
$last = get_transient(self::PURGED_AT);
return is_numeric($last) ? (int) $last : null;
}
/** wp_cache_post_edit for one post; false = locked. Never throttled. */
public function post(int $postId): bool
{
if (! $this->allowed(SuperCacheFeature::PurgePost)) {
return false;
}
($this->purgePost)($postId);
return true;
}
/**
* Whole cache, at most every THROTTLE seconds. Inside the lock time one trailing purge is scheduled (cron events
* are per site: the callback runs on this blog, no blog switch, wp_cache_clear_cache takes the blog ID).
*
* @return self::PURGED|self::THROTTLED|self::LOCKED
*/
public function everything(): string
{
if (! $this->allowed(SuperCacheFeature::PurgeAll)) {
return self::LOCKED;
}
$last = $this->lastPurge();
if ($last !== null && self::within($last)) {
if (wp_next_scheduled(self::TRAILING_HOOK) === false) {
wp_schedule_single_event($last + self::THROTTLE, self::TRAILING_HOOK);
}
return self::THROTTLED;
}
// Known and unavoidable on a multisite: WPSC also empties the shared blogs/ meta directory of all sites
// in its blog branch (wp-cache-phase2.php, wp_cache_clear_cache).
($this->purgeAll)(is_multisite() ? get_current_blog_id() : 0);
set_transient(self::PURGED_AT, time(), self::THROTTLE);
return self::PURGED;
}
private function allowed(SuperCacheFeature $feature): bool
{
if (($this->usable)($feature)) {
return true;
}
if (! $this->warned) {
$this->warned = true;
Log::line(sprintf('WP Super Cache not purged: %s not usable (see tab Compatibility)', $feature->id()));
}
return false;
}
}