From 93117118dfe404ac1f303ef919ba9089cd1965d6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 22 Feb 2026 20:23:20 -0800 Subject: [PATCH 1/5] Prevent calling wp_cache_set_posts_last_changed() when touching post meta in WP_Sync_Post_Meta_Storage --- .../class-wp-sync-post-meta-storage.php | 41 +++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php index ae8a54cc81d94..472c07914ff26 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -79,9 +79,9 @@ public function add_update( string $room, $update ): bool { return false; } - $meta_id = add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $update, false ); - - return (bool) $meta_id; + return $this->with_suspended_posts_last_changed_update( + fn() => (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $update, false ) + ); } /** @@ -123,7 +123,10 @@ public function set_awareness_state( string $room, array $awareness ): bool { } // update_post_meta returns false if the value is the same as the existing value. - update_post_meta( $post_id, wp_slash( self::AWARENESS_META_KEY ), wp_slash( $awareness ) ); + $this->with_suspended_posts_last_changed_update( + fn() => update_post_meta( $post_id, self::AWARENESS_META_KEY, wp_slash( $awareness ) ) + ); + return true; } @@ -299,4 +302,34 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool return true; } + + /** + * Invokes the provided callback while the suspending setting the posts last_changed cache key. + * + * @since 7.0.0 + * @see wp_cache_set_posts_last_changed() + * + * @template T + * @param Closure(): T $callback Callback. + * @return T Return value from the callback. + */ + private function with_suspended_posts_last_changed_update( Closure $callback ) { + $priorities = array( + 'added_post_meta' => has_action( 'added_post_meta', 'wp_cache_set_posts_last_changed' ), + 'updated_post_meta' => has_action( 'updated_post_meta', 'wp_cache_set_posts_last_changed' ), + 'deleted_post_meta' => has_action( 'deleted_post_meta', 'wp_cache_set_posts_last_changed' ), + ); + foreach ( $priorities as $action => $priority ) { + if ( false !== $priority ) { + remove_action( $action, 'wp_cache_set_posts_last_changed', $priority ); + } + } + $return_value = $callback(); + foreach ( $priorities as $action => $priority ) { + if ( false !== $priority ) { + add_action( $action, 'wp_cache_set_posts_last_changed', $priority ); + } + } + return $return_value; + } } From fba4b2a8db2012c543f81d3654a662b8ad8db293 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 20 Mar 2026 09:36:38 -0600 Subject: [PATCH 2/5] Suspend via a special global --- .../class-wp-sync-post-meta-storage.php | 25 ++++++------------- src/wp-includes/post.php | 4 +++ 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php index 472c07914ff26..f7d3dc2c2656e 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -304,7 +304,8 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool } /** - * Invokes the provided callback while the suspending setting the posts last_changed cache key. + * Invokes the provided callback while the suspending setting the posts + * last_changed cache key via a special global flag. * * @since 7.0.0 * @see wp_cache_set_posts_last_changed() @@ -314,22 +315,12 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool * @return T Return value from the callback. */ private function with_suspended_posts_last_changed_update( Closure $callback ) { - $priorities = array( - 'added_post_meta' => has_action( 'added_post_meta', 'wp_cache_set_posts_last_changed' ), - 'updated_post_meta' => has_action( 'updated_post_meta', 'wp_cache_set_posts_last_changed' ), - 'deleted_post_meta' => has_action( 'deleted_post_meta', 'wp_cache_set_posts_last_changed' ), - ); - foreach ( $priorities as $action => $priority ) { - if ( false !== $priority ) { - remove_action( $action, 'wp_cache_set_posts_last_changed', $priority ); - } - } - $return_value = $callback(); - foreach ( $priorities as $action => $priority ) { - if ( false !== $priority ) { - add_action( $action, 'wp_cache_set_posts_last_changed', $priority ); - } + $GLOBALS['__suspend_posts_last_changed_update'] = true; + + try { + return $callback(); + } finally { + $GLOBALS['__suspend_posts_last_changed_update'] = false; } - return $return_value; } } diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 90b6c5e9e93e5..73bb9081638b9 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8443,6 +8443,10 @@ function wp_add_trashed_suffix_to_post_name_for_post( $post ) { * @since 5.0.0 */ function wp_cache_set_posts_last_changed() { + if ( isset( $GLOBALS['__suspend_posts_last_changed_update'] ) && true === $GLOBALS['__suspend_posts_last_changed_update'] ) { + return; + } + wp_cache_set_last_changed( 'posts' ); } From 4498e29d6a02c44fef35b558b61104b568eb4650 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 20 Mar 2026 10:29:36 -0600 Subject: [PATCH 3/5] Inline global setting --- .../class-wp-sync-post-meta-storage.php | 46 ++++++++----------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php index f7d3dc2c2656e..4e2961371a435 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -79,9 +79,15 @@ public function add_update( string $room, $update ): bool { return false; } - return $this->with_suspended_posts_last_changed_update( - fn() => (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $update, false ) - ); + // Suspend setting the posts last_changed cache key for this operation. + try { + $GLOBALS['__suspend_posts_last_changed_update'] = true; + return (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $update, false ); + } finally { + $GLOBALS['__suspend_posts_last_changed_update'] = false; + } + + return false; } /** @@ -122,11 +128,16 @@ public function set_awareness_state( string $room, array $awareness ): bool { return false; } - // update_post_meta returns false if the value is the same as the existing value. - $this->with_suspended_posts_last_changed_update( - fn() => update_post_meta( $post_id, self::AWARENESS_META_KEY, wp_slash( $awareness ) ) - ); + // Suspend setting the posts last_changed cache key for this operation. + try { + $GLOBALS['__suspend_posts_last_changed_update'] = true; + update_post_meta( $post_id, self::AWARENESS_META_KEY, wp_slash( $awareness ) ); + } finally { + $GLOBALS['__suspend_posts_last_changed_update'] = false; + } + // update_post_meta returns false if the value is the same as the existing value. + // ignore the return value since awareness updates are not critical. return true; } @@ -302,25 +313,4 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool return true; } - - /** - * Invokes the provided callback while the suspending setting the posts - * last_changed cache key via a special global flag. - * - * @since 7.0.0 - * @see wp_cache_set_posts_last_changed() - * - * @template T - * @param Closure(): T $callback Callback. - * @return T Return value from the callback. - */ - private function with_suspended_posts_last_changed_update( Closure $callback ) { - $GLOBALS['__suspend_posts_last_changed_update'] = true; - - try { - return $callback(); - } finally { - $GLOBALS['__suspend_posts_last_changed_update'] = false; - } - } } From 98b851aeecc0e105ec94896b6f6ac073648234c0 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 20 Mar 2026 10:38:23 -0600 Subject: [PATCH 4/5] Use counter approach --- .../class-wp-sync-post-meta-storage.php | 14 ++++++++++---- src/wp-includes/post.php | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php index 4e2961371a435..bc1ace5b12714 100644 --- a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -80,11 +80,14 @@ public function add_update( string $room, $update ): bool { } // Suspend setting the posts last_changed cache key for this operation. + if ( ! isset( $GLOBALS['__suspend_posts_last_changed_update'] ) ) { + $GLOBALS['__suspend_posts_last_changed_update'] = 0; + } + ++$GLOBALS['__suspend_posts_last_changed_update']; try { - $GLOBALS['__suspend_posts_last_changed_update'] = true; return (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $update, false ); } finally { - $GLOBALS['__suspend_posts_last_changed_update'] = false; + --$GLOBALS['__suspend_posts_last_changed_update']; } return false; @@ -129,11 +132,14 @@ public function set_awareness_state( string $room, array $awareness ): bool { } // Suspend setting the posts last_changed cache key for this operation. + if ( ! isset( $GLOBALS['__suspend_posts_last_changed_update'] ) ) { + $GLOBALS['__suspend_posts_last_changed_update'] = 0; + } + ++$GLOBALS['__suspend_posts_last_changed_update']; try { - $GLOBALS['__suspend_posts_last_changed_update'] = true; update_post_meta( $post_id, self::AWARENESS_META_KEY, wp_slash( $awareness ) ); } finally { - $GLOBALS['__suspend_posts_last_changed_update'] = false; + --$GLOBALS['__suspend_posts_last_changed_update']; } // update_post_meta returns false if the value is the same as the existing value. diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 73bb9081638b9..b54d58cc3cb39 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8443,7 +8443,7 @@ function wp_add_trashed_suffix_to_post_name_for_post( $post ) { * @since 5.0.0 */ function wp_cache_set_posts_last_changed() { - if ( isset( $GLOBALS['__suspend_posts_last_changed_update'] ) && true === $GLOBALS['__suspend_posts_last_changed_update'] ) { + if ( ! empty( $GLOBALS['__suspend_posts_last_changed_update'] ) ) { return; } From dc85bf74a91b2023572fc422ee7ac81060b82106 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 20 Mar 2026 11:19:00 -0600 Subject: [PATCH 5/5] Add unit tests for wp_cache_set_last_changed --- .../tests/post/wpCacheSetPostsLastChanged.php | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 tests/phpunit/tests/post/wpCacheSetPostsLastChanged.php diff --git a/tests/phpunit/tests/post/wpCacheSetPostsLastChanged.php b/tests/phpunit/tests/post/wpCacheSetPostsLastChanged.php new file mode 100644 index 0000000000000..b62e18a55df5b --- /dev/null +++ b/tests/phpunit/tests/post/wpCacheSetPostsLastChanged.php @@ -0,0 +1,259 @@ +post->create(); + } + + public static function wpTearDownAfterClass() { + wp_delete_post( self::$post_id, true ); + } + + public function tear_down() { + unset( $GLOBALS['__suspend_posts_last_changed_update'] ); + parent::tear_down(); + } + + /** + * Verifies that the function updates the 'posts' cache group. + */ + public function test_sets_last_changed_for_posts_group() { + wp_cache_delete( 'last_changed', 'posts' ); + + wp_cache_set_posts_last_changed(); + + $this->assertNotFalse( wp_cache_get( 'last_changed', 'posts' ) ); + } + + /** + * Verifies that the function is suspended when the global counter is set. + */ + public function test_suspended_when_global_is_set() { + wp_cache_delete( 'last_changed', 'posts' ); + + $GLOBALS['__suspend_posts_last_changed_update'] = 1; + + wp_cache_set_posts_last_changed(); + + $this->assertFalse( wp_cache_get( 'last_changed', 'posts' ) ); + } + + /** + * Verifies that the function resumes after the global counter reaches zero. + */ + public function test_resumes_when_global_reaches_zero() { + wp_cache_delete( 'last_changed', 'posts' ); + + $GLOBALS['__suspend_posts_last_changed_update'] = 0; + + wp_cache_set_posts_last_changed(); + + $this->assertNotFalse( wp_cache_get( 'last_changed', 'posts' ) ); + } + + /** + * Verifies that add_post_meta updates last_changed when not suspended. + */ + public function test_add_post_meta_updates_last_changed() { + wp_cache_set_posts_last_changed(); + $before = wp_cache_get( 'last_changed', 'posts' ); + + // Ensure the microtime value changes. + usleep( 1 ); + + add_post_meta( self::$post_id, '_test_meta_add', 'value' ); + + $after = wp_cache_get( 'last_changed', 'posts' ); + $this->assertNotSame( $before, $after, 'add_post_meta should update last_changed.' ); + } + + /** + * Verifies that add_post_meta does not update last_changed when suspended. + */ + public function test_add_post_meta_suspended() { + wp_cache_set_posts_last_changed(); + $before = wp_cache_get( 'last_changed', 'posts' ); + + // Ensure the microtime value changes. + usleep( 1 ); + + $GLOBALS['__suspend_posts_last_changed_update'] = 1; + add_post_meta( self::$post_id, '_test_meta_add_suspended', 'value' ); + $GLOBALS['__suspend_posts_last_changed_update'] = 0; + + $after = wp_cache_get( 'last_changed', 'posts' ); + $this->assertSame( $before, $after, 'add_post_meta should not update last_changed when suspended.' ); + } + + /** + * Verifies that update_post_meta updates last_changed when not suspended. + */ + public function test_update_post_meta_updates_last_changed() { + add_post_meta( self::$post_id, '_test_meta_update', 'old' ); + + wp_cache_set_posts_last_changed(); + $before = wp_cache_get( 'last_changed', 'posts' ); + + // Ensure the microtime value changes. + usleep( 1 ); + + update_post_meta( self::$post_id, '_test_meta_update', 'new' ); + + $after = wp_cache_get( 'last_changed', 'posts' ); + $this->assertNotSame( $before, $after, 'update_post_meta should update last_changed.' ); + } + + /** + * Verifies that update_post_meta does not update last_changed when suspended. + */ + public function test_update_post_meta_suspended() { + add_post_meta( self::$post_id, '_test_meta_update_suspended', 'old' ); + + wp_cache_set_posts_last_changed(); + $before = wp_cache_get( 'last_changed', 'posts' ); + + // Ensure the microtime value changes. + usleep( 1 ); + + $GLOBALS['__suspend_posts_last_changed_update'] = 1; + update_post_meta( self::$post_id, '_test_meta_update_suspended', 'new' ); + $GLOBALS['__suspend_posts_last_changed_update'] = 0; + + $after = wp_cache_get( 'last_changed', 'posts' ); + $this->assertSame( $before, $after, 'update_post_meta should not update last_changed when suspended.' ); + } + + /** + * Verifies that delete_post_meta updates last_changed when not suspended. + */ + public function test_delete_post_meta_updates_last_changed() { + add_post_meta( self::$post_id, '_test_meta_delete', 'value' ); + + wp_cache_set_posts_last_changed(); + $before = wp_cache_get( 'last_changed', 'posts' ); + + // Ensure the microtime value changes. + usleep( 1 ); + + delete_post_meta( self::$post_id, '_test_meta_delete' ); + + $after = wp_cache_get( 'last_changed', 'posts' ); + $this->assertNotSame( $before, $after, 'delete_post_meta should update last_changed.' ); + } + + /** + * Verifies that delete_post_meta does not update last_changed when suspended. + */ + public function test_delete_post_meta_suspended() { + add_post_meta( self::$post_id, '_test_meta_delete_suspended', 'value' ); + + wp_cache_set_posts_last_changed(); + $before = wp_cache_get( 'last_changed', 'posts' ); + + // Ensure the microtime value changes. + usleep( 1 ); + + $GLOBALS['__suspend_posts_last_changed_update'] = 1; + delete_post_meta( self::$post_id, '_test_meta_delete_suspended' ); + $GLOBALS['__suspend_posts_last_changed_update'] = 0; + + $after = wp_cache_get( 'last_changed', 'posts' ); + $this->assertSame( $before, $after, 'delete_post_meta should not update last_changed when suspended.' ); + } + + /** + * Verifies that an embedded post meta operation triggered by a hook during + * a suspended call does not prematurely unsuspend the counter. + * + * Simulates: outer add_post_meta (suspended) triggers a hook that calls + * update_post_meta internally. The inner operation should not reset the + * counter, and last_changed should remain unchanged after both complete. + */ + public function test_nested_post_meta_via_hook_respects_suspension_counter() { + // When the outer add_post_meta fires 'added_post_meta', this hook + // performs its own update_post_meta while the counter is still active. + $nested_callback = function ( $meta_id, $post_id, $meta_key ) { + if ( '_test_outer_meta' !== $meta_key ) { + return; + } + + // Increment the counter to simulate a nested suspended operation. + ++$GLOBALS['__suspend_posts_last_changed_update']; + try { + update_post_meta( $post_id, '_test_inner_meta', 'inner_value' ); + } finally { + --$GLOBALS['__suspend_posts_last_changed_update']; + } + }; + + add_action( 'added_post_meta', $nested_callback, 10, 3 ); + + wp_cache_set_posts_last_changed(); + $before = wp_cache_get( 'last_changed', 'posts' ); + + // Ensure the microtime value changes. + usleep( 1 ); + + // Outer suspended operation. + $GLOBALS['__suspend_posts_last_changed_update'] = 1; + add_post_meta( self::$post_id, '_test_outer_meta', 'outer_value' ); + $GLOBALS['__suspend_posts_last_changed_update'] = 0; + + $after = wp_cache_get( 'last_changed', 'posts' ); + + remove_action( 'added_post_meta', $nested_callback, 10 ); + + $this->assertSame( $before, $after, 'Nested post meta via hook should not update last_changed while suspended.' ); + $this->assertSame( 'inner_value', get_post_meta( self::$post_id, '_test_inner_meta', true ), 'Inner meta should still be written.' ); + } + + /** + * Verifies that last_changed is updated normally after suspension ends, + * even when a nested operation occurred during the suspended window. + */ + public function test_last_changed_updates_after_nested_suspension_ends() { + $nested_callback = function ( $mid, $post_id, $meta_key ) { + if ( '_test_outer_meta_2' !== $meta_key ) { + return; + } + ++$GLOBALS['__suspend_posts_last_changed_update']; + try { + update_post_meta( $post_id, '_test_inner_meta_2', 'inner' ); + } finally { + --$GLOBALS['__suspend_posts_last_changed_update']; + } + }; + + add_action( 'added_post_meta', $nested_callback, 10, 3 ); + + // Suspended operation with nested hook. + $GLOBALS['__suspend_posts_last_changed_update'] = 1; + add_post_meta( self::$post_id, '_test_outer_meta_2', 'outer' ); + $GLOBALS['__suspend_posts_last_changed_update'] = 0; + + remove_action( 'added_post_meta', $nested_callback, 10 ); + + // Record the cache value after the suspended window. + wp_cache_set_posts_last_changed(); + $before = wp_cache_get( 'last_changed', 'posts' ); + + // A normal (unsuspended) meta operation should update last_changed. + usleep( 1 ); + + add_post_meta( self::$post_id, '_test_after_nested', 'value' ); + + $after = wp_cache_get( 'last_changed', 'posts' ); + $this->assertNotSame( $before, $after, 'last_changed should update normally after suspension ends.' ); + } +}