4 Commits

Author SHA1 Message Date
wpdev df62edd0f5 docs(2meet-data-optimizer-hivepress-addon): add PR-115 to the 0.4.0 release notes
Tests / PHP Lint (pull_request) Successful in 7s
Tests / Unit Tests (pull_request) Successful in 1m21s
Tests / Integration Tests (pull_request) Successful in 1m12s
- timeout TTL order, attribute types and registration timing; Redis upgrade
  procedure gains the timeout pass
- PR: PR-102 (follow-up)
2026-09-27 17:34:19 +08:00
wpdev 69cd4b6f8b 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)
2026-09-27 17:33:13 +08:00
wpdev c139ebca89 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)
2026-09-27 17:12:54 +08:00
wpdev 0efac618de 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
2026-09-27 17:10:19 +08:00
6 changed files with 146 additions and 14 deletions
+20 -5
View File
@@ -24,10 +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` 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.36 s, `rating_count !=` 2.1 s → 0.76 s, hourly rate + price sort
2.1 s → 0.65 s. (PR-090) 2.1 s → 0.65 s. (PR-090)
- With a persistent object cache, transient entries now expire: a timeout - With a persistent object cache, transient entries now expire: a value after
entry when it records, a value after at most a week (`MAX_CACHE_TTL`; a week (`MAX_CACHE_TTL`; HivePress writes it before its timeout, and a miss
HivePress writes the value before its timeout, and a miss only makes it only makes it rebuild), its timeout a minute after the later of that and its
rebuild). Entries of deleted posts no longer pile up. (PR-099) 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 ### Upgrading
@@ -41,9 +53,12 @@ Released with 2meet-data-optimizer 1.3.0 (hardening 2.3).
``` ```
redis-cli --scan --pattern '<WP_CACHE_KEY_SALT>wp:tmdo_hp_transient:*' \ redis-cli --scan --pattern '<WP_CACHE_KEY_SALT>wp:tmdo_hp_transient:*' \
| awk '{printf "EXPIRE %s 604800 NX\r\n", $0}' | redis-cli --pipe | awk '{printf "EXPIRE %s 604800 NX\r\n", $0}' | redis-cli --pipe
redis-cli --scan --pattern '<WP_CACHE_KEY_SALT>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 (`<salt><blog prefix>:<group>:<key>`); follows the drop-in's key layout (`<salt><blog prefix>:<group>:<key>`);
check it with `redis-cli --scan --pattern '<salt>*tmdo_hp_transient*' | head` check it with `redis-cli --scan --pattern '<salt>*tmdo_hp_transient*' | head`
first. redis-cli 7 has no `--count` with `--scan`. first. redis-cli 7 has no `--count` with `--scan`.
@@ -76,6 +76,7 @@ final class TMDO_HivePress_Entity_Groups {
self::register_hp_request_core_group(); self::register_hp_request_core_group();
self::register_hp_vendor_core_group(); self::register_hp_vendor_core_group();
self::register_hp_user_group(); self::register_hp_user_group();
self::register_hp_listing_attributes_group();
} }
// ── Group definitions ──────────────────────────────────────────────────── // ── Group definitions ────────────────────────────────────────────────────
@@ -271,6 +272,47 @@ 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
* 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). * HivePress request core meta keys (post_type=hp_request).
* *
@@ -270,9 +270,10 @@ final class TMDO_Hivepress_Transient_Filter {
* In wp_options both halves of the transient pair are written with * In wp_options both halves of the transient pair are written with
* `autoload=no` (they are read on-demand only — never via * `autoload=no` (they are read on-demand only — never via
* wp_load_alloptions() — so eagerly loading them on every page would defeat * 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 * the point of the move). In the object cache the value, written before its
* it says; the value, written before its timeout, after MAX_CACHE_TTL — a * timeout, expires after MAX_CACHE_TTL (a miss only makes HivePress rebuild
* miss only makes HivePress rebuild it — so entries of deleted posts do * 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). * not pile up (2.3 PR-099).
* *
* @param int $post_id Post ID. * @param int $post_id Post ID.
@@ -286,7 +287,9 @@ final class TMDO_Hivepress_Transient_Filter {
$option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated; $option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated;
if ( wp_using_ext_object_cache() ) { 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 ); wp_cache_set( $option_name, $meta_value, self::CACHE_GROUP, $ttl );
return; return;
} }
+6
View File
@@ -24,6 +24,12 @@ if ( ! file_exists( $tmdo_core_bootstrap ) ) {
} }
require_once $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_PATH', dirname( __DIR__ ) . '/' );
define( 'TMDO_HIVEPRESS_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer-hivepress-addon/' ); 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' ); define( 'TMDO_HIVEPRESS_FILE', dirname( __DIR__ ) . '/2meet-data-optimizer-hivepress-addon.php' );
@@ -214,9 +214,10 @@ class HivepressTransientFilterTest extends TestCase {
} }
/** /**
* Entries expire in the object cache too (2.3 PR-099): the timeout at the * Entries expire in the object cache too (2.3 PR-099): the value (written
* time it records, the value (written first, before its timeout is known) * first, before its timeout is known) after a week, the timeout after the
* after at most a week. Orphans of deleted posts no longer pile up. * 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 { public function test_with_an_object_cache_entries_get_a_ttl(): void {
$GLOBALS['_wp_using_ext_object_cache'] = true; $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 ]; $ttl = $GLOBALS['_wp_cache_ttl'][ WPDO_Hivepress_Transient_Filter::CACHE_GROUP ];
$this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL, $ttl[ '_transient_' . $translated ] ); $this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL, $ttl[ '_transient_' . $translated ] );
$this->assertEqualsWithDelta( 3600, $ttl[ '_transient_timeout_' . $translated ], 2 ); // A timeout must outlive its value: HivePress takes a missing timeout for
$this->assertSame( 1, $ttl[ '_transient_timeout_' . WPDO_Hivepress_Transient_Filter::translate_key( 101, $key ) ], 'already expired' ); // "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 { public function test_with_an_object_cache_a_value_left_in_wp_options_is_not_read(): void {
+61
View File
@@ -73,6 +73,67 @@ 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.
'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 { public function test_shared_keys_keep_their_current_owner(): void {
foreach ( self::SHARED_KEY_OWNERS as $key => $group ) { foreach ( self::SHARED_KEY_OWNERS as $key => $group ) {
$this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key )['group'], $key ); $this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key )['group'], $key );