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..d5c29f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,60 @@ 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 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. 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 + +- 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 + redis-cli --scan --pattern 'wp:tmdo_hp_transient:_transient_timeout_*' \ + | awk '{printf "EXPIRE %s 608400 GT\r\n", $0}' | redis-cli --pipe + ``` + + 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`. + +--- + ## [0.3.0] — 2026-09-27 Released with 2meet-data-optimizer 1.2.0 (hardening 2.2). diff --git a/includes/class-tmdo-hivepress-entity-groups.php b/includes/class-tmdo-hivepress-entity-groups.php index 2894502..42a78f8 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 ──────────────────────────────────────────────────── @@ -257,9 +258,61 @@ 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. + // 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(), + ) ); } + /** + * 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 + * 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; + $owner = TMDO_Entity_Registry::get_field( 'post', $key, 'hp_listing' ); + if ( 1 !== preg_match( '/^hp_[a-z0-9_]+$/', $key ) + || ! in_array( $type, array( 'text', 'textarea', 'integer', 'decimal', 'boolean', 'date', 'datetime', 'timestamp', 'enum' ), true ) + || ( null !== $owner && 'hp_listing_attrs' !== $owner['group'] ) ) { + 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/includes/class-tmdo-hivepress-transient-filter.php b/includes/class-tmdo-hivepress-transient-filter.php index 8adb2d5..85a681a 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,11 @@ 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 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. * @param string $meta_key Original `_transient_*` meta_key. @@ -281,7 +287,10 @@ 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 ); + // 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; } update_option( $option_name, $meta_value, false ); 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 3547f20..ada423f 100644 --- a/tests/integration/HivepressTransientFilterTest.php +++ b/tests/integration/HivepressTransientFilterTest.php @@ -213,6 +213,31 @@ 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 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; + $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 ] ); + // 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 { // 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). diff --git a/tests/unit/HivePressEntityGroupsTest.php b/tests/unit/HivePressEntityGroupsTest.php index db610ce..e004493 100644 --- a/tests/unit/HivePressEntityGroupsTest.php +++ b/tests/unit/HivePressEntityGroupsTest.php @@ -58,6 +58,82 @@ 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_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. + '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 ) ); + 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(); + unset( $GLOBALS['_wp_did_action'] ); + } + + $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(); + } + + /** + * 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 );