Skip to content

Commit 61a00fa

Browse files
committed
Preserve child wrapper identity in native AST facade
The native parser extension constructs a fresh WP_MySQL_Native_Parser_Node on every accessor call, so two reads of the same logical node returned different PHP objects and any state a caller attached to the first wrapper was invisible through the second. WP_Parser_Node has always given callers stable child identity, and the lazy native facade is meant to keep that contract intact — this restores it. A per-AST identity map is created lazily on the root and shared by every interned wrapper. Each accessor that returns a node looks the index up in the map and returns the canonical instance, discarding the freshly constructed one. Materialization pulls children through the same map so mutations a caller made through get_first_child_node() before triggering append_child() survive into $this->children. Adds a regression test that exercises identity across child, descendant, and post-materialization reads, plus a walk benchmark and a CI workflow that reports parse + walk time and peak memory for the PHP and native paths so the cache cost is measurable on every PR.
1 parent 076b7e5 commit 61a00fa

6 files changed

Lines changed: 476 additions & 8 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
name: Native AST Walk Perf
2+
3+
on:
4+
push:
5+
paths:
6+
- '.github/workflows/native-ast-perf.yml'
7+
- 'packages/mysql-on-sqlite/src/mysql/native/**'
8+
- 'packages/mysql-on-sqlite/tests/tools/run-native-ast-walk-benchmark.php'
9+
- 'packages/php-ext-wp-mysql-parser/**'
10+
pull_request:
11+
paths:
12+
- '.github/workflows/native-ast-perf.yml'
13+
- 'packages/mysql-on-sqlite/src/mysql/native/**'
14+
- 'packages/mysql-on-sqlite/tests/tools/run-native-ast-walk-benchmark.php'
15+
- 'packages/php-ext-wp-mysql-parser/**'
16+
workflow_dispatch:
17+
18+
concurrency:
19+
group: ${{ github.workflow }}-${{ github.ref }}
20+
cancel-in-progress: true
21+
22+
jobs:
23+
perf:
24+
name: Native AST walk benchmark
25+
runs-on: ubuntu-latest
26+
timeout-minutes: 25
27+
28+
steps:
29+
- name: Checkout repository
30+
uses: actions/checkout@v4
31+
32+
- name: Set up PHP
33+
uses: shivammathur/setup-php@v2
34+
with:
35+
php-version: '8.2'
36+
coverage: none
37+
38+
- name: Set up Rust
39+
uses: dtolnay/rust-toolchain@stable
40+
41+
- name: Install native build dependencies
42+
run: |
43+
sudo apt-get update
44+
sudo apt-get install -y libclang-dev
45+
echo "PHP_CONFIG=$(command -v php-config)" >> "$GITHUB_ENV"
46+
LIBCLANG_SO="$(find /usr/lib -name 'libclang.so*' | head -n 1)"
47+
echo "LIBCLANG_PATH=$(dirname "$LIBCLANG_SO")" >> "$GITHUB_ENV"
48+
49+
- name: Install Composer dependencies (root)
50+
uses: ramsey/composer-install@v3
51+
with:
52+
ignore-cache: "yes"
53+
composer-options: "--optimize-autoloader"
54+
55+
- name: Install Composer dependencies (mysql-on-sqlite)
56+
uses: ramsey/composer-install@v3
57+
with:
58+
working-directory: packages/mysql-on-sqlite
59+
ignore-cache: "yes"
60+
composer-options: "--optimize-autoloader"
61+
62+
- name: Download MySQL test query corpus
63+
working-directory: packages/mysql-on-sqlite
64+
run: bash tests/tools/mysql-download-tests.sh || true
65+
66+
- name: Build parser extension (release)
67+
run: cargo build --release
68+
working-directory: packages/php-ext-wp-mysql-parser
69+
70+
- name: Locate built extension
71+
run: |
72+
EXT="$GITHUB_WORKSPACE/packages/php-ext-wp-mysql-parser/target/release/libwp_mysql_parser.so"
73+
test -f "$EXT" || { echo "Extension not built at $EXT"; exit 1; }
74+
echo "NATIVE_EXT=$EXT" >> "$GITHUB_ENV"
75+
76+
- name: Benchmark — pure-PHP path (parse only)
77+
working-directory: packages/mysql-on-sqlite
78+
run: |
79+
php tests/tools/run-native-ast-walk-benchmark.php --no-walk | tee php-parse-only.txt
80+
81+
- name: Benchmark — pure-PHP path (walk)
82+
working-directory: packages/mysql-on-sqlite
83+
run: |
84+
php tests/tools/run-native-ast-walk-benchmark.php | tee php-walk.txt
85+
86+
- name: Benchmark — native path (parse only)
87+
working-directory: packages/mysql-on-sqlite
88+
run: |
89+
php -d extension="$NATIVE_EXT" tests/tools/run-native-ast-walk-benchmark.php --no-walk | tee native-parse-only.txt
90+
91+
- name: Benchmark — native path (walk, with identity cache)
92+
working-directory: packages/mysql-on-sqlite
93+
run: |
94+
php -d extension="$NATIVE_EXT" tests/tools/run-native-ast-walk-benchmark.php | tee native-walk.txt
95+
96+
- name: Summarize
97+
if: always()
98+
working-directory: packages/mysql-on-sqlite
99+
run: |
100+
{
101+
echo '### Native AST walk perf'
102+
echo
103+
echo '| scenario | result |'
104+
echo '|---|---|'
105+
for f in php-parse-only.txt php-walk.txt native-parse-only.txt native-walk.txt; do
106+
[ -f "$f" ] || continue
107+
line="$(cat "$f")"
108+
echo "| ${f%.txt} | \`$line\` |"
109+
done
110+
} >> "$GITHUB_STEP_SUMMARY"
111+
112+
- name: Upload raw output
113+
if: always()
114+
uses: actions/upload-artifact@v4
115+
with:
116+
name: native-ast-perf-${{ github.run_id }}
117+
path: packages/mysql-on-sqlite/*.txt
118+
if-no-files-found: warn

packages/mysql-on-sqlite/src/load.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
if ( class_exists( 'WP_MySQL_Native_Parser', false ) ) {
2929
require_once __DIR__ . '/mysql/native/mysql-rust-bridge.php';
30+
require_once __DIR__ . '/mysql/native/class-wp-mysql-native-ast-cache.php';
3031
require_once __DIR__ . '/mysql/native/class-wp-mysql-native-parser-node.php';
3132
require_once __DIR__ . '/mysql/native/class-wp-mysql-parser.php';
3233
} else {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
/**
4+
* Per-AST identity map for native parser node wrappers.
5+
*
6+
* The native parser extension constructs a fresh WP_MySQL_Native_Parser_Node
7+
* every time it returns a child or descendant. Without an identity map, two
8+
* reads of the same logical node would yield distinct PHP objects, breaking
9+
* the WP_Parser_Node contract that callers can mutate a child in place and
10+
* see the mutation again when they walk the tree later.
11+
*
12+
* One cache is created lazily on the root node and shared by reference with
13+
* every wrapper interned through it. Lookup is keyed by the Rust-side
14+
* `node_index`, which is stable for the lifetime of the AST.
15+
*/
16+
class WP_MySQL_Native_AST_Cache {
17+
/**
18+
* Map of native node index => WP_MySQL_Native_Parser_Node.
19+
*
20+
* @var array<int, WP_MySQL_Native_Parser_Node>
21+
*/
22+
public $nodes = array();
23+
}

packages/mysql-on-sqlite/src/mysql/native/class-wp-mysql-native-parser-node.php

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,49 @@
88
* caller actually walks the tree. On the first mutation (append_child or
99
* merge_fragment), the node materializes its children into the inherited
1010
* `$children` array and behaves like a plain WP_Parser_Node from then on.
11+
*
12+
* Wrappers returned by accessors are interned through a per-AST identity
13+
* map (WP_MySQL_Native_AST_Cache) so two reads of the same logical node
14+
* yield the same PHP instance. This preserves the WP_Parser_Node contract
15+
* that mutations performed on a child via `get_first_child_node()` remain
16+
* visible when the same child is reached again, including after the parent
17+
* has materialized.
1118
*/
1219
class WP_MySQL_Native_Parser_Node extends WP_Parser_Node {
1320
private $native_ast = null;
1421
private $native_node_index = null;
1522
private $was_mutated = false;
1623

24+
/**
25+
* Per-AST identity map shared between every interned wrapper.
26+
*
27+
* Created lazily on the first child access; the root wrapper is the
28+
* first entry. Children inherit the same cache instance by reference.
29+
*
30+
* @var WP_MySQL_Native_AST_Cache|null
31+
*/
32+
private $cache = null;
33+
1734
public function __construct( $rule_id, $rule_name, $native_ast = null, $native_node_index = null ) {
1835
parent::__construct( $rule_id, $rule_name );
1936

2037
$this->native_ast = $native_ast;
2138
$this->native_node_index = $native_node_index;
2239
}
2340

41+
/**
42+
* Native node index in the Rust-owned arena.
43+
*
44+
* Exposed so the identity cache can key on it. Returns null after
45+
* the wrapper has materialized — at that point the node is detached
46+
* from the native arena and behaves like a plain WP_Parser_Node.
47+
*
48+
* @return int|null
49+
*/
50+
public function get_native_node_index(): ?int {
51+
return $this->native_node_index;
52+
}
53+
2454
/** @inheritDoc */
2555
public function append_child( $node ) {
2656
$this->materialize_native_children();
@@ -65,15 +95,15 @@ public function get_first_child() {
6595
if ( $this->was_mutated() ) {
6696
return parent::get_first_child();
6797
}
68-
return wp_sqlite_mysql_native_ast_get_first_child( $this->native_ast, $this->native_node_index );
98+
return $this->intern( wp_sqlite_mysql_native_ast_get_first_child( $this->native_ast, $this->native_node_index ) );
6999
}
70100

71101
/** @inheritDoc */
72102
public function get_first_child_node( ?string $rule_name = null ): ?WP_Parser_Node {
73103
if ( $this->was_mutated() ) {
74104
return parent::get_first_child_node( $rule_name );
75105
}
76-
return wp_sqlite_mysql_native_ast_get_first_child_node( $this->native_ast, $this->native_node_index, $rule_name );
106+
return $this->intern( wp_sqlite_mysql_native_ast_get_first_child_node( $this->native_ast, $this->native_node_index, $rule_name ) );
77107
}
78108

79109
/** @inheritDoc */
@@ -89,7 +119,7 @@ public function get_first_descendant_node( ?string $rule_name = null ): ?WP_Pars
89119
if ( $this->was_mutated() ) {
90120
return parent::get_first_descendant_node( $rule_name );
91121
}
92-
return wp_sqlite_mysql_native_ast_get_first_descendant_node( $this->native_ast, $this->native_node_index, $rule_name );
122+
return $this->intern( wp_sqlite_mysql_native_ast_get_first_descendant_node( $this->native_ast, $this->native_node_index, $rule_name ) );
93123
}
94124

95125
/** @inheritDoc */
@@ -105,15 +135,15 @@ public function get_children(): array {
105135
if ( $this->was_mutated() ) {
106136
return parent::get_children();
107137
}
108-
return wp_sqlite_mysql_native_ast_get_children( $this->native_ast, $this->native_node_index );
138+
return $this->intern_all( wp_sqlite_mysql_native_ast_get_children( $this->native_ast, $this->native_node_index ) );
109139
}
110140

111141
/** @inheritDoc */
112142
public function get_child_nodes( ?string $rule_name = null ): array {
113143
if ( $this->was_mutated() ) {
114144
return parent::get_child_nodes( $rule_name );
115145
}
116-
return wp_sqlite_mysql_native_ast_get_child_nodes( $this->native_ast, $this->native_node_index, $rule_name );
146+
return $this->intern_all( wp_sqlite_mysql_native_ast_get_child_nodes( $this->native_ast, $this->native_node_index, $rule_name ) );
117147
}
118148

119149
/** @inheritDoc */
@@ -129,15 +159,15 @@ public function get_descendants(): array {
129159
if ( $this->was_mutated() ) {
130160
return parent::get_descendants();
131161
}
132-
return wp_sqlite_mysql_native_ast_get_descendants( $this->native_ast, $this->native_node_index );
162+
return $this->intern_all( wp_sqlite_mysql_native_ast_get_descendants( $this->native_ast, $this->native_node_index ) );
133163
}
134164

135165
/** @inheritDoc */
136166
public function get_descendant_nodes( ?string $rule_name = null ): array {
137167
if ( $this->was_mutated() ) {
138168
return parent::get_descendant_nodes( $rule_name );
139169
}
140-
return wp_sqlite_mysql_native_ast_get_descendant_nodes( $this->native_ast, $this->native_node_index, $rule_name );
170+
return $this->intern_all( wp_sqlite_mysql_native_ast_get_descendant_nodes( $this->native_ast, $this->native_node_index, $rule_name ) );
141171
}
142172

143173
/** @inheritDoc */
@@ -168,12 +198,78 @@ private function was_mutated(): bool {
168198
return $this->was_mutated;
169199
}
170200

201+
/**
202+
* Intern a single accessor return value through the per-AST cache.
203+
*
204+
* Tokens and nulls pass through untouched. Native node wrappers are
205+
* keyed on their `native_node_index`: on cache miss, the freshly
206+
* constructed wrapper is stored and given the cache reference; on
207+
* cache hit, the canonical instance is returned and the new wrapper
208+
* is discarded so callers see stable identity and surviving mutations.
209+
*
210+
* @param mixed $value Return value from the Rust bridge.
211+
* @return mixed
212+
*/
213+
private function intern( $value ) {
214+
if ( ! $value instanceof WP_MySQL_Native_Parser_Node ) {
215+
return $value;
216+
}
217+
218+
$cache = $this->ensure_cache();
219+
$index = $value->native_node_index;
220+
if ( null === $index ) {
221+
return $value;
222+
}
223+
if ( isset( $cache->nodes[ $index ] ) ) {
224+
return $cache->nodes[ $index ];
225+
}
226+
$value->cache = $cache;
227+
$cache->nodes[ $index ] = $value;
228+
return $value;
229+
}
230+
231+
/**
232+
* Intern every entry in an accessor return array.
233+
*
234+
* @param array $values
235+
* @return array
236+
*/
237+
private function intern_all( array $values ): array {
238+
foreach ( $values as $i => $value ) {
239+
$values[ $i ] = $this->intern( $value );
240+
}
241+
return $values;
242+
}
243+
244+
/**
245+
* Lazily build (or reuse) the per-AST identity map.
246+
*
247+
* The root wrapper is constructed without a cache, so the first time
248+
* any accessor needs to intern a child, it creates the cache and
249+
* registers itself as the root entry. Subsequent interns on this
250+
* wrapper or any descendant share the same cache by reference.
251+
*
252+
* @return WP_MySQL_Native_AST_Cache
253+
*/
254+
private function ensure_cache(): WP_MySQL_Native_AST_Cache {
255+
if ( null === $this->cache ) {
256+
$this->cache = new WP_MySQL_Native_AST_Cache();
257+
if ( null !== $this->native_node_index ) {
258+
$this->cache->nodes[ $this->native_node_index ] = $this;
259+
}
260+
}
261+
return $this->cache;
262+
}
263+
171264
private function materialize_native_children(): void {
172265
if ( $this->was_mutated ) {
173266
return;
174267
}
175268

176-
$this->children = wp_sqlite_mysql_native_ast_get_children( $this->native_ast, $this->native_node_index );
269+
// Pull children through the cache so any wrapper a caller already
270+
// mutated via get_first_child_node() etc. survives the transition
271+
// into $this->children — same instance, same mutations.
272+
$this->children = $this->intern_all( wp_sqlite_mysql_native_ast_get_children( $this->native_ast, $this->native_node_index ) );
177273
$this->native_ast = null;
178274
$this->native_node_index = null;
179275
$this->was_mutated = true;

0 commit comments

Comments
 (0)