From b515b8441eb6dbc2e5d05bd5cb46a2436aba96e9 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 09:00:03 +0800 Subject: [PATCH] fix(2meet-data-optimizer-hivepress-addon): keep HivePress transients in the object cache when there is one - With a persistent object cache (wp_using_ext_object_cache(), Redis on the HivePress sites) the transient filter stores HivePress's per-post transient pairs in the tmdo_hp_transient cache group instead of wp_options, where they piled up (1.15 M rows on dev36). Reads fall back to wp_options for values written before; deletes clear both. Without an object cache nothing changes. - Verify: HivepressTransientFilterTest object-cache cases (RED before); dev36 with Redis: update/get/delete_post_meta on an HP transient key leaves 0 rows in wp_options and postmeta. - PR: PR-081 --- .../class-tmdo-hivepress-transient-filter.php | 28 +++++++++--- .../HivepressTransientFilterTest.php | 44 ++++++++++++++++++- tests/integration/bootstrap.php | 7 +++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/includes/class-tmdo-hivepress-transient-filter.php b/includes/class-tmdo-hivepress-transient-filter.php index 92c9fec..1deadff 100644 --- a/includes/class-tmdo-hivepress-transient-filter.php +++ b/includes/class-tmdo-hivepress-transient-filter.php @@ -77,6 +77,9 @@ final class TMDO_Hivepress_Transient_Filter { /** Namespace prefix for translated wp_options entries. */ public const TRANSLATED_NAMESPACE = 'wpdo_hp_pm_'; + /** Object cache group used instead of wp_options when a persistent cache is present. */ + public const CACHE_GROUP = 'tmdo_hp_transient'; + /** * Register the four metadata filters on `init`. Idempotent — safe to call * multiple times. @@ -164,7 +167,11 @@ final class TMDO_Hivepress_Transient_Filter { // 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 ); + $value = wp_using_ext_object_cache() ? wp_cache_get( $option_name, self::CACHE_GROUP ) : false; + if ( false === $value ) { + // wp_options: where values went before an object cache was used. + $value = get_option( $option_name, null ); + } if ( null === $value ) { // Cache miss in our store → fall through to normal postmeta path @@ -239,16 +246,23 @@ final class TMDO_Hivepress_Transient_Filter { $translated = self::translate_key( (int) $object_id, (string) $meta_key ); $option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated; + if ( wp_using_ext_object_cache() ) { + wp_cache_delete( $option_name, self::CACHE_GROUP ); + } delete_option( $option_name ); return true; } /** - * Internal: persist value or timeout to wp_options. + * Internal: persist value or timeout to the object cache when a persistent + * one is in use (Redis: a TTL cache belongs there, not in wp_options), else + * 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). + * In 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). HivePress checks the timeout itself, so the + * cache entries get no TTL of their own. * * @param int $post_id Post ID. * @param string $meta_key Original `_transient_*` meta_key. @@ -260,6 +274,10 @@ final class TMDO_Hivepress_Transient_Filter { $translated = self::translate_key( $post_id, $meta_key ); $option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated; + if ( wp_using_ext_object_cache() ) { + wp_cache_set( $option_name, $meta_value, self::CACHE_GROUP ); + return; + } update_option( $option_name, $meta_value, false ); } diff --git a/tests/integration/HivepressTransientFilterTest.php b/tests/integration/HivepressTransientFilterTest.php index 827d907..41e2f9a 100644 --- a/tests/integration/HivepressTransientFilterTest.php +++ b/tests/integration/HivepressTransientFilterTest.php @@ -44,8 +44,10 @@ class HivepressTransientFilterTest extends TestCase { global $wpdb; $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' ); - // Reset wp_options state for filter tests - $GLOBALS['_wp_options'] = array(); + // Reset wp_options / object cache state for filter tests + $GLOBALS['_wp_options'] = array(); + $GLOBALS['_wp_cache'] = array(); + $GLOBALS['_wp_using_ext_object_cache'] = false; // Default the toggle to enabled update_option( WPDO_Hivepress_Transient_Filter::OPT_ENABLED, '1' ); @@ -195,6 +197,44 @@ class HivepressTransientFilterTest extends TestCase { $this->assertSame( array( array( 'serialized', 'data' ) ), $value ); } + // ── External object cache (2.2 PR-081) ────────────────────────────────── + + public function test_with_an_object_cache_writes_skip_wp_options(): void { + $GLOBALS['_wp_using_ext_object_cache'] = true; + $key = '_transient_hp_models/cat/v1'; + $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); + + $this->assertTrue( WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' ) ); + WPDO_Hivepress_Transient_Filter::on_update( null, 100, '_transient_timeout_hp_models/cat/v1', '99999999', '' ); + + $this->assertFalse( get_option( '_transient_' . $translated ), 'no wp_options row' ); + $this->assertFalse( get_option( '_transient_timeout_' . $translated ), 'no wp_options row' ); + $this->assertSame( array( 'val' ), WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) ); + $this->assertSame( array( '99999999' ), WPDO_Hivepress_Transient_Filter::on_read( null, 100, '_transient_timeout_hp_models/cat/v1', true ) ); + } + + public function test_with_an_object_cache_a_value_left_in_wp_options_still_reads(): void { + $key = '_transient_hp_models/cat/v1'; + $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); + update_option( '_transient_' . $translated, 'from-before' ); + $GLOBALS['_wp_using_ext_object_cache'] = true; + + $this->assertSame( array( 'from-before' ), WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) ); + } + + public function test_with_an_object_cache_delete_clears_both_stores(): void { + $key = '_transient_hp_models/cat/v1'; + $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); + update_option( '_transient_' . $translated, 'from-before' ); + $GLOBALS['_wp_using_ext_object_cache'] = true; + WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' ); + + WPDO_Hivepress_Transient_Filter::on_delete( null, 100, $key, '', false ); + + $this->assertNull( WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) ); + $this->assertFalse( get_option( '_transient_' . $translated ) ); + } + // ── count/purge legacy ────────────────────────────────────────────────── public function test_count_legacy_postmeta_rows_returns_zero_when_clean(): void { diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index b34bf9d..c5be687 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -23,6 +23,13 @@ if ( ! file_exists( $tmdo_core_bootstrap ) ) { } require_once $tmdo_core_bootstrap; +// External object cache switch for the transient filter tests (Redis on real sites). +if ( ! function_exists( 'wp_using_ext_object_cache' ) ) { + function wp_using_ext_object_cache( $using = null ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + return ! empty( $GLOBALS['_wp_using_ext_object_cache'] ); + } +} + define( 'TMDO_HIVEPRESS_PATH', dirname( __DIR__, 2 ) . '/' ); define( 'TMDO_HIVEPRESS_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer-hivepress-addon/' ); define( 'TMDO_HIVEPRESS_FILE', TMDO_HIVEPRESS_PATH . '2meet-data-optimizer-hivepress-addon.php' );