From f06a80eae57fb993330b5a93f9e2a0cb635e69df Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 16:08:23 +0800 Subject: [PATCH 1/8] perf(2meet-data-optimizer-hivepress-addon): index the numeric listing columns HivePress filters and sorts on - hp_listing_core declares indexes on hp_hourly_rate and hp_rating_count; core adds them in place (WP-CLI / WP-Cron), the pinned schema hash is unchanged - Verify: unit 145, integration 52, phpcs; dev36 902k listings (SHOW INDEX, EXPLAIN) - PR: PR-090 --- includes/class-tmdo-hivepress-entity-groups.php | 11 ++++++++++- tests/unit/HivePressEntityGroupsTest.php | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/includes/class-tmdo-hivepress-entity-groups.php b/includes/class-tmdo-hivepress-entity-groups.php index 2894502..eed41ea 100644 --- a/includes/class-tmdo-hivepress-entity-groups.php +++ b/includes/class-tmdo-hivepress-entity-groups.php @@ -257,7 +257,16 @@ final class TMDO_HivePress_Entity_Groups { 'label' => 'Hourly rate', ), ), - array( 'post_types' => array( 'hp_listing' ) ) + array( + 'post_types' => array( 'hp_listing' ), + // Filtered and sorted on by HivePress search. Declared as indexes + // (added in place) rather than `searchable`, which would change + // the schema hash and rebuild a table that may hold aeav_only data. + 'indexes' => array( + 'hourly_rate' => array( 'hp_hourly_rate' ), + 'rating_count' => array( 'hp_rating_count' ), + ), + ) ); } /** diff --git a/tests/unit/HivePressEntityGroupsTest.php b/tests/unit/HivePressEntityGroupsTest.php index db610ce..6eea5f1 100644 --- a/tests/unit/HivePressEntityGroupsTest.php +++ b/tests/unit/HivePressEntityGroupsTest.php @@ -58,6 +58,21 @@ class HivePressEntityGroupsTest extends TestCase { } } + /** + * HivePress filters and sorts listings by these; they are declared as + * group indexes (added in place by core) rather than `searchable`, which + * would change the pinned hash and rebuild the table. + */ + public function test_listing_columns_filtered_and_sorted_on_are_indexed(): void { + $this->assertSame( + array( + 'hourly_rate' => array( 'hp_hourly_rate' ), + 'rating_count' => array( 'hp_rating_count' ), + ), + TMDO_Entity_Registry::get_group_indexes( 'post', 'hp_listing_core' ) + ); + } + public function test_shared_keys_keep_their_current_owner(): void { foreach ( self::SHARED_KEY_OWNERS as $key => $group ) { $this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key )['group'], $key ); -- 2.43.0 From d129139c7d5ab071e6d066652dfccec89d713add Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 16:42:52 +0800 Subject: [PATCH 2/8] perf(2meet-data-optimizer-hivepress-addon): expire object-cache transients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - with an external object cache, a HivePress timeout entry expires when it records; the value (written before its timeout) after at most a week — a miss only makes HivePress rebuild it. Entries of deleted posts no longer accumulate without end - Verify: HivepressTransientFilterTest TTL test, integration 53, unit 145; dev36 Redis TTL on tmdo_hp_transient keys - PR: PR-099 --- .../class-tmdo-hivepress-transient-filter.php | 12 ++++++++--- .../HivepressTransientFilterTest.php | 20 +++++++++++++++++++ tests/integration/bootstrap.php | 8 ++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/includes/class-tmdo-hivepress-transient-filter.php b/includes/class-tmdo-hivepress-transient-filter.php index 8adb2d5..bb17a46 100644 --- a/includes/class-tmdo-hivepress-transient-filter.php +++ b/includes/class-tmdo-hivepress-transient-filter.php @@ -80,6 +80,9 @@ final class TMDO_Hivepress_Transient_Filter { /** Object cache group used instead of wp_options when a persistent cache is present. */ public const CACHE_GROUP = 'tmdo_hp_transient'; + /** Longest an object-cache entry lives: a week (HivePress's default is a day). */ + public const MAX_CACHE_TTL = 604800; + /** * Register the four metadata filters on `init`. Idempotent — safe to call * multiple times. @@ -267,8 +270,10 @@ final class TMDO_Hivepress_Transient_Filter { * 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. + * the point of the move). In the object cache a timeout entry expires when + * it says; the value, written before its timeout, after MAX_CACHE_TTL — a + * miss only makes HivePress rebuild it — so entries of deleted posts do + * not pile up (2.3 PR-099). * * @param int $post_id Post ID. * @param string $meta_key Original `_transient_*` meta_key. @@ -281,7 +286,8 @@ final class TMDO_Hivepress_Transient_Filter { $option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated; if ( wp_using_ext_object_cache() ) { - wp_cache_set( $option_name, $meta_value, self::CACHE_GROUP ); + $ttl = $is_timeout ? min( self::MAX_CACHE_TTL, max( 1, (int) $meta_value - time() ) ) : self::MAX_CACHE_TTL; + wp_cache_set( $option_name, $meta_value, self::CACHE_GROUP, $ttl ); return; } update_option( $option_name, $meta_value, false ); diff --git a/tests/integration/HivepressTransientFilterTest.php b/tests/integration/HivepressTransientFilterTest.php index 3547f20..3b70036 100644 --- a/tests/integration/HivepressTransientFilterTest.php +++ b/tests/integration/HivepressTransientFilterTest.php @@ -213,6 +213,26 @@ class HivepressTransientFilterTest extends TestCase { $this->assertSame( array( '99999999' ), WPDO_Hivepress_Transient_Filter::on_read( null, 100, '_transient_timeout_hp_models/cat/v1', true ) ); } + /** + * Entries expire in the object cache too (2.3 PR-099): the timeout at the + * time it records, the value (written first, before its timeout is known) + * after at most a week. Orphans of deleted posts no longer pile up. + */ + public function test_with_an_object_cache_entries_get_a_ttl(): void { + $GLOBALS['_wp_using_ext_object_cache'] = true; + $key = '_transient_hp_models/cat/v1'; + $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); + + WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' ); + WPDO_Hivepress_Transient_Filter::on_update( null, 100, '_transient_timeout_hp_models/cat/v1', (string) ( time() + 3600 ), '' ); + WPDO_Hivepress_Transient_Filter::on_update( null, 101, '_transient_timeout_hp_models/cat/v1', (string) ( time() - 5 ), '' ); + + $ttl = $GLOBALS['_wp_cache_ttl'][ WPDO_Hivepress_Transient_Filter::CACHE_GROUP ]; + $this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL, $ttl[ '_transient_' . $translated ] ); + $this->assertEqualsWithDelta( 3600, $ttl[ '_transient_timeout_' . $translated ], 2 ); + $this->assertSame( 1, $ttl[ '_transient_timeout_' . WPDO_Hivepress_Transient_Filter::translate_key( 101, $key ) ], 'already expired' ); + } + public function test_with_an_object_cache_a_value_left_in_wp_options_is_not_read(): void { // A miss must not fall back to get_option(): the option is absent after // cleanup and WordPress would add its name to `notoptions` for good. diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index c5be687..6e637f1 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -21,6 +21,14 @@ if ( ! file_exists( $tmdo_core_bootstrap ) ) { . dirname( __DIR__, 3 ) . '/2meet-data-optimizer' ); } +// Defined before the core bootstrap (which only adds it when missing) so tests can read the TTL. +if ( ! function_exists( 'wp_cache_set' ) ) { + function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { + $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; + $GLOBALS['_wp_cache_ttl'][ $group ][ $key ] = (int) $ttl; + return true; + } +} require_once $tmdo_core_bootstrap; // External object cache switch for the transient filter tests (Redis on real sites). -- 2.43.0 From b2585459e337b31f607941a418fe0d85a87e1bbf Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 16:50:01 +0800 Subject: [PATCH 3/8] fix(2meet-data-optimizer-hivepress-addon): declare the listing indexes only on a core that adds them outside web requests - the hp_listing_core indexes are declared only when TMDO_Schema_Manager has ddl_allowed() (core 1.3.0+); core 1.2.0 would run the ALTER on 900k rows inside the first request after deploy, a visitor's included, if this AddOn were deployed first - Verify: unit 145, integration 53, phpcs - PR: PR-090b (found while writing the 0.4.0 notes) --- includes/class-tmdo-hivepress-entity-groups.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/includes/class-tmdo-hivepress-entity-groups.php b/includes/class-tmdo-hivepress-entity-groups.php index eed41ea..27d41ee 100644 --- a/includes/class-tmdo-hivepress-entity-groups.php +++ b/includes/class-tmdo-hivepress-entity-groups.php @@ -262,10 +262,12 @@ final class TMDO_HivePress_Entity_Groups { // Filtered and sorted on by HivePress search. Declared as indexes // (added in place) rather than `searchable`, which would change // the schema hash and rebuild a table that may hold aeav_only data. - 'indexes' => array( + // Only with core 1.3.0+ (ddl_allowed()): older cores run the ALTER + // inside whatever request comes first, a visitor's included. + 'indexes' => method_exists( 'TMDO_Schema_Manager', 'ddl_allowed' ) ? array( 'hourly_rate' => array( 'hp_hourly_rate' ), 'rating_count' => array( 'hp_rating_count' ), - ), + ) : array(), ) ); } -- 2.43.0 From e57dd5bd15a71caed1a271f5cbf83f886f20c21a Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 16:50:01 +0800 Subject: [PATCH 4/8] docs(2meet-data-optimizer-hivepress-addon): 0.4.0 release notes - version 0.4.0; CHANGELOG for PR-090, 090b and 099 with dev36 numbers - Upgrading: deploy with core 1.3.0; give 0.3.0 Redis entries a TTL with redis-cli --scan | EXPIRE ... NX | --pipe (tested on dev36: 3.0 M keys, 45 s, latency max 2 ms), never flush-group - Verify: composer test (145) - PR: PR-102 --- 2meet-data-optimizer-hivepress-addon.php | 4 +-- CHANGELOG.md | 39 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/2meet-data-optimizer-hivepress-addon.php b/2meet-data-optimizer-hivepress-addon.php index 35cc1c6..d57a191 100644 --- a/2meet-data-optimizer-hivepress-addon.php +++ b/2meet-data-optimizer-hivepress-addon.php @@ -3,7 +3,7 @@ * Plugin Name: 2meet Data Optimizer — Hivepress AddOn * Plugin URI: https://2meet.io/2meet-data-optimizer/hivepress * Description: HivePress + 12 擴充整合:13 adapter (core/reviews/bookings/messages/memberships/requests/favorites/statistics/tags/seo/social-links/blocks/marketplace) + 7 HPCT interceptor + 5 query interceptor + admin tab + CLI namespace - * Version: 0.3.0 + * Version: 0.4.0 * Requires at least: 6.0 * Tested up to: 6.9.4 * Requires PHP: 8.1 @@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } -define( 'TMDO_HIVEPRESS_VERSION', '0.3.0' ); +define( 'TMDO_HIVEPRESS_VERSION', '0.4.0' ); define( 'TMDO_HIVEPRESS_PATH', plugin_dir_path( __FILE__ ) ); define( 'TMDO_HIVEPRESS_URL', plugin_dir_url( __FILE__ ) ); define( 'TMDO_HIVEPRESS_FILE', __FILE__ ); diff --git a/CHANGELOG.md b/CHANGELOG.md index f57af6e..a194023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,45 @@ Versioning follows [Semantic Versioning](https://semver.org/). --- +## [0.4.0] — 2026-09-27 + +Released with 2meet-data-optimizer 1.3.0 (hardening 2.3). + +### Changed + +- `hp_listing_core` declares indexes on `hp_hourly_rate` and `hp_rating_count` + (HivePress filters and sorts on both). They are declared through the group's + `indexes`, not `searchable`, so the pinned schema hash is unchanged and the + table is not rebuilt; core 1.3.0 adds them in place from WP-CLI / WP-Cron. + dev36 (902k listings): two indexes in about 4.7 s; routed `rating_count IN` + 2.1 s → 0.36 s, `rating_count !=` 2.1 s → 0.76 s, hourly rate + price sort + 2.1 s → 0.65 s. (PR-090) +- With a persistent object cache, transient entries now expire: a timeout + entry when it records, a value after at most a week (`MAX_CACHE_TTL`; + HivePress writes the value before its timeout, and a miss only makes it + rebuild). Entries of deleted posts no longer pile up. (PR-099) + +### Upgrading + +- Deploy with core 1.3.0: on an older core the AddOn declares no index at all + (core 1.2.0 would run the ALTER inside a web request). (PR-090b) +- Redis: entries written by 0.3.0 have no TTL (dev36 had 3.0 M). Give them one + with non-blocking commands — never `wp cache flush-group`, which the Redis + Object Cache drop-in runs as one server-side script and which blocked Redis + on a large group. `NX` leaves entries written by 0.4.0 alone: + + ``` + redis-cli --scan --pattern 'wp:tmdo_hp_transient:*' \ + | awk '{printf "EXPIRE %s 604800 NX\r\n", $0}' | redis-cli --pipe + ``` + + dev36: 3,013,352 keys in 45 s, 0 errors, Redis latency max 2 ms. The pattern + follows the drop-in's key layout (`::`); + check it with `redis-cli --scan --pattern '*tmdo_hp_transient*' | head` + first. redis-cli 7 has no `--count` with `--scan`. + +--- + ## [0.3.0] — 2026-09-27 Released with 2meet-data-optimizer 1.2.0 (hardening 2.2). -- 2.43.0 From 0efac618de461589c85fb6c272a6dac2b253195f Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:10:19 +0800 Subject: [PATCH 5/8] feat(2meet-data-optimizer-hivepress-addon): flatten a site's own listing attributes on opt-in - the tmdo_hp_listing_attribute_fields filter (hp_ key => type) registers a site's HivePress listing attributes, such as a "Warranty" number attribute (hp_warranty, 901k rows on dev36), in their own hp_listing_attrs group: a new table with each column indexed at creation; hp_listing_core and its pinned hash are unchanged - keys another group manages, keys without the hp_ prefix and unknown types are ignored - Verify: HivePressEntityGroupsTest (opt-in, filtering, pinned hashes), unit 147, integration 53, phpcs; dev36 hp_warranty backfill and queries - PR: PR-109 --- .../class-tmdo-hivepress-entity-groups.php | 34 +++++++++++++++++++ tests/unit/HivePressEntityGroupsTest.php | 33 ++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/includes/class-tmdo-hivepress-entity-groups.php b/includes/class-tmdo-hivepress-entity-groups.php index 27d41ee..184c367 100644 --- a/includes/class-tmdo-hivepress-entity-groups.php +++ b/includes/class-tmdo-hivepress-entity-groups.php @@ -76,6 +76,7 @@ final class TMDO_HivePress_Entity_Groups { self::register_hp_request_core_group(); self::register_hp_vendor_core_group(); self::register_hp_user_group(); + self::register_hp_listing_attributes_group(); } // ── Group definitions ──────────────────────────────────────────────────── @@ -271,6 +272,39 @@ final class TMDO_HivePress_Entity_Groups { ) ); } + /** + * A site's own HivePress listing attributes (created under Listings → + * Attributes, e.g. a "Warranty" number → hp_warranty), opt-in through the + * `tmdo_hp_listing_attribute_fields` filter: `array( 'hp_warranty' => 'integer' )`. + * + * They get a group of their own — a new table, each column indexed at + * creation — so hp_listing_core and its pinned definition stay unchanged. + * Keys another group already manages, keys without the hp_ prefix and + * unknown types are ignored. Backfill with `wp tmdo post-migrate-group`. + * + * @return void + */ + private static function register_hp_listing_attributes_group(): void { + $fields = array(); + foreach ( (array) apply_filters( 'tmdo_hp_listing_attribute_fields', array() ) as $key => $type ) { + $key = (string) $key; + if ( 1 !== preg_match( '/^hp_[a-z0-9_]+$/', $key ) + || ! in_array( $type, TMDO_Entity_Registry::VALID_TYPES, true ) + || null !== TMDO_Entity_Registry::get_field( 'post', $key, 'hp_listing' ) ) { + continue; + } + $fields[] = array( + 'key' => $key, + 'type' => $type, + 'searchable' => true, + 'label' => 'Site listing attribute', + ); + } + if ( $fields ) { + TMDO_Entity_Registry::register_group( 'post', 'hp_listing_attrs', $fields, array( 'post_types' => array( 'hp_listing' ) ) ); + } + } + /** * HivePress request core meta keys (post_type=hp_request). * diff --git a/tests/unit/HivePressEntityGroupsTest.php b/tests/unit/HivePressEntityGroupsTest.php index 6eea5f1..44c170f 100644 --- a/tests/unit/HivePressEntityGroupsTest.php +++ b/tests/unit/HivePressEntityGroupsTest.php @@ -73,6 +73,39 @@ class HivePressEntityGroupsTest extends TestCase { ); } + public function test_site_listing_attributes_are_opt_in(): void { + $this->assertSame( array(), TMDO_Entity_Registry::get_group_fields( 'post', 'hp_listing_attrs' ) ); + } + + /** + * A site flattens its own HivePress listing attributes (e.g. a "Warranty" + * number attribute → hp_warranty) in a group of their own: a new table, + * indexed at creation; hp_listing_core and its pinned hash stay as they are. + */ + public function test_site_listing_attributes_get_their_own_indexed_group(): void { + $fields = static fn() => array( + 'hp_warranty' => 'integer', + 'hp_price' => 'decimal', // Already in hp_listing_core. + 'warranty' => 'integer', // Not a HivePress key. + 'hp_color' => 'colour', // Unknown type. + ); + add_filter( 'tmdo_hp_listing_attribute_fields', $fields ); + try { + TMDO_Entity_Registry::init(); + TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) ); + TMDO_Entity_Registry::register_adapter( 'user', $this->createMock( TMDO_Entity_Adapter_Interface::class ) ); + TMDO_HivePress_Entity_Groups::register_entity_fields(); + } finally { + $GLOBALS['_wp_filter_callbacks']['tmdo_hp_listing_attribute_fields'] = array(); + } + + $this->assertSame( array( 'hp_warranty' ), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_attrs' ) ); + $this->assertTrue( (bool) TMDO_Entity_Registry::get_group_fields( 'post', 'hp_listing_attrs' )[0]['searchable'] ); + $this->assertSame( array( 'hp_listing' ), TMDO_Entity_Registry::get_group_post_types( 'post', 'hp_listing_attrs' ) ); + $this->assertSame( 'hp_listing_core', TMDO_Entity_Registry::get_field( 'post', 'hp_price' )['group'] ); + $this->test_definitions_match_persisted_schema_hashes(); + } + public function test_shared_keys_keep_their_current_owner(): void { foreach ( self::SHARED_KEY_OWNERS as $key => $group ) { $this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key )['group'], $key ); -- 2.43.0 From c139ebca892051b27c743158acda1b65fe1a169f Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:12:54 +0800 Subject: [PATCH 6/8] docs(2meet-data-optimizer-hivepress-addon): add PR-109 to the 0.4.0 release notes - Added: site listing attributes through tmdo_hp_listing_attribute_fields - PR: PR-102 (follow-up) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a194023..ba2799d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,14 @@ Released with 2meet-data-optimizer 1.3.0 (hardening 2.3). HivePress writes the value before its timeout, and a miss only makes it rebuild). Entries of deleted posts no longer pile up. (PR-099) +### Added + +- Opt-in flattening of a site's own listing attributes: the + `tmdo_hp_listing_attribute_fields` filter (`'hp_warranty' => 'integer'`) + registers them in an `hp_listing_attrs` group — a new table, each column + indexed at creation — leaving `hp_listing_core` unchanged. Backfill with + `wp tmdo post-migrate-group`. (PR-109) + ### Upgrading - Deploy with core 1.3.0: on an older core the AddOn declares no index at all -- 2.43.0 From 69cd4b6f8b007716d7fea7099b16984e4f5cf8b1 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:33:13 +0800 Subject: [PATCH 7/8] fix(2meet-data-optimizer-hivepress-addon): let transient timeouts outlive their values; register site attributes once themes load - review F5: a timeout entry expired before its value (a day vs a week), and HivePress reads a missing timeout as "never expires", serving the stale value for up to six more days. The timeout now expires a minute after the later of MAX_CACHE_TTL and its own time, so HivePress sees it expire first - review F7: site attributes accept only types with a plain index (json and binary would fail the CREATE TABLE) - review F8: the hp_listing_attrs group is registered on the run after after_setup_theme (init:1), so a filter added in a theme counts and the group has one definition per request; only keys of other groups are skipped - Verify: TTL test, attribute group tests, unit 148, integration 53, phpcs - PR: PR-115 (2.3 acceptance correctness review) --- .../class-tmdo-hivepress-entity-groups.php | 16 ++++++++--- .../class-tmdo-hivepress-transient-filter.php | 11 +++++--- tests/bootstrap.php | 6 ++++ .../HivepressTransientFilterTest.php | 15 ++++++---- tests/unit/HivePressEntityGroupsTest.php | 28 +++++++++++++++++++ 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/includes/class-tmdo-hivepress-entity-groups.php b/includes/class-tmdo-hivepress-entity-groups.php index 184c367..42a78f8 100644 --- a/includes/class-tmdo-hivepress-entity-groups.php +++ b/includes/class-tmdo-hivepress-entity-groups.php @@ -280,17 +280,25 @@ final class TMDO_HivePress_Entity_Groups { * They get a group of their own — a new table, each column indexed at * creation — so hp_listing_core and its pinned definition stay unchanged. * Keys another group already manages, keys without the hp_ prefix and - * unknown types are ignored. Backfill with `wp tmdo post-migrate-group`. + * types that cannot take a plain index (json, binary) are ignored. Core + * fills the new table from native meta (drift fix) before using it. + * + * Registered once themes are loaded (the init:1 run): a filter added in a + * theme is seen, and the group gets one definition per request. * * @return void */ private static function register_hp_listing_attributes_group(): void { + if ( ! did_action( 'after_setup_theme' ) ) { + return; + } $fields = array(); foreach ( (array) apply_filters( 'tmdo_hp_listing_attribute_fields', array() ) as $key => $type ) { - $key = (string) $key; + $key = (string) $key; + $owner = TMDO_Entity_Registry::get_field( 'post', $key, 'hp_listing' ); if ( 1 !== preg_match( '/^hp_[a-z0-9_]+$/', $key ) - || ! in_array( $type, TMDO_Entity_Registry::VALID_TYPES, true ) - || null !== TMDO_Entity_Registry::get_field( 'post', $key, 'hp_listing' ) ) { + || ! in_array( $type, array( 'text', 'textarea', 'integer', 'decimal', 'boolean', 'date', 'datetime', 'timestamp', 'enum' ), true ) + || ( null !== $owner && 'hp_listing_attrs' !== $owner['group'] ) ) { continue; } $fields[] = array( diff --git a/includes/class-tmdo-hivepress-transient-filter.php b/includes/class-tmdo-hivepress-transient-filter.php index bb17a46..85a681a 100644 --- a/includes/class-tmdo-hivepress-transient-filter.php +++ b/includes/class-tmdo-hivepress-transient-filter.php @@ -270,9 +270,10 @@ final class TMDO_Hivepress_Transient_Filter { * 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). In the object cache a timeout entry expires when - * it says; the value, written before its timeout, after MAX_CACHE_TTL — a - * miss only makes HivePress rebuild it — so entries of deleted posts do + * the point of the move). In the object cache the value, written before its + * timeout, expires after MAX_CACHE_TTL (a miss only makes HivePress rebuild + * it) and the timeout a minute after the later of that and its own time, + * so HivePress always sees it expire first; entries of deleted posts do * not pile up (2.3 PR-099). * * @param int $post_id Post ID. @@ -286,7 +287,9 @@ final class TMDO_Hivepress_Transient_Filter { $option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated; if ( wp_using_ext_object_cache() ) { - $ttl = $is_timeout ? min( self::MAX_CACHE_TTL, max( 1, (int) $meta_value - time() ) ) : self::MAX_CACHE_TTL; + // A timeout outlives its value: HivePress takes a missing timeout for + // "never expires" and would keep serving the value. + $ttl = $is_timeout ? max( self::MAX_CACHE_TTL, (int) $meta_value - time() ) + MINUTE_IN_SECONDS : self::MAX_CACHE_TTL; wp_cache_set( $option_name, $meta_value, self::CACHE_GROUP, $ttl ); return; } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c4a0cc2..05b4c67 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -24,6 +24,12 @@ if ( ! file_exists( $tmdo_core_bootstrap ) ) { } require_once $tmdo_core_bootstrap; +if ( ! function_exists( 'did_action' ) ) { + function did_action( string $hook ): int { + return (int) ( $GLOBALS['_wp_did_action'][ $hook ] ?? 0 ); + } +} + define( 'TMDO_HIVEPRESS_PATH', dirname( __DIR__ ) . '/' ); define( 'TMDO_HIVEPRESS_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer-hivepress-addon/' ); define( 'TMDO_HIVEPRESS_FILE', dirname( __DIR__ ) . '/2meet-data-optimizer-hivepress-addon.php' ); diff --git a/tests/integration/HivepressTransientFilterTest.php b/tests/integration/HivepressTransientFilterTest.php index 3b70036..ada423f 100644 --- a/tests/integration/HivepressTransientFilterTest.php +++ b/tests/integration/HivepressTransientFilterTest.php @@ -214,9 +214,10 @@ class HivepressTransientFilterTest extends TestCase { } /** - * Entries expire in the object cache too (2.3 PR-099): the timeout at the - * time it records, the value (written first, before its timeout is known) - * after at most a week. Orphans of deleted posts no longer pile up. + * Entries expire in the object cache too (2.3 PR-099): the value (written + * first, before its timeout is known) after a week, the timeout after the + * later of that and its own time, plus a minute. Orphans of deleted posts + * no longer pile up. */ public function test_with_an_object_cache_entries_get_a_ttl(): void { $GLOBALS['_wp_using_ext_object_cache'] = true; @@ -229,8 +230,12 @@ class HivepressTransientFilterTest extends TestCase { $ttl = $GLOBALS['_wp_cache_ttl'][ WPDO_Hivepress_Transient_Filter::CACHE_GROUP ]; $this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL, $ttl[ '_transient_' . $translated ] ); - $this->assertEqualsWithDelta( 3600, $ttl[ '_transient_timeout_' . $translated ], 2 ); - $this->assertSame( 1, $ttl[ '_transient_timeout_' . WPDO_Hivepress_Transient_Filter::translate_key( 101, $key ) ], 'already expired' ); + // A timeout must outlive its value: HivePress takes a missing timeout for + // "never expires" and would serve the stale value (review F5). + $this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL + 60, $ttl[ '_transient_timeout_' . $translated ] ); + $this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL + 60, $ttl[ '_transient_timeout_' . WPDO_Hivepress_Transient_Filter::translate_key( 101, $key ) ], 'already expired' ); + WPDO_Hivepress_Transient_Filter::on_update( null, 102, '_transient_timeout_hp_models/cat/v1', (string) ( time() + 2 * WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL ), '' ); + $this->assertEqualsWithDelta( 2 * WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL + 60, $GLOBALS['_wp_cache_ttl'][ WPDO_Hivepress_Transient_Filter::CACHE_GROUP ][ '_transient_timeout_' . WPDO_Hivepress_Transient_Filter::translate_key( 102, $key ) ], 2 ); } public function test_with_an_object_cache_a_value_left_in_wp_options_is_not_read(): void { diff --git a/tests/unit/HivePressEntityGroupsTest.php b/tests/unit/HivePressEntityGroupsTest.php index 44c170f..e004493 100644 --- a/tests/unit/HivePressEntityGroupsTest.php +++ b/tests/unit/HivePressEntityGroupsTest.php @@ -88,8 +88,10 @@ class HivePressEntityGroupsTest extends TestCase { 'hp_price' => 'decimal', // Already in hp_listing_core. 'warranty' => 'integer', // Not a HivePress key. 'hp_color' => 'colour', // Unknown type. + 'hp_features' => 'json', // No plain index on LONGTEXT. ); add_filter( 'tmdo_hp_listing_attribute_fields', $fields ); + $GLOBALS['_wp_did_action']['after_setup_theme'] = 1; try { TMDO_Entity_Registry::init(); TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) ); @@ -97,6 +99,7 @@ class HivePressEntityGroupsTest extends TestCase { TMDO_HivePress_Entity_Groups::register_entity_fields(); } finally { $GLOBALS['_wp_filter_callbacks']['tmdo_hp_listing_attribute_fields'] = array(); + unset( $GLOBALS['_wp_did_action'] ); } $this->assertSame( array( 'hp_warranty' ), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_attrs' ) ); @@ -106,6 +109,31 @@ class HivePressEntityGroupsTest extends TestCase { $this->test_definitions_match_persisted_schema_hashes(); } + /** + * Registration runs twice (plugins_loaded, then init:1): the group is + * registered on the run after themes load, so a theme's filter counts and + * a filter that changed between runs cannot collide (review F8). + */ + public function test_site_listing_attributes_are_registered_once_themes_are_loaded(): void { + add_filter( 'tmdo_hp_listing_attribute_fields', static fn() => array( 'hp_warranty' => 'integer' ) ); + try { + TMDO_Entity_Registry::init(); + TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) ); + TMDO_Entity_Registry::register_adapter( 'user', $this->createMock( TMDO_Entity_Adapter_Interface::class ) ); + TMDO_HivePress_Entity_Groups::register_entity_fields(); + $this->assertSame( array(), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_attrs' ), 'plugins_loaded run: not yet' ); + + $GLOBALS['_wp_did_action']['after_setup_theme'] = 1; + TMDO_HivePress_Entity_Groups::register_entity_fields(); + } finally { + $GLOBALS['_wp_filter_callbacks']['tmdo_hp_listing_attribute_fields'] = array(); + unset( $GLOBALS['_wp_did_action'] ); + } + + $this->assertSame( array( 'hp_warranty' ), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_attrs' ) ); + $this->assertSame( array(), TMDO_Entity_Registry::get_collisions() ); + } + public function test_shared_keys_keep_their_current_owner(): void { foreach ( self::SHARED_KEY_OWNERS as $key => $group ) { $this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key )['group'], $key ); -- 2.43.0 From df62edd0f54c87e220f50b799f919e553bbfac3f Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:34:19 +0800 Subject: [PATCH 8/8] docs(2meet-data-optimizer-hivepress-addon): add PR-115 to the 0.4.0 release notes - timeout TTL order, attribute types and registration timing; Redis upgrade procedure gains the timeout pass - PR: PR-102 (follow-up) --- CHANGELOG.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba2799d..d5c29f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,18 +24,22 @@ Released with 2meet-data-optimizer 1.3.0 (hardening 2.3). dev36 (902k listings): two indexes in about 4.7 s; routed `rating_count IN` 2.1 s → 0.36 s, `rating_count !=` 2.1 s → 0.76 s, hourly rate + price sort 2.1 s → 0.65 s. (PR-090) -- With a persistent object cache, transient entries now expire: a timeout - entry when it records, a value after at most a week (`MAX_CACHE_TTL`; - HivePress writes the value before its timeout, and a miss only makes it - rebuild). Entries of deleted posts no longer pile up. (PR-099) +- With a persistent object cache, transient entries now expire: a value after + a week (`MAX_CACHE_TTL`; HivePress writes it before its timeout, and a miss + only makes it rebuild), its timeout a minute after the later of that and its + own time — HivePress reads a missing timeout as "never expires", so the + timeout must go last. Entries of deleted posts no longer pile up. (PR-099, + PR-115) ### Added - Opt-in flattening of a site's own listing attributes: the `tmdo_hp_listing_attribute_fields` filter (`'hp_warranty' => 'integer'`) registers them in an `hp_listing_attrs` group — a new table, each column - indexed at creation — leaving `hp_listing_core` unchanged. Backfill with - `wp tmdo post-migrate-group`. (PR-109) + indexed at creation — leaving `hp_listing_core` unchanged. Types: text, + textarea, integer, decimal, boolean, date, datetime, timestamp, enum. The + group is registered once themes load, so the filter may live in a theme; + core 1.3.0 keeps it native until a drift fix has filled it. (PR-109, PR-115) ### Upgrading @@ -49,9 +53,12 @@ Released with 2meet-data-optimizer 1.3.0 (hardening 2.3). ``` redis-cli --scan --pattern 'wp:tmdo_hp_transient:*' \ | awk '{printf "EXPIRE %s 604800 NX\r\n", $0}' | redis-cli --pipe + redis-cli --scan --pattern 'wp:tmdo_hp_transient:_transient_timeout_*' \ + | awk '{printf "EXPIRE %s 608400 GT\r\n", $0}' | redis-cli --pipe ``` - dev36: 3,013,352 keys in 45 s, 0 errors, Redis latency max 2 ms. The pattern + The second pass makes timeouts outlive their values. dev36: 3,013,352 keys + in 45 s and 1,867,569 timeouts in 32 s, 0 errors, Redis latency max 2 ms. The pattern follows the drop-in's key layout (`::`); check it with `redis-cli --scan --pattern '*tmdo_hp_transient*' | head` first. redis-cli 7 has no `--count` with `--scan`. -- 2.43.0