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 );
+ }
}