Commit 4db593fd71d for woocommerce

commit 4db593fd71db55d84094a679397805e258551a76
Author: oxfordmetadata <167354013+oxfordmetadata@users.noreply.github.com>
Date:   Thu Jul 23 16:35:39 2026 +0300

    Memoize CallbackUtil::get_hook_callback_signatures() per request (#66882)

    * Memoize CallbackUtil::get_hook_callback_signatures() per request

    get_hook_callback_signatures() recomputed every callback signature on
    every call. WC_Product_Variable_Data_Store_CPT::get_price_hash() calls it
    three times (once per variation-price filter), and get_price_hash() runs
    per variable product per request, twice where the opposite tax hash is
    computed.

    For closure and invokable callbacks each signature costs a
    ReflectionFunction / ReflectionMethod construction, so repeating the work
    made the non-legacy price-hash branch slower than the legacy one on
    closure-heavy hooks.

    Memoize the result per hook, keyed on a cheap identity fingerprint of the
    registered callables (priority, class name and object id, no reflection).
    Signatures are recomputed only when the registered set actually changes,
    so add_filter() / remove_filter() mid-request remain correct. The cache
    entry retains a reference to the callables it described so their object
    ids cannot be reused while the entry is live.

    Measured on PHP 8.5.4, 20k iterations, ns per call across the three
    variation-price filters (legacy / CallbackUtil / memoized):

      named function        2,083 /  1,208 /  1,239   -40.5% vs legacy
      method, public state  2,030 /  1,778 /  1,553   -23.5%
      closure x1            2,082 /  2,406 /  1,285   -38.3%
      closure x5            7,808 / 10,097 /  3,465   -55.6%
      closure x20          28,351 / 40,333 / 11,080   -60.9%
      realistic mix         6,174 /  7,147 /  3,780   -38.8%

    With this change the non-legacy branch is faster than legacy in every
    measured shape. No public API change; return values are unchanged.

    Refs #37629

    * Fingerprint unsupported callback shapes distinctly

    The fallback branch mapped every callback shape not matched by the string,
    array or object branches to a constant 'priority:?'. get_callback_signature()
    serializes those shapes into distinct values, so two different unsupported
    callbacks registered at the same priority produced an identical fingerprint
    and the second one returned the first one's cached signature.

    Restrict the array fast path to object and string targets, mirroring
    get_callback_signature()'s own branching, and delegate every remaining shape
    to get_callback_signature() itself. The fingerprint can then never be coarser
    than the value it guards.

    Adds a regression test covering a replaced unsupported callback.

    * Address review nitpicks: drop @since from private method, cover closures

    Remove the @since annotation from the private get_callbacks_fingerprint(),
    per the convention that private methods do not carry them.

    Add two tests covering the closure object-id path: two distinct closures
    sharing a priority produce two distinct signatures, and replacing a closure
    at the same priority invalidates the memo.

    * Free the first closure so the object-id reuse path is exercised

    The closure-swap test kept the first closure in scope for its whole body, so
    PHP could never recycle its object id and the test passed without reaching the
    case the retained-callbacks reference guards against.

    Unsetting it after remove_action() lets the replacement land on the same id.
    Verified on PHP 8.5: the id is recycled, and without the retained reference the
    memo returns the first closure's signature for the second closure.

    * Compare the callback arrays directly instead of fingerprinting them

    Per review: storing $wp_filter[$hook_name]->callbacks and comparing it with ===
    detects every change the fingerprint did, and lets PHP do the matching. The
    fingerprint helper is removed entirely.

    It is also much faster, and — unlike the fingerprint — flat with respect to the
    number of registered callbacks, because nothing is rebuilt per call. Measured on
    PHP 8.5.4, 50k iterations, ns per call across the three variation-price filters:

      shape             legacy   CallbackUtil   vs legacy
      plain function     2,120            440      -79.2%
      method (public)    2,025            464      -77.1%
      method (private)   2,053            431      -79.0%
      closure x1         2,046            449      -78.0%
      closure x5         7,504            473      -93.7%
      closure x20       28,414            454      -98.4%
      realistic mix      6,696            460      -93.1%
      as-installed       2,200            455      -79.3%

    For comparison, the fingerprint version measured ~3,465 ns at 5 closures and
    ~11,900 ns at 20; this is ~455 ns in both cases.

    Object identity is what makes it correct: PHP compares the objects inside those
    arrays by identity, so a replaced instance or closure is a mismatch. Holding the
    array also keeps their object ids from being reused while the entry is live,
    which is what the retention comment previously explained.

    Verified: add_filter() and remove_filter() are both still detected mid-request,
    and repeated calls remain stable.

    Refs #37629

    * Address review: assertSame, compact comments, tighter changelog

    * assertEquals/assertNotEquals -> assertSame/assertNotSame across the tests
      added by this PR (8 and 3 respectively). Pre-existing tests left alone to
      keep the diff to this PR's own additions. Verified every converted assertion
      still holds under strict comparison, since assertSame also compares types.
    * Compacted the two multi-line comments on get_hook_callback_signatures.
    * Dropped the stray blank line left where the fingerprint helper was removed.
    * Tightened the changelog entry to one line.

    * Fix wording the previous commit broke, and a comment left behind

    Compacting went too far in two places:

    * "skip the reflection closures need" reads as a garden path — "the reflection
      closure" parses as a noun phrase before the sentence recovers. Restored "the
      reflection work that closure and invokable callbacks require", in both the
      docblock and the changelog entry, which ships verbatim into the release notes.
      The compacted docblock had also dropped invokables, which use ReflectionMethod
      and are equally covered.
    * The closure-swap test still described the fingerprint mechanism removed in
      1f5cc7c, and claimed the unset() frees the closure — it does not, since the
      memo retains the callbacks array. The test remains a valid regression guard;
      the comment now says what it actually guards.

    ---------

    Co-authored-by: Vladimir Reznichenko <kalessil@gmail.com>

diff --git a/plugins/woocommerce/changelog/perf-callbackutil-memoise-hook-signatures b/plugins/woocommerce/changelog/perf-callbackutil-memoise-hook-signatures
new file mode 100644
index 00000000000..76dbdde2dd0
--- /dev/null
+++ b/plugins/woocommerce/changelog/perf-callbackutil-memoise-hook-signatures
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Memoize CallbackUtil::get_hook_callback_signatures() per request, so repeated calls skip the reflection work that closure and invokable callbacks require.
diff --git a/plugins/woocommerce/src/Utilities/CallbackUtil.php b/plugins/woocommerce/src/Utilities/CallbackUtil.php
index d1d8ae307d6..16698f2ae30 100644
--- a/plugins/woocommerce/src/Utilities/CallbackUtil.php
+++ b/plugins/woocommerce/src/Utilities/CallbackUtil.php
@@ -70,6 +70,10 @@ final class CallbackUtil {
 	 * Closure signatures are based on their file location and line numbers,
 	 * providing consistent hashes across requests for the same closure code.
 	 *
+	 * Memoized per hook for the request, recomputed only when the registered
+	 * callbacks change, so repeated calls skip the reflection work that closure
+	 * and invokable callbacks require.
+	 *
 	 * @param string $hook_name The name of the hook to inspect.
 	 * @return array<int, array<string>> Array of priority => array( signatures ),  empty if hook has no callbacks.
 	 *
@@ -78,19 +82,35 @@ final class CallbackUtil {
 	public static function get_hook_callback_signatures( string $hook_name ): array {
 		global $wp_filter;

+		static $cache = array();
+
 		if ( ! isset( $wp_filter[ $hook_name ] ) ) {
+			unset( $cache[ $hook_name ] );
 			return array();
 		}

+		$callbacks_by_priority = $wp_filter[ $hook_name ]->callbacks;
+
+		// Objects inside compare by identity, so a replaced instance or closure is
+		// a mismatch; holding the array also stops their ids being reused.
+		if ( isset( $cache[ $hook_name ] ) && $cache[ $hook_name ]['callbacks'] === $callbacks_by_priority ) {
+			return $cache[ $hook_name ]['signatures'];
+		}
+
 		$result = array();

-		foreach ( $wp_filter[ $hook_name ]->callbacks as $priority => $priority_callbacks ) {
+		foreach ( $callbacks_by_priority as $priority => $priority_callbacks ) {
 			$result[ $priority ] = array_map(
 				fn( $callback_data ) => self::get_callback_signature( $callback_data['function'] ),
 				array_values( $priority_callbacks )
 			);
 		}

+		$cache[ $hook_name ] = array(
+			'callbacks'  => $callbacks_by_priority,
+			'signatures' => $result,
+		);
+
 		return $result;
 	}

diff --git a/plugins/woocommerce/tests/php/src/Utilities/CallbackUtilTest.php b/plugins/woocommerce/tests/php/src/Utilities/CallbackUtilTest.php
index e157b7a61ad..fd14792f9a7 100644
--- a/plugins/woocommerce/tests/php/src/Utilities/CallbackUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Utilities/CallbackUtilTest.php
@@ -391,4 +391,182 @@ class CallbackUtilTest extends \WC_Unit_Test_Case {
 		$this->assertIsArray( $signatures );
 		$this->assertEmpty( $signatures );
 	}
+
+	/**
+	 * @testdox `get_hook_callback_signatures` should return the same result on repeated calls.
+	 */
+	public function test_get_hook_callback_signatures_is_stable_across_repeated_calls() {
+		$hook_name = 'test_hook_memo_stable';
+
+		add_action( $hook_name, '__return_true', 10 );
+		add_action( $hook_name, fn() => true, 20 );
+
+		$first  = CallbackUtil::get_hook_callback_signatures( $hook_name );
+		$second = CallbackUtil::get_hook_callback_signatures( $hook_name );
+
+		$this->assertSame( $first, $second );
+		$this->assertSame( '__return_true', $first[10][0] );
+
+		remove_all_actions( $hook_name );
+	}
+
+	/**
+	 * @testdox `get_hook_callback_signatures` should pick up a callback added after a previous call.
+	 */
+	public function test_get_hook_callback_signatures_detects_added_callback() {
+		$hook_name = 'test_hook_memo_added';
+
+		add_action( $hook_name, '__return_true', 10 );
+		$before = CallbackUtil::get_hook_callback_signatures( $hook_name );
+
+		add_action( $hook_name, '__return_false', 30 );
+		$after = CallbackUtil::get_hook_callback_signatures( $hook_name );
+
+		$this->assertArrayNotHasKey( 30, $before );
+		$this->assertArrayHasKey( 30, $after );
+		$this->assertSame( '__return_false', $after[30][0] );
+
+		remove_all_actions( $hook_name );
+	}
+
+	/**
+	 * @testdox `get_hook_callback_signatures` should pick up a callback removed after a previous call.
+	 */
+	public function test_get_hook_callback_signatures_detects_removed_callback() {
+		$hook_name = 'test_hook_memo_removed';
+
+		add_action( $hook_name, '__return_true', 10 );
+		add_action( $hook_name, '__return_false', 30 );
+		CallbackUtil::get_hook_callback_signatures( $hook_name );
+
+		remove_action( $hook_name, '__return_false', 30 );
+		$after = CallbackUtil::get_hook_callback_signatures( $hook_name );
+
+		$this->assertArrayHasKey( 10, $after );
+		$this->assertArrayNotHasKey( 30, $after );
+
+		remove_all_actions( $hook_name );
+	}
+
+	/**
+	 * @testdox `get_hook_callback_signatures` should distinguish different classes exposing the same method name.
+	 */
+	public function test_get_hook_callback_signatures_detects_swapped_callback_object() {
+		$hook_name = 'test_hook_memo_swapped';
+
+		$first_object = new DummyCallbackClass();
+		add_action( $hook_name, array( $first_object, 'my_method' ), 10 );
+		$before = CallbackUtil::get_hook_callback_signatures( $hook_name );
+
+		remove_action( $hook_name, array( $first_object, 'my_method' ), 10 );
+
+		$second_object = new AnotherDummyCallbackClass();
+		add_action( $hook_name, array( $second_object, 'my_method' ), 10 );
+		$after = CallbackUtil::get_hook_callback_signatures( $hook_name );
+
+		$this->assertSame( DummyCallbackClass::class . '::my_method', $before[10][0] );
+		$this->assertSame( AnotherDummyCallbackClass::class . '::my_method', $after[10][0] );
+
+		remove_all_actions( $hook_name );
+	}
+
+	/**
+	 * @testdox `get_hook_callback_signatures` should return an empty array once every callback is removed.
+	 */
+	public function test_get_hook_callback_signatures_after_all_callbacks_removed() {
+		$hook_name = 'test_hook_memo_emptied';
+
+		add_action( $hook_name, '__return_true', 10 );
+		$this->assertNotEmpty( CallbackUtil::get_hook_callback_signatures( $hook_name ) );
+
+		remove_all_actions( $hook_name );
+
+		$this->assertEmpty( CallbackUtil::get_hook_callback_signatures( $hook_name ) );
+	}
+
+	/**
+	 * @testdox `get_hook_callback_signatures` should distinguish callbacks that fall back to the serialized signature.
+	 */
+	public function test_get_hook_callback_signatures_detects_replaced_unsupported_callback() {
+		$hook_name = 'test_hook_memo_unsupported';
+
+		// Shapes that get_callback_signature() serializes rather than naming.
+		$first  = array( 'first_target', 'first_method', 'extra' );
+		$second = array( 'second_target', 'second_method', 'extra' );
+
+		add_action( $hook_name, $first, 10 );
+		$before = CallbackUtil::get_hook_callback_signatures( $hook_name );
+
+		remove_action( $hook_name, $first, 10 );
+		add_action( $hook_name, $second, 10 );
+		$after = CallbackUtil::get_hook_callback_signatures( $hook_name );
+
+		$this->assertSame( CallbackUtil::get_callback_signature( $first ), $before[10][0] );
+		$this->assertSame( CallbackUtil::get_callback_signature( $second ), $after[10][0] );
+		$this->assertNotSame( $before[10][0], $after[10][0] );
+
+		remove_all_actions( $hook_name );
+	}
+
+	/**
+	 * @testdox `get_hook_callback_signatures` should distinguish two different closures registered at the same priority.
+	 */
+	public function test_get_hook_callback_signatures_detects_replaced_closure_at_same_priority() {
+		$hook_name = 'test_hook_memo_closure_swap';
+
+		$first_closure = function () {
+			return 1;
+		};
+		add_action( $hook_name, $first_closure, 10 );
+		$before = CallbackUtil::get_hook_callback_signatures( $hook_name );
+
+		remove_action( $hook_name, $first_closure, 10 );
+
+		// Drop our own reference too, so the replacement is free to reuse the
+		// slot. Guards any future implementation that keys the memo on derived
+		// ids rather than on the callbacks array itself.
+		unset( $first_closure );
+
+		$second_closure = function () {
+			return 2;
+		};
+		add_action( $hook_name, $second_closure, 10 );
+		$after = CallbackUtil::get_hook_callback_signatures( $hook_name );
+
+		$this->assertStringStartsWith( 'Closure@', $before[10][0] );
+		$this->assertStringStartsWith( 'Closure@', $after[10][0] );
+		$this->assertNotSame( $before[10][0], $after[10][0] );
+
+		remove_all_actions( $hook_name );
+	}
+
+	/**
+	 * @testdox `get_hook_callback_signatures` should return one signature per closure sharing a priority.
+	 */
+	public function test_get_hook_callback_signatures_with_two_closures_at_same_priority() {
+		$hook_name = 'test_hook_memo_two_closures';
+
+		add_action(
+			$hook_name,
+			function () {
+				return 1;
+			},
+			10
+		);
+		add_action(
+			$hook_name,
+			function () {
+				return 2;
+			},
+			10
+		);
+
+		$signatures = CallbackUtil::get_hook_callback_signatures( $hook_name );
+
+		$this->assertCount( 2, $signatures[10] );
+		$this->assertNotSame( $signatures[10][0], $signatures[10][1] );
+		$this->assertSame( $signatures, CallbackUtil::get_hook_callback_signatures( $hook_name ) );
+
+		remove_all_actions( $hook_name );
+	}
 }