` and * `_transient_timeout_` 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_', $val) * * wp_postmeta(post_id, '_transient_timeout_hp_models/cat/v1', $exp) * → wp_options('_transient_timeout_', $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/`) 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 } }