b4400a68e5
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
308 lines
12 KiB
PHP
308 lines
12 KiB
PHP
<?php
|
||
// phpcs:ignore WPDO.AntiEAV -- platform integration with HivePress: external data shape transform
|
||
/**
|
||
* TMDO_Hivepress_Transient_Filter — Reroute HivePress per-post TTL cache from
|
||
* wp_postmeta to wp_options (native transient API).
|
||
*
|
||
* Background
|
||
* ----------
|
||
* HivePress (`hivepress/includes/components/class-cache.php`) implements an
|
||
* entity-scoped TTL cache by storing pairs of `_transient_<name>` and
|
||
* `_transient_timeout_<name>` rows in *the entity's metadata table*
|
||
* (wp_postmeta / wp_usermeta / wp_termmeta / wp_commentmeta) — calling
|
||
* `update_post_meta($post_id, '_transient_…', $value)` directly.
|
||
*
|
||
* This is a deliberate HivePress design choice (cache invalidation tied to
|
||
* the entity's lifecycle), but it produces 8–16 wp_postmeta rows per
|
||
* hp_listing every save_post — completely defeating the visible benefit of
|
||
* post-entity reverse-EAV (mode=aeav_only successfully short-circuits the 7
|
||
* registered entity keys, but HivePress still bloats wp_postmeta with
|
||
* transient cache rows that look identical to "1:16 not optimized" externally).
|
||
*
|
||
* Strategy
|
||
* --------
|
||
* We register four metadata filters that intercept any `_transient_hp_*` /
|
||
* `_transient_timeout_hp_*` post meta read/write/delete and re-route the
|
||
* call to wp_options via the native `set_transient` storage layout. HivePress
|
||
* remains oblivious — its standard `update_post_meta` / `get_post_meta` /
|
||
* `delete_post_meta` calls work transparently — but the actual storage moves
|
||
* out of wp_postmeta entirely.
|
||
*
|
||
* Translation rule
|
||
* ----------------
|
||
* wp_postmeta(post_id, '_transient_hp_models/cat/v1', $val)
|
||
* → wp_options('_transient_<HASH>', $val)
|
||
*
|
||
* wp_postmeta(post_id, '_transient_timeout_hp_models/cat/v1', $exp)
|
||
* → wp_options('_transient_timeout_<HASH>', $exp)
|
||
*
|
||
* where HASH = "wpdo_hp_pm_{$post_id}_" . md5($stripped_key) — fits within
|
||
* the 172-char wp_options.option_name index, namespaces by post_id (so two
|
||
* listings caching different models never collide), and is deterministic so
|
||
* read/write/delete all hit the same row.
|
||
*
|
||
* Scope (v2.11.5 launch)
|
||
* ----------------------
|
||
* ✅ wp_postmeta `_transient_hp_*` (HivePress is the dominant offender)
|
||
* ❌ wp_usermeta / wp_termmeta / wp_commentmeta (deferred — measure first)
|
||
* ❌ Other plugins' `_transient_*` postmeta (deliberately excluded for safety)
|
||
*
|
||
* Toggle
|
||
* ------
|
||
* Option `wpdo_hp_transient_filter_enabled` (default true). Disable via:
|
||
* `wp option update wpdo_hp_transient_filter_enabled 0`
|
||
*
|
||
* @package WP_Data_Optimizer
|
||
* @since 2.11.5
|
||
*/
|
||
|
||
if ( ! defined( 'ABSPATH' ) ) {
|
||
exit;
|
||
}
|
||
|
||
/**
|
||
* Routes HivePress per-post TTL cache out of wp_postmeta into wp_options.
|
||
*/
|
||
final class TMDO_Hivepress_Transient_Filter {
|
||
|
||
/** Option toggle key. */
|
||
public const OPT_ENABLED = 'wpdo_hp_transient_filter_enabled';
|
||
|
||
/** Prefix that identifies a HivePress meta-cache value row. */
|
||
public const PREFIX_VALUE = '_transient_hp_';
|
||
|
||
/** Prefix that identifies a HivePress meta-cache timeout row. */
|
||
public const PREFIX_TIMEOUT = '_transient_timeout_hp_';
|
||
|
||
/** Namespace prefix for translated wp_options entries. */
|
||
public const TRANSLATED_NAMESPACE = 'wpdo_hp_pm_';
|
||
|
||
/**
|
||
* Register the four metadata filters on `init`. Idempotent — safe to call
|
||
* multiple times.
|
||
*
|
||
* @return void
|
||
*/
|
||
public static function init(): void {
|
||
if ( ! self::is_enabled() ) {
|
||
return;
|
||
}
|
||
|
||
// Filter priority 9 — runs *before* WPDO Hook Bus (priority 10) so we
|
||
// strip transient keys from the meta path before Hook Bus attempts to
|
||
// route them through entity registry (it wouldn't find a match anyway,
|
||
// but skipping the lookup is a tiny perf win).
|
||
add_filter( 'get_post_metadata', array( self::class, 'on_read' ), 9, 4 );
|
||
add_filter( 'add_post_metadata', array( self::class, 'on_add' ), 9, 5 );
|
||
add_filter( 'update_post_metadata', array( self::class, 'on_update' ), 9, 5 );
|
||
add_filter( 'delete_post_metadata', array( self::class, 'on_delete' ), 9, 5 );
|
||
}
|
||
|
||
/**
|
||
* Check the admin toggle. Defaults to enabled.
|
||
*
|
||
* @return bool
|
||
*/
|
||
public static function is_enabled(): bool {
|
||
return (bool) get_option( self::OPT_ENABLED, '1' );
|
||
}
|
||
|
||
/**
|
||
* Test whether a meta_key is a HivePress transient pair (value or timeout).
|
||
*
|
||
* @param mixed $meta_key Candidate meta_key (may be non-string from filter).
|
||
* @return bool
|
||
*/
|
||
public static function is_target_key( $meta_key ): bool {
|
||
if ( ! is_string( $meta_key ) ) {
|
||
return false;
|
||
}
|
||
return str_starts_with( $meta_key, self::PREFIX_VALUE )
|
||
|| str_starts_with( $meta_key, self::PREFIX_TIMEOUT );
|
||
}
|
||
|
||
/**
|
||
* Translate (post_id, meta_key) → namespaced wp_options option_name root.
|
||
*
|
||
* The original meta_key is hashed (md5) so very long HivePress cache keys
|
||
* (`_transient_hp_models/term/listing_availability/<long-hex-hash>`) still
|
||
* fit within wp_options.option_name index width.
|
||
*
|
||
* @param int $post_id Post ID owning the cache.
|
||
* @param string $meta_key Original `_transient_*` meta_key.
|
||
* @return string Translated option_name root (without `_transient_` prefix).
|
||
*/
|
||
public static function translate_key( int $post_id, string $meta_key ): string {
|
||
// Strip `_transient_timeout_` or `_transient_` prefix to get the bare cache name.
|
||
$stripped = preg_replace( '/^_transient_(timeout_)?/', '', $meta_key );
|
||
return self::TRANSLATED_NAMESPACE . $post_id . '_' . md5( (string) $stripped );
|
||
}
|
||
|
||
/**
|
||
* Filter callback: get_post_metadata.
|
||
*
|
||
* Returns null → continue normal flow (DB query); returns array → WP
|
||
* unwraps `[0]` for `$single=true` callers, or returns array as-is for
|
||
* `$single=false` callers.
|
||
*
|
||
* @param mixed $pre Filter accumulator (null at our priority).
|
||
* @param int $object_id Post ID.
|
||
* @param string $meta_key Meta key being read.
|
||
* @param bool $single Whether single value was requested.
|
||
* @return mixed
|
||
*/
|
||
public static function on_read( $pre, $object_id, $meta_key, $single ) {
|
||
if ( ! self::is_target_key( $meta_key ) ) {
|
||
return $pre;
|
||
}
|
||
$is_timeout = str_starts_with( (string) $meta_key, self::PREFIX_TIMEOUT );
|
||
$translated = self::translate_key( (int) $object_id, (string) $meta_key );
|
||
$option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated;
|
||
|
||
// Read directly from wp_options without going through get_transient()
|
||
// — we don't want the transient API to delete-on-expire, because the
|
||
// HivePress cache layer reads the timeout *first*, then decides whether
|
||
// to read the value. We must preserve raw stored values until HivePress
|
||
// itself orders deletion.
|
||
$value = get_option( $option_name, null );
|
||
|
||
if ( null === $value ) {
|
||
// Cache miss in our store → fall through to normal postmeta path
|
||
// (back-compat: lets a pre-filter postmeta row still resolve).
|
||
return $pre;
|
||
}
|
||
|
||
// Match WP's metadata return contract:
|
||
// $single=true returns [value] (WP unwraps to value),
|
||
// $single=false returns array of values (WP returns as-is).
|
||
return array( $value );
|
||
}
|
||
|
||
/**
|
||
* Filter callback: add_post_metadata.
|
||
*
|
||
* Returns non-null to short-circuit the DB INSERT; truthy result is what
|
||
* `add_post_meta()` returns to its caller (typically a meta_id, but a
|
||
* truthy non-zero is sufficient for the calling code's success check).
|
||
*
|
||
* @param mixed $check Filter accumulator (null at our priority).
|
||
* @param int $object_id Post ID.
|
||
* @param string $meta_key Meta key being written.
|
||
* @param mixed $meta_value Value being written.
|
||
* @param bool $unique Whether the unique flag was set.
|
||
* @return mixed
|
||
*/
|
||
public static function on_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
|
||
unset( $unique ); // HivePress transient writes never use $unique.
|
||
if ( ! self::is_target_key( $meta_key ) ) {
|
||
return $check;
|
||
}
|
||
self::write_translated( (int) $object_id, (string) $meta_key, $meta_value );
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Filter callback: update_post_metadata.
|
||
*
|
||
* @param mixed $check Filter accumulator (null at our priority).
|
||
* @param int $object_id Post ID.
|
||
* @param string $meta_key Meta key being written.
|
||
* @param mixed $meta_value Value being written.
|
||
* @param mixed $prev_value Previous value scope (unused for our keys).
|
||
* @return mixed
|
||
*/
|
||
public static function on_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
|
||
unset( $prev_value );
|
||
if ( ! self::is_target_key( $meta_key ) ) {
|
||
return $check;
|
||
}
|
||
self::write_translated( (int) $object_id, (string) $meta_key, $meta_value );
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Filter callback: delete_post_metadata.
|
||
*
|
||
* @param mixed $check Filter accumulator (null at our priority).
|
||
* @param int $object_id Post ID.
|
||
* @param string $meta_key Meta key being deleted.
|
||
* @param mixed $meta_value Value-scoped delete (unused for our keys).
|
||
* @param bool $delete_all Whether to delete from all objects (unused).
|
||
* @return mixed
|
||
*/
|
||
public static function on_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) {
|
||
unset( $meta_value, $delete_all );
|
||
if ( ! self::is_target_key( $meta_key ) ) {
|
||
return $check;
|
||
}
|
||
$is_timeout = str_starts_with( (string) $meta_key, self::PREFIX_TIMEOUT );
|
||
$translated = self::translate_key( (int) $object_id, (string) $meta_key );
|
||
$option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated;
|
||
|
||
delete_option( $option_name );
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Internal: persist value or timeout to wp_options.
|
||
*
|
||
* Both halves of the transient pair are written with `autoload=no` (they
|
||
* are read on-demand only — never via wp_load_alloptions() — so eagerly
|
||
* loading them on every page would defeat the point of the move).
|
||
*
|
||
* @param int $post_id Post ID.
|
||
* @param string $meta_key Original `_transient_*` meta_key.
|
||
* @param mixed $meta_value Value to store.
|
||
* @return void
|
||
*/
|
||
private static function write_translated( int $post_id, string $meta_key, $meta_value ): void {
|
||
$is_timeout = str_starts_with( $meta_key, self::PREFIX_TIMEOUT );
|
||
$translated = self::translate_key( $post_id, $meta_key );
|
||
$option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated;
|
||
|
||
update_option( $option_name, $meta_value, false );
|
||
}
|
||
|
||
/**
|
||
* One-time migration helper: count how many `_transient_hp_*` rows
|
||
* currently live in wp_postmeta (callers can use this to decide whether
|
||
* to run the cleanup CLI).
|
||
*
|
||
* @return int
|
||
*/
|
||
public static function count_legacy_postmeta_rows(): int {
|
||
global $wpdb;
|
||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||
return (int) $wpdb->get_var(
|
||
"SELECT COUNT(*) FROM {$wpdb->postmeta}
|
||
WHERE meta_key LIKE '\\_transient\\_hp\\_%'
|
||
OR meta_key LIKE '\\_transient\\_timeout\\_hp\\_%'"
|
||
);
|
||
// phpcs:enable
|
||
}
|
||
|
||
/**
|
||
* One-time migration: DELETE all historical `_transient_hp_*` rows from
|
||
* wp_postmeta. Safe to call when the filter is enabled (any future writes
|
||
* will go to wp_options instead).
|
||
*
|
||
* Note: existing transient values are *abandoned* (HivePress will rebuild
|
||
* them on first cache miss). We don't attempt to migrate values to
|
||
* wp_options because:
|
||
* - HivePress's cache versioning means stale values are auto-superseded
|
||
* - The cost of re-fetching is bounded (taxonomy term lookups, fast)
|
||
*
|
||
* @return int Rows deleted.
|
||
*/
|
||
public static function purge_legacy_postmeta_rows(): int {
|
||
global $wpdb;
|
||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||
return (int) $wpdb->query(
|
||
"DELETE FROM {$wpdb->postmeta}
|
||
WHERE meta_key LIKE '\\_transient\\_hp\\_%'
|
||
OR meta_key LIKE '\\_transient\\_timeout\\_hp\\_%'"
|
||
);
|
||
// phpcs:enable
|
||
}
|
||
}
|