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
+18 -2
View File
@@ -50,6 +50,8 @@ final class CompatibilityTab implements AdminTab
/** admin-post actions; the nonce action of a request is the action plus "_<profile ID>". */
public const RELEASE_ACTION = 'lit_mcp_compat_release';
public const WITHDRAW_ACTION = 'lit_mcp_compat_withdraw';
private const ABILITY_PREFIX = 'lit/';
private const ABILITY_SUFFIX = '-release';
/** @var Closure(Profile): CheckReportStore */
private Closure $reports;
@@ -71,6 +73,20 @@ final class CompatibilityTab implements AdminTab
$this->releases = $releases ?? ReleaseStore::forProfile(...);
}
/** Ability name of the release and withdraw entries in the change log: lit/<profile>-release. */
public static function releaseAbility(string $profileId): string
{
return self::ABILITY_PREFIX . $profileId . self::ABILITY_SUFFIX;
}
/** Whether the ability names such an entry (any profile). */
public static function isReleaseAbility(string $ability): bool
{
return strlen($ability) > strlen(self::ABILITY_PREFIX . self::ABILITY_SUFFIX)
&& str_starts_with($ability, self::ABILITY_PREFIX)
&& str_ends_with($ability, self::ABILITY_SUFFIX);
}
public function slug(): string
{
return self::SLUG;
@@ -137,7 +153,7 @@ final class CompatibilityTab implements AdminTab
($this->releases)($profile)->add($version, Plugin::VERSION, get_current_user_id(), $report->id);
$this->log?->record(
'lit/' . $profile->id . '-release',
self::releaseAbility($profile->id),
$profile->id,
$version,
sprintf(
@@ -167,7 +183,7 @@ final class CompatibilityTab implements AdminTab
}
$this->log?->record(
'lit/' . $profile->id . '-release',
self::releaseAbility($profile->id),
$profile->id,
$version,
sprintf('Freigabe von %s %s zurückgezogen.', $profile->label, $version)
@@ -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;
}
}
+6 -1
View File
@@ -37,6 +37,8 @@ use LIT\MCP\Integrations\Pods\PodsCompatibility;
use LIT\MCP\Integrations\Pods\PodsFeature;
use LIT\MCP\Integrations\Pods\PodsFieldSource;
use LIT\MCP\Integrations\Pods\PodsIntegration;
use LIT\MCP\Integrations\SuperCache\SuperCacheIntegration;
use LIT\MCP\Integrations\SuperCache\SuperCacheListener;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
@@ -96,6 +98,8 @@ final class Plugin
// Integration-owned hooks (Divi: upgrader hook, check route, CLI command, tab "Divi") – before the settings
// page reads its tabs.
$registry->boot();
// After every write of the plugin: empty the WP Super Cache where WordPress itself would not.
SuperCacheListener::create($registry->compatibility())->register();
// The admin check of every compatibility profile (POST lit-mcp/v1/<id>-check), REST like the MCP calls.
(new CheckRoute($registry->compatibility()))->register();
@@ -209,7 +213,8 @@ final class Plugin
$divi,
$divi->library(),
$divi->themeBuilder(),
new PodsIntegration()
new PodsIntegration(),
new SuperCacheIntegration()
);
return $registry;
+10 -5
View File
@@ -19,10 +19,12 @@ defined('ABSPATH') || exit;
* Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Integrations\Divi\Release\
* lit_mcp_<profile>_releases (Compatibility\ReleaseStore), lit_mcp_<profile>_check_report
* (Compatibility\CheckReportStore) and lit_mcp_<profile>_check_lock (Admin\CheckRoute) for the
* profiles divi and pods (an explicit list: a LIKE pattern could hit foreign options),
* profiles divi, pods and wpsc (an explicit list: a LIKE pattern could hit foreign options),
* Integrations\Divi\Design\DesignLock::option(), Admin\ConnectionStatus::ENDPOINT_TRANSIENT,
* Integrations\Divi\Release\ReleaseResolver::CACHE,
* ChangeLog\ChangeLogCleanup::HOOK, ChangeLog\ChangeLog::TABLE, Admin\LogTab::PER_PAGE_OPTION and
* Integrations\SuperCache\SuperCacheListener::PURGED_AT (transient),
* ChangeLog\ChangeLogCleanup::HOOK, Integrations\SuperCache\SuperCacheListener::TRAILING_HOOK (cron),
* ChangeLog\ChangeLog::TABLE, Admin\LogTab::PER_PAGE_OPTION and
* Updater\RecheckOnInstall::OPTION on purpose;
* tests/Unit/UninstallerTest.php and tests/Unit/UninstallTest.php keep them in sync with
* those constants.
@@ -38,6 +40,7 @@ final class Uninstaller
'lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version',
'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock',
'lit_mcp_pods_releases', 'lit_mcp_pods_check_report', 'lit_mcp_pods_check_lock',
'lit_mcp_wpsc_releases', 'lit_mcp_wpsc_check_report', 'lit_mcp_wpsc_check_lock',
'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css',
'lit_mcp_lock_divi_options', 'lit_mcp_lock_variables', 'lit_mcp_lock_presets', 'lit_mcp_lock_custom_css',
@@ -45,8 +48,8 @@ final class Uninstaller
'lit_mcp_lock_colors', 'lit_mcp_lock_theme_options',
'lit_mcp_installed_version',
];
private const TRANSIENTS = ['lit_mcp_endpoint_check', 'lit_mcp_divi_state'];
private const CRON_HOOK = 'lit_mcp_log_cleanup';
private const TRANSIENTS = ['lit_mcp_endpoint_check', 'lit_mcp_divi_state', 'lit_mcp_wpsc_purged_at'];
private const CRON_HOOKS = ['lit_mcp_log_cleanup', 'lit_mcp_wpsc_trailing_purge'];
private const TABLE = 'lit_mcp_log';
/** Screen option "Einträge pro Seite" of the Protokoll tab (Admin\LogTab::PER_PAGE_OPTION): user meta. */
@@ -80,7 +83,9 @@ final class Uninstaller
foreach (self::TRANSIENTS as $transient) {
delete_transient($transient);
}
wp_clear_scheduled_hook(self::CRON_HOOK);
foreach (self::CRON_HOOKS as $hook) {
wp_clear_scheduled_hook($hook);
}
$table = $wpdb->prefix . self::TABLE;
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name, not user input.