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)
This commit is contained in:
2026-09-27 17:33:13 +08:00
parent c139ebca89
commit 69cd4b6f8b
5 changed files with 63 additions and 13 deletions
@@ -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(
@@ -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;
}
+6
View File
@@ -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' );
@@ -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 {
+28
View File
@@ -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 );