Files
2meet-data-optimizer-hivepr…/includes/class-tmdo-hivepress-transient-filter.php
T
wpdev b4400a68e5 chore: initial snapshot of 2meet-data-optimizer-hivepress-addon v0.1.0
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
2026-07-31 05:06:36 +08:00

308 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 816 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
}
}