feat(cache): WP Super Cache über den Kompatibilitätskern anbinden

SuperCacheFeature, SuperCacheCompatibility (LockOnFailure, getestet 3.1.5, Version aus dem Plugin-Header) und SuperCacheIntegration ohne Tools. Der SuperCacheListener sammelt Änderungen über lit_mcp_changed und leert einmal am Request-Ende: einen Beitrag per wp_cache_post_edit, mehrere Beiträge oder seitenweite Änderungen per wp_cache_clear_cache (Multisite: eigener Blog). Komplett-Purges höchstens alle 30 s, mit genau einem Nachlauf per WP-Cron. Freigabe-Einträge leeren nichts. Uninstaller räumt Optionen, Transient und Cron-Hook ab; Reiter und lit-site-status zeigen das Profil automatisch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-06 14:50:35 +01:00
co-authored by Claude Opus 5.5
parent c383b99e7d
commit 05bf890481
16 changed files with 1054 additions and 21 deletions
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\SuperCache;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\ConstantRequirement;
use LIT\MCP\Compatibility\GlobalRequirement;
use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
defined('ABSPATH') || exit;
/**
* WP Super Cache as a compatibility profile: the two purge functions SuperCacheListener calls, checked against
* 3.1.5 (wp-cache-phase2.php). wp_cache_post_edit is no documented API: if a release drops it, only the purge is
* locked (visible in the tab and in lit-site-status). Both functions read the global $cache_path, which wp-cache.php
* sets on load from wp-cache-config.php or its sample; wp_cache_post_edit also reads $blog_cache_dir
* (wp-cache-base.php).
*/
final class SuperCacheCompatibility
{
public const LABEL = 'WP Super Cache';
public static function profile(): Profile
{
$all = SuperCacheFeature::cases();
return new Profile(
id: 'wpsc',
label: self::LABEL,
tier: Tier::LockOnFailure,
capabilities: $all,
manifests: [new Manifest(
'WP Super Cache',
calls: [
new Call('wp_cache_post_edit', 1, [SuperCacheFeature::PurgePost]),
new Call('wp_cache_clear_cache', 1, [SuperCacheFeature::PurgeAll]),
],
constants: [new ConstantRequirement('WPCACHEHOME', $all)],
globals: [
new GlobalRequirement('cache_path', $all),
new GlobalRequirement('blog_cache_dir', [SuperCacheFeature::PurgePost]),
]
)],
active: static fn (): bool => defined('WPCACHEHOME'),
version: static function (): ?string {
if (! defined('WPCACHEHOME')) {
return null;
}
// WPSC_VERSION_ID is a build counter, not the plugin version: read the plugin header.
$version = get_file_data(WPCACHEHOME . 'wp-cache.php', ['Version' => 'Version'])['Version'] ?? '';
return $version === '' ? null : $version;
},
testedVersions: ['3.1.5']
);
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\SuperCache;
use LIT\MCP\Compatibility\Capability;
defined('ABSPATH') || exit;
// The installed PHPCompatibility sniffs predate enums (PHP 8.1) and read their methods as plain functions.
// phpcs:disable PHPCompatibility.Variables.ForbiddenThisUseContexts.OutsideObjectContext
/** What the plugin does to WP Super Cache: empty its cache for one post or entirely. */
enum SuperCacheFeature: string implements Capability
{
case PurgePost = 'wpsc-purge-post';
case PurgeAll = 'wpsc-purge-all';
public function id(): string
{
return $this->value;
}
public function label(): string
{
return match ($this) {
self::PurgePost => 'Cache eines Beitrags leeren',
self::PurgeAll => 'Gesamten Cache leeren',
};
}
public function isWrite(): bool
{
return true;
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\SuperCache;
use LIT\MCP\Compatibility\ProvidesCompatibility;
use LIT\MCP\Integrations\Integration;
defined('ABSPATH') || exit;
/**
* WP Super Cache has no tools: the integration only brings the compatibility profile (SuperCacheListener does the
* work, wired by Plugin). Without abilities it is no tool group.
*/
final class SuperCacheIntegration implements Integration, ProvidesCompatibility
{
public function id(): string
{
return 'wpsc';
}
public function label(): string
{
return SuperCacheCompatibility::LABEL;
}
public function isAvailable(): bool
{
return defined('WPCACHEHOME');
}
public function abilities(): array
{
return [];
}
public function compatibilities(): array
{
return [SuperCacheCompatibility::profile()];
}
}
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\SuperCache;
use Closure;
use LIT\MCP\Admin\CompatibilityTab;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Log;
use Throwable;
defined('ABSPATH') || exit;
/**
* Empties the WP Super Cache after a write of this plugin (hook lit_mcp_changed). WPSC purges on its own only what
* goes through wp_update_post: field, term and featured-image updates, options, menu locations, Divi design,
* theme builder and global library modules it never sees.
*
* Purge with trailing run: the listener only collects, one purge runs at the end of the request (shutdown). A post
* of a public post type (not attachment) that still exists: that post (wp_cache_post_edit, which skips drafts and
* honours the user's "clear all on edit" setting). More than one post, or anything else: the whole cache of this
* site, once. The whole-cache purge runs at most every THROTTLE seconds (a client looping over media updates must
* not keep the cache empty): a purge inside the lock time schedules exactly one trailing purge (TRAILING_HOOK) at
* its end. Single-post purges are never throttled. The release/withdraw entries of the check workflow change
* nothing on the site and purge nothing.
*/
final class SuperCacheListener
{
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;
private Closure $usable;
private Closure $cacheEnabled;
private Closure $purgePost;
private Closure $purgeAll;
private Closure $postIdFor;
/** @var array<int, true> posts changed in this request */
private array $posts = [];
private bool $everything = false;
private bool $queued = false;
private bool $warned = false;
/**
* Defaults are the real WPSC and WordPress 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.
* @param (Closure(string, string): ?int)|null $postIdFor Post ID if objectType/objectId name an existing
* post of a public type but attachment.
*/
public function __construct(
Closure $usable,
?Closure $cacheEnabled = null,
?Closure $purgePost = null,
?Closure $purgeAll = null,
?Closure $postIdFor = 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);
};
$this->postIdFor = $postIdFor ?? self::publicPostId(...);
}
public static function create(Compatibility $compatibility): self
{
return new self($compatibility->usable(...));
}
public function register(): void
{
add_action(ChangeLog::CHANGED_ACTION, [$this, 'onChange'], 10, 4);
add_action(self::TRAILING_HOOK, [$this, 'trailingPurge']);
}
/** lit_mcp_changed: only collects, see flush(). Never throws. */
public function onChange(string $ability, string $objectType, string $objectId, ?int $logId = null): void
{
try {
if (CompatibilityTab::isReleaseAbility($ability) || ! ($this->cacheEnabled)()) {
return;
}
$postId = ($this->postIdFor)($objectType, $objectId);
if ($postId === null) {
$this->everything = true;
} else {
$this->posts[$postId] = true;
}
if (! $this->queued) {
$this->queued = true;
add_action('shutdown', [$this, 'flush']);
}
} catch (Throwable $e) {
Log::failure('WP Super Cache purge', $e);
}
}
/** shutdown: the one purge of the request. Never throws: a failed purge must not break the response. */
public function flush(): void
{
$posts = array_keys($this->posts);
$everything = $this->everything;
$this->posts = [];
$this->everything = false;
try {
if ($everything || count($posts) > 1) {
$this->purgeEverything();
} elseif ($posts !== []) {
$this->purge(SuperCacheFeature::PurgePost, fn () => ($this->purgePost)($posts[0]));
}
} catch (Throwable $e) {
Log::failure('WP Super Cache purge', $e);
}
}
/** Cron TRAILING_HOOK: the whole-cache purge a throttled one postponed. Same checks as the first try. */
public function trailingPurge(): void
{
try {
if (($this->cacheEnabled)()) {
$this->purgeEverything();
}
} catch (Throwable $e) {
Log::failure('WP Super Cache trailing purge', $e);
}
}
/**
* 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).
*/
private function purgeEverything(): void
{
$this->purge(SuperCacheFeature::PurgeAll, function (): void {
$last = get_transient(self::PURGED_AT);
// Transients in the options table come back as strings.
if (is_numeric($last) && time() - (int) $last < self::THROTTLE) {
if (wp_next_scheduled(self::TRAILING_HOOK) === false) {
wp_schedule_single_event((int) $last + self::THROTTLE, self::TRAILING_HOOK);
}
return;
}
// 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);
});
}
/** @param Closure(): void $purge */
private function purge(SuperCacheFeature $feature, Closure $purge): void
{
if (! ($this->usable)($feature)) {
if (! $this->warned) {
$this->warned = true;
Log::line(sprintf('WP Super Cache not purged: %s not usable (see tab Compatibility)', $feature->id()));
}
return;
}
$purge();
}
private static function publicPostId(string $objectType, string $objectId): ?int
{
if ($objectType === 'attachment' || ! ctype_digit($objectId)) {
return null;
}
$type = get_post_type_object($objectType);
if ($type === null || empty($type->public)) {
return null;
}
return get_post((int) $objectId) instanceof \WP_Post ? (int) $objectId : null;
}
}