From 359949a83a5d9a084a20dd2c782dabcafadadea4 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 04:56:10 -0400 Subject: [PATCH 1/8] fix: preserve native transaction and lock semantics --- ...lass-wp-markdown-native-advisory-locks.php | 81 +++++++++++++++++++ ...lass-wp-markdown-native-query-executor.php | 31 ++++++- ...class-wp-markdown-native-query-runtime.php | 32 +++++++- .../class-wp-markdown-native-transactions.php | 5 +- inc/native/class-wp-markdown-native-wpdb.php | 3 + tests/probe-native-transaction-semantics.php | 4 + tests/smoke-native-advisory-locks.php | 46 +++++++++++ 7 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 inc/native/class-wp-markdown-native-advisory-locks.php create mode 100644 tests/smoke-native-advisory-locks.php diff --git a/inc/native/class-wp-markdown-native-advisory-locks.php b/inc/native/class-wp-markdown-native-advisory-locks.php new file mode 100644 index 0000000..edc25a0 --- /dev/null +++ b/inc/native/class-wp-markdown-native-advisory-locks.php @@ -0,0 +1,81 @@ + */ + private array $locks = array(); + + public function __construct( private readonly string $state_root ) {} + + /** Acquire a named lock, waiting no longer than the requested bounded timeout. */ + public function acquire( string $name, float $timeout ): bool { + if ( isset( $this->locks[ $name ] ) ) { + ++$this->locks[ $name ]['count']; + return true; + } + $directory = $this->state_root . DIRECTORY_SEPARATOR . self::DIRECTORY; + if ( ! is_dir( $directory ) && ! @mkdir( $directory, 0755, true ) && ! is_dir( $directory ) ) { + return false; + } + $path = $directory . DIRECTORY_SEPARATOR . hash( 'sha256', $name ) . '.lock'; + $handle = @fopen( $path, 'c+b' ); + if ( false === $handle ) { + return false; + } + $wait = min( max( 0, $timeout * 1000000 ), self::MAX_WAIT_MICROSECONDS ); + $deadline = hrtime( true ) + (int) ( $wait * 1000 ); + do { + if ( flock( $handle, LOCK_EX | LOCK_NB ) ) { + $this->locks[ $name ] = array( 'handle' => $handle, 'count' => 1, 'path' => $path ); + return true; + } + if ( 0.0 === $wait || hrtime( true ) >= $deadline ) { + break; + } + usleep( 10000 ); + } while ( true ); + fclose( $handle ); + return false; + } + + /** @return int|null One when released, zero when held by another connection, null when absent. */ + public function release( string $name ): ?int { + if ( ! isset( $this->locks[ $name ] ) ) { + return is_file( $this->path( $name ) ) ? 0 : null; + } + --$this->locks[ $name ]['count']; + if ( 0 < $this->locks[ $name ]['count'] ) { + return 1; + } + $lock = $this->locks[ $name ]; + unset( $this->locks[ $name ] ); + flock( $lock['handle'], LOCK_UN ); + fclose( $lock['handle'] ); + @unlink( $lock['path'] ); + return 1; + } + + /** Release every lock when the logical native connection closes. */ + public function close(): void { + foreach ( array_keys( $this->locks ) as $name ) { + $this->locks[ $name ]['count'] = 1; + $this->release( $name ); + } + } + + public function __destruct() { + $this->close(); + } + + private function path( string $name ): string { + return $this->state_root . DIRECTORY_SEPARATOR . self::DIRECTORY . DIRECTORY_SEPARATOR . hash( 'sha256', $name ) . '.lock'; + } +} diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index aa56648..3c8ce15 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -50,7 +50,8 @@ public function __construct( private ?WP_Markdown_Native_Table_Mutation_Runtime $table_mutations = null, private ?WP_Markdown_Native_Transaction_Journal $transactions = null, private ?WP_Markdown_Native_Post_Mutation_Runtime $post_mutations = null, - private int $correlated_subquery_limit = self::MAX_CORRELATED_SUBQUERY_EVALUATIONS + private int $correlated_subquery_limit = self::MAX_CORRELATED_SUBQUERY_EVALUATIONS, + private ?WP_Markdown_Native_Advisory_Locks $advisory_locks = null ) { $this->schema_introspection = new WP_Markdown_Native_Schema_Introspection( $registry ); } @@ -63,6 +64,10 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query if ( 1 === preg_match( '/^\s*(?:SHOW|DESCRIBE)\b/i', $request->sql() ) ) { return $this->schema_introspection->execute( $request ); } + $advisory_lock = $this->advisory_lock_query( $request->sql() ); + if ( null !== $advisory_lock ) { + return $advisory_lock; + } // The canonical store is a directory, not a named server database. if ( 1 === preg_match( '/^\s*SELECT\s+DATABASE\s*\(\s*\)\s*;?\s*$/i', $request->sql() ) ) { return WP_Markdown_Query_Result::selected( @@ -116,6 +121,30 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query return $this->execute_plan( $plan ); } + /** Release this logical connection's root-scoped advisory locks. */ + public function close(): void { + $this->advisory_locks?->close(); + } + + private function advisory_lock_query( string $sql ): ?WP_Markdown_Query_Result { + if ( 1 !== preg_match( "/^\\s*SELECT\\s+(GET_LOCK|RELEASE_LOCK)\\s*\\(\\s*'((?:\\\\.|[^'])*)'\\s*(?:,\\s*([0-9]+(?:\\.[0-9]+)?))?\\s*\\)\\s*;?\\s*$/i", $sql, $match ) ) { + return null; + } + $function = strtoupper( $match[1] ); + if ( ( 'GET_LOCK' === $function && ! isset( $match[3] ) ) || null === $this->advisory_locks ) { + return $this->failure( 'unsupported_grammar', 'mdi-native advisory locks require a literal name and bounded timeout.' ); + } + $name = stripcslashes( $match[2] ); + $value = 'GET_LOCK' === $function + ? (int) $this->advisory_locks->acquire( $name, (float) $match[3] ) + : $this->advisory_locks->release( $name ); + $column = $function . '(' . $match[2] . ( 'GET_LOCK' === $function ? ', ' . $match[3] : '' ) . ')'; + return WP_Markdown_Query_Result::selected( + array( array( $column => null === $value ? null : (string) $value ) ), + array( array( 'name' => $column, 'table' => '', 'type' => 8 ) ) + ); + } + private function execute_plan( WP_Markdown_Native_Query_Plan $plan, bool $allow_union = true ): WP_Markdown_Query_Result { if ( $allow_union && null !== $plan->union() ) { return $this->execute_union( $plan ); diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index f64c09c..34d6abc 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -23,6 +23,7 @@ require_once __DIR__ . '/../class-wp-markdown-sql-classifier.php'; require_once __DIR__ . '/../class-wp-markdown-table-durability-policy.php'; require_once __DIR__ . '/class-wp-markdown-native-transactions.php'; +require_once __DIR__ . '/class-wp-markdown-native-advisory-locks.php'; require_once __DIR__ . '/class-wp-markdown-native-query-executor.php'; final class WP_Markdown_Native_Runtime_Factory { @@ -229,7 +230,8 @@ public static function runtime( bool $multisite = false, ?string $content_root = null, ?string $global_state_root = null, - ?string $global_content_root = null + ?string $global_content_root = null, + ?WP_Markdown_Native_Advisory_Locks $advisory_locks = null ): WP_Markdown_Native_Query_Runtime { $state_root = self::materialize_state_root( $state_root ); if ( null !== $global_state_root ) { @@ -265,7 +267,8 @@ public static function runtime( $parser, self::shared_storage( $content_root ?? $state_root ), $transactions - ) + ), + advisory_locks: $advisory_locks ?? new WP_Markdown_Native_Advisory_Locks( $state_root ) ); } @@ -598,11 +601,14 @@ final class WP_Markdown_Native_Prefix_Query_Runtime implements WP_Markdown_Query /** @var array */ private array $runtimes = array(); + private WP_Markdown_Native_Advisory_Locks $advisory_locks; public function __construct( private string $state_root, private string $content_root - ) {} + ) { + $this->advisory_locks = new WP_Markdown_Native_Advisory_Locks( $state_root ); + } public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { $prefix = $request->table_prefix(); @@ -612,11 +618,16 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query $prefix, $prefix, false, - $this->content_root + $this->content_root, + advisory_locks: $this->advisory_locks ); } return $this->runtimes[ $prefix ]->execute( $request ); } + + public function close(): void { + $this->advisory_locks->close(); + } } /** Defer WordPress topology detection because db.php precedes multisite bootstrap. */ @@ -648,6 +659,13 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query } return $this->multisite_runtimes[ $base_prefix ]->execute( $request ); } + + public function close(): void { + $this->prefix_runtime->close(); + foreach ( $this->multisite_runtimes as $runtime ) { + $runtime->close(); + } + } } /** Lazily compose a native runtime for each WordPress multisite table scope. */ @@ -715,6 +733,12 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query return $this->runtimes[ $prefix ]->execute( $request ); } + public function close(): void { + foreach ( $this->runtimes as $runtime ) { + $runtime->close(); + } + } + private function is_scope_prefix( string $prefix ): bool { if ( $this->base_prefix === $prefix || ! str_starts_with( $prefix, $this->base_prefix ) ) { return $this->base_prefix === $prefix; diff --git a/inc/native/class-wp-markdown-native-transactions.php b/inc/native/class-wp-markdown-native-transactions.php index 7112762..5a2585f 100644 --- a/inc/native/class-wp-markdown-native-transactions.php +++ b/inc/native/class-wp-markdown-native-transactions.php @@ -220,10 +220,7 @@ public function rollback(): true|string { public function savepoint( string $name ): true|string { if ( ! $this->active ) { - $begun = $this->begin(); - if ( true !== $begun ) { - return $begun; - } + return sprintf( 'SAVEPOINT %s does not exist.', $name ); } $this->savepoints[ $name ] = count( $this->entries ); return true; diff --git a/inc/native/class-wp-markdown-native-wpdb.php b/inc/native/class-wp-markdown-native-wpdb.php index 706118c..f9f4f93 100644 --- a/inc/native/class-wp-markdown-native-wpdb.php +++ b/inc/native/class-wp-markdown-native-wpdb.php @@ -87,6 +87,9 @@ public function close() { return false; } + if ( method_exists( $this->native_runtime, 'close' ) ) { + $this->native_runtime->close(); + } $this->ready = false; return true; } diff --git a/tests/probe-native-transaction-semantics.php b/tests/probe-native-transaction-semantics.php index 9805c90..65105d1 100644 --- a/tests/probe-native-transaction-semantics.php +++ b/tests/probe-native-transaction-semantics.php @@ -99,6 +99,9 @@ function probe_session_variable( WP_Markdown_Native_Query_Runtime $runtime, stri ); } +$inactive_savepoint = probe_statement( $runtime, 'SAVEPOINT outside_transaction' ); +$inactive_state = probe_session_variable( $runtime, 'SELECT @@session.in_transaction' ); + $session_before = array( 'in_transaction' => probe_session_variable( $runtime, 'SELECT @@session.in_transaction' ), 'autocommit' => probe_session_variable( $runtime, 'SELECT @@autocommit' ), @@ -236,6 +239,7 @@ function probe_canonical_value( string $root, string $option ): ?string { ), ), 'assertions' => array( + 'savepoint outside a transaction fails without starting one' => false === $inactive_savepoint['return_value'] && '0' === $inactive_state['value'], 'transaction control statements execute' => $control_executes, 'session state reports MySQL-shaped strings and headers' => '0' === $session_before['in_transaction']['value'] && '@@session.in_transaction' === $session_before['in_transaction']['column'] diff --git a/tests/smoke-native-advisory-locks.php b/tests/smoke-native-advisory-locks.php new file mode 100644 index 0000000..e1bfdb6 --- /dev/null +++ b/tests/smoke-native-advisory-locks.php @@ -0,0 +1,46 @@ +execute( new WP_Markdown_Query_Request( $sql ) ); + $rows = $result->wpdb_state()['last_result']; + return isset( $rows[0] ) ? current( get_object_vars( $rows[0] ) ) : null; +} + +$first_acquire = advisory_lock_value( $first, "SELECT GET_LOCK('native-lock', 0)" ); +$first_reentrant = advisory_lock_value( $first, "SELECT GET_LOCK('native-lock', 0)" ); +$second_contended = advisory_lock_value( $second, "SELECT GET_LOCK('native-lock', 0)" ); +$first_release_once = advisory_lock_value( $first, "SELECT RELEASE_LOCK('native-lock')" ); +$second_still_contended = advisory_lock_value( $second, "SELECT GET_LOCK('native-lock', 0)" ); +$first_release_final = advisory_lock_value( $first, "SELECT RELEASE_LOCK('native-lock')" ); +$second_acquire = advisory_lock_value( $second, "SELECT GET_LOCK('native-lock', 0)" ); +$second->close(); +$first_after_close = advisory_lock_value( $first, "SELECT GET_LOCK('native-lock', 0)" ); +$unknown_release = advisory_lock_value( $first, "SELECT RELEASE_LOCK('missing-native-lock')" ); + +$assertions = array( + 'acquisition returns a selected MySQL scalar' => '1' === $first_acquire, + 'the owning logical connection is reentrant' => '1' === $first_reentrant, + 'an independent runtime is excluded without waiting' => '0' === $second_contended, + 'one reentrant release retains ownership' => '1' === $first_release_once && '0' === $second_still_contended, + 'the final release transfers ownership' => '1' === $first_release_final && '1' === $second_acquire, + 'close releases all locks owned by its runtime' => '1' === $first_after_close, + 'unknown release has MySQL null shape' => null === $unknown_release, +); +$passed = ! in_array( false, $assertions, true ); +fwrite( $passed ? STDOUT : STDERR, json_encode( array( 'assertions' => $assertions, 'passed' => $passed ), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR ) . "\n" ); +exit( $passed ? 0 : 1 ); From f5fb108a31dccee350b5afa35930ba9bc5e23977 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 05:38:20 -0400 Subject: [PATCH 2/8] fix: correct native lock ownership semantics --- ...lass-wp-markdown-native-advisory-locks.php | 16 +++++-- ...lass-wp-markdown-native-query-executor.php | 20 +++++--- ...class-wp-markdown-native-query-runtime.php | 9 ++-- .../class-wp-markdown-native-transactions.php | 3 +- tests/native-advisory-lock-worker.php | 30 ++++++++++++ tests/probe-native-transaction-semantics.php | 13 ++++- tests/smoke-native-advisory-locks.php | 48 ++++++++++++++++++- 7 files changed, 121 insertions(+), 18 deletions(-) create mode 100644 tests/native-advisory-lock-worker.php diff --git a/inc/native/class-wp-markdown-native-advisory-locks.php b/inc/native/class-wp-markdown-native-advisory-locks.php index edc25a0..b47fe9a 100644 --- a/inc/native/class-wp-markdown-native-advisory-locks.php +++ b/inc/native/class-wp-markdown-native-advisory-locks.php @@ -8,7 +8,7 @@ final class WP_Markdown_Native_Advisory_Locks { private const DIRECTORY = '_locks'; - private const MAX_WAIT_MICROSECONDS = 5000000; + public const MAX_WAIT_SECONDS = 5.0; /** @var array */ private array $locks = array(); @@ -30,7 +30,7 @@ public function acquire( string $name, float $timeout ): bool { if ( false === $handle ) { return false; } - $wait = min( max( 0, $timeout * 1000000 ), self::MAX_WAIT_MICROSECONDS ); + $wait = $timeout * 1000000; $deadline = hrtime( true ) + (int) ( $wait * 1000 ); do { if ( flock( $handle, LOCK_EX | LOCK_NB ) ) { @@ -49,7 +49,16 @@ public function acquire( string $name, float $timeout ): bool { /** @return int|null One when released, zero when held by another connection, null when absent. */ public function release( string $name ): ?int { if ( ! isset( $this->locks[ $name ] ) ) { - return is_file( $this->path( $name ) ) ? 0 : null; + $handle = @fopen( $this->path( $name ), 'c+b' ); + if ( false === $handle ) { + return null; + } + $available = flock( $handle, LOCK_EX | LOCK_NB ); + if ( $available ) { + flock( $handle, LOCK_UN ); + } + fclose( $handle ); + return $available ? null : 0; } --$this->locks[ $name ]['count']; if ( 0 < $this->locks[ $name ]['count'] ) { @@ -59,7 +68,6 @@ public function release( string $name ): ?int { unset( $this->locks[ $name ] ); flock( $lock['handle'], LOCK_UN ); fclose( $lock['handle'] ); - @unlink( $lock['path'] ); return 1; } diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 3c8ce15..64aff16 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -127,18 +127,26 @@ public function close(): void { } private function advisory_lock_query( string $sql ): ?WP_Markdown_Query_Result { - if ( 1 !== preg_match( "/^\\s*SELECT\\s+(GET_LOCK|RELEASE_LOCK)\\s*\\(\\s*'((?:\\\\.|[^'])*)'\\s*(?:,\\s*([0-9]+(?:\\.[0-9]+)?))?\\s*\\)\\s*;?\\s*$/i", $sql, $match ) ) { + if ( 1 !== preg_match( "/^\\s*SELECT\\s+((GET_LOCK|RELEASE_LOCK)\\s*\\(\\s*('(?:\\\\.|[^'])*')\\s*(?:,\\s*([0-9]+(?:\\.[0-9]+)?))?\\s*\\))\\s*;?\\s*$/i", $sql, $match ) ) { return null; } - $function = strtoupper( $match[1] ); - if ( ( 'GET_LOCK' === $function && ! isset( $match[3] ) ) || null === $this->advisory_locks ) { + $function = strtoupper( $match[2] ); + if ( ( 'GET_LOCK' === $function && ! isset( $match[4] ) ) || ( 'RELEASE_LOCK' === $function && isset( $match[4] ) ) || null === $this->advisory_locks ) { return $this->failure( 'unsupported_grammar', 'mdi-native advisory locks require a literal name and bounded timeout.' ); } - $name = stripcslashes( $match[2] ); + try { + $literal = ( new WP_Markdown_Native_SQL_Tokenizer() )->tokenize( $match[3] )[0]; + $name = $literal->value(); + } catch ( WP_Markdown_Native_SQL_Parse_Error ) { + return $this->failure( 'unsupported_literal', 'mdi-native cannot decode the requested advisory lock name.' ); + } + if ( ! is_string( $name ) || ( isset( $match[4] ) && (float) $match[4] > WP_Markdown_Native_Advisory_Locks::MAX_WAIT_SECONDS ) ) { + return $this->failure( 'unsupported_grammar', 'mdi-native advisory lock timeouts must be between 0 and 5 seconds.' ); + } $value = 'GET_LOCK' === $function - ? (int) $this->advisory_locks->acquire( $name, (float) $match[3] ) + ? (int) $this->advisory_locks->acquire( $name, (float) $match[4] ) : $this->advisory_locks->release( $name ); - $column = $function . '(' . $match[2] . ( 'GET_LOCK' === $function ? ', ' . $match[3] : '' ) . ')'; + $column = $match[1]; return WP_Markdown_Query_Result::selected( array( array( $column => null === $value ? null : (string) $value ) ), array( array( 'name' => $column, 'table' => '', 'type' => 8 ) ) diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index 34d6abc..8e6fa37 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -675,6 +675,7 @@ final class WP_Markdown_Native_Multisite_Query_Runtime implements WP_Markdown_Qu private array $runtimes = array(); private string $state_root; private string $content_root; + private WP_Markdown_Native_Advisory_Locks $advisory_locks; public function __construct( string $state_root, @@ -686,6 +687,7 @@ public function __construct( } $this->state_root = rtrim( $state_root, '/\\' ); $this->content_root = rtrim( $content_root, '/\\' ); + $this->advisory_locks = new WP_Markdown_Native_Advisory_Locks( $this->state_root ); } public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query_Result { @@ -718,7 +720,8 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query true, $roots['content'], $this->state_root, - $this->content_root + $this->content_root, + $this->advisory_locks ); } catch ( Throwable ) { return WP_Markdown_Query_Result::failure( @@ -734,9 +737,7 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query } public function close(): void { - foreach ( $this->runtimes as $runtime ) { - $runtime->close(); - } + $this->advisory_locks->close(); } private function is_scope_prefix( string $prefix ): bool { diff --git a/inc/native/class-wp-markdown-native-transactions.php b/inc/native/class-wp-markdown-native-transactions.php index 5a2585f..c8fc543 100644 --- a/inc/native/class-wp-markdown-native-transactions.php +++ b/inc/native/class-wp-markdown-native-transactions.php @@ -220,7 +220,8 @@ public function rollback(): true|string { public function savepoint( string $name ): true|string { if ( ! $this->active ) { - return sprintf( 'SAVEPOINT %s does not exist.', $name ); + // With autocommit on, MySQL accepts SAVEPOINT without opening a transaction. + return $this->autocommit ? true : sprintf( 'SAVEPOINT %s does not exist.', $name ); } $this->savepoints[ $name ] = count( $this->entries ); return true; diff --git a/tests/native-advisory-lock-worker.php b/tests/native-advisory-lock-worker.php new file mode 100644 index 0000000..0592f3a --- /dev/null +++ b/tests/native-advisory-lock-worker.php @@ -0,0 +1,30 @@ + \n" ); + exit( 2 ); +} + +$path = $argv[1] . '/_locks/' . hash( 'sha256', $argv[2] ) . '.lock'; +$handle = fopen( $path, 'c+b' ); +if ( false === $handle ) { + exit( 2 ); +} +fwrite( STDOUT, "descriptor-open\n" ); +fflush( STDOUT ); +for ( $attempt = 0; $attempt < 500; ++$attempt ) { + if ( flock( $handle, LOCK_EX | LOCK_NB ) ) { + fwrite( STDOUT, "acquired\n" ); + fflush( STDOUT ); + stream_get_contents( STDIN ); + flock( $handle, LOCK_UN ); + fclose( $handle ); + exit( 0 ); + } + usleep( 10000 ); +} +fclose( $handle ); +exit( 1 ); diff --git a/tests/probe-native-transaction-semantics.php b/tests/probe-native-transaction-semantics.php index 65105d1..79fae91 100644 --- a/tests/probe-native-transaction-semantics.php +++ b/tests/probe-native-transaction-semantics.php @@ -101,6 +101,13 @@ function probe_session_variable( WP_Markdown_Native_Query_Runtime $runtime, stri $inactive_savepoint = probe_statement( $runtime, 'SAVEPOINT outside_transaction' ); $inactive_state = probe_session_variable( $runtime, 'SELECT @@session.in_transaction' ); +$inactive_release = probe_statement( $runtime, 'RELEASE SAVEPOINT outside_transaction' ); + +probe_statement( $runtime, 'SET autocommit = 0' ); +$autocommit_off_savepoint = probe_statement( $runtime, 'SAVEPOINT autocommit_off' ); +$autocommit_off_savepoint_state = probe_session_variable( $runtime, 'SELECT @@session.in_transaction' ); +probe_statement( $runtime, 'ROLLBACK' ); +probe_statement( $runtime, 'SET autocommit = 1' ); $session_before = array( 'in_transaction' => probe_session_variable( $runtime, 'SELECT @@session.in_transaction' ), @@ -239,7 +246,11 @@ function probe_canonical_value( string $root, string $option ): ?string { ), ), 'assertions' => array( - 'savepoint outside a transaction fails without starting one' => false === $inactive_savepoint['return_value'] && '0' === $inactive_state['value'], + 'savepoint outside an autocommit session is a no-op' => 0 === $inactive_savepoint['return_value'] + && '0' === $inactive_state['value'] + && false === $inactive_release['return_value'], + 'autocommit-off savepoint does not create an untracked transaction' => false === $autocommit_off_savepoint['return_value'] + && '0' === $autocommit_off_savepoint_state['value'], 'transaction control statements execute' => $control_executes, 'session state reports MySQL-shaped strings and headers' => '0' === $session_before['in_transaction']['value'] && '@@session.in_transaction' === $session_before['in_transaction']['column'] diff --git a/tests/smoke-native-advisory-locks.php b/tests/smoke-native-advisory-locks.php index e1bfdb6..1cf6457 100644 --- a/tests/smoke-native-advisory-locks.php +++ b/tests/smoke-native-advisory-locks.php @@ -15,8 +15,8 @@ $second = WP_Markdown_Native_Runtime_Factory::runtime( $root ); /** @return int|string|null */ -function advisory_lock_value( WP_Markdown_Native_Query_Runtime $runtime, string $sql ): int|string|null { - $result = $runtime->execute( new WP_Markdown_Query_Request( $sql ) ); +function advisory_lock_value( WP_Markdown_Query_Runtime $runtime, string $sql, string $prefix = 'wp_' ): int|string|null { + $result = $runtime->execute( new WP_Markdown_Query_Request( $sql, $prefix ) ); $rows = $result->wpdb_state()['last_result']; return isset( $rows[0] ) ? current( get_object_vars( $rows[0] ) ) : null; } @@ -31,6 +31,45 @@ function advisory_lock_value( WP_Markdown_Native_Query_Runtime $runtime, string $second->close(); $first_after_close = advisory_lock_value( $first, "SELECT GET_LOCK('native-lock', 0)" ); $unknown_release = advisory_lock_value( $first, "SELECT RELEASE_LOCK('missing-native-lock')" ); +$persistent_owner = advisory_lock_value( $first, "SELECT GET_LOCK('persistent-unowned-lock', 0)" ); +$failed_acquire = advisory_lock_value( $second, "SELECT GET_LOCK('persistent-unowned-lock', 0)" ); +$persistent_owner_release = advisory_lock_value( $first, "SELECT RELEASE_LOCK('persistent-unowned-lock')" ); +$unowned_persistent_release = advisory_lock_value( $first, "SELECT RELEASE_LOCK('persistent-unowned-lock')" ); +$escaped_name = advisory_lock_value( $first, "SELECT GET_LOCK('escaped\\nlock', 0)" ); +$escaped_column = $first->execute( new WP_Markdown_Query_Request( "SELECT GET_LOCK('escaped\\nlock', 0)" ) )->wpdb_state()['col_info'][0]->name ?? null; +$escaped_release = advisory_lock_value( $first, "SELECT RELEASE_LOCK('escaped\\nlock')" ); +$unsupported_release_arity = $first->execute( new WP_Markdown_Query_Request( "SELECT RELEASE_LOCK('native-lock', 0)" ) ); +$unsupported_timeout = $first->execute( new WP_Markdown_Query_Request( "SELECT GET_LOCK('native-lock', 5.1)" ) ); + +// The worker has an open descriptor before this owner releases the stable inode. +$race_owner = advisory_lock_value( $first, "SELECT GET_LOCK('inode-race-lock', 0)" ); +$worker = proc_open( + escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( __DIR__ . '/native-advisory-lock-worker.php' ) . ' ' . escapeshellarg( $root ) . ' inode-race-lock', + array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), + $pipes +); +$worker_descriptor_open = is_resource( $worker ) && 'descriptor-open' === rtrim( (string) fgets( $pipes[1] ) ); +$race_owner_release = advisory_lock_value( $first, "SELECT RELEASE_LOCK('inode-race-lock')" ); +$worker_acquired = $worker_descriptor_open && 'acquired' === rtrim( (string) fgets( $pipes[1] ) ); +$third = WP_Markdown_Native_Runtime_Factory::runtime( $root ); +$third_contended = advisory_lock_value( $third, "SELECT GET_LOCK('inode-race-lock', 0)" ); +if ( is_resource( $worker ) ) { + fclose( $pipes[0] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + $worker_status = proc_close( $worker ); +} else { + $worker_status = 1; +} + +$multisite = new WP_Markdown_Native_Multisite_Query_Runtime( $root, 'wp_', $root ); +$multisite_base = advisory_lock_value( $multisite, "SELECT GET_LOCK('multisite-lock', 0)" ); +$multisite_site = advisory_lock_value( $multisite, "SELECT GET_LOCK('multisite-lock', 0)", 'wp_2_' ); +$external = WP_Markdown_Native_Runtime_Factory::runtime( $root ); +$multisite_release_once = advisory_lock_value( $multisite, "SELECT RELEASE_LOCK('multisite-lock')", 'wp_2_' ); +$external_contended = advisory_lock_value( $external, "SELECT GET_LOCK('multisite-lock', 0)" ); +$multisite_release_final = advisory_lock_value( $multisite, "SELECT RELEASE_LOCK('multisite-lock')" ); +$external_acquire = advisory_lock_value( $external, "SELECT GET_LOCK('multisite-lock', 0)" ); $assertions = array( 'acquisition returns a selected MySQL scalar' => '1' === $first_acquire, @@ -40,6 +79,11 @@ function advisory_lock_value( WP_Markdown_Native_Query_Runtime $runtime, string 'the final release transfers ownership' => '1' === $first_release_final && '1' === $second_acquire, 'close releases all locks owned by its runtime' => '1' === $first_after_close, 'unknown release has MySQL null shape' => null === $unknown_release, + 'a stale lock file is not treated as a live owner' => '1' === $persistent_owner && '0' === $failed_acquire && '1' === $persistent_owner_release && null === $unowned_persistent_release, + 'lock literals and result metadata retain SQL semantics' => '1' === $escaped_name && "GET_LOCK('escaped\\nlock', 0)" === $escaped_column && '1' === $escaped_release, + 'release arity and timeout bounds fail explicitly' => false === $unsupported_release_arity->return_value() && false === $unsupported_timeout->return_value(), + 'a pre-opened waiter cannot split ownership onto an unlinked inode' => '1' === $race_owner && $worker_descriptor_open && '1' === $race_owner_release && $worker_acquired && '0' === $third_contended && 0 === $worker_status, + 'multisite prefixes share one logical connection lock owner' => '1' === $multisite_base && '1' === $multisite_site && '1' === $multisite_release_once && '0' === $external_contended && '1' === $multisite_release_final && '1' === $external_acquire, ); $passed = ! in_array( false, $assertions, true ); fwrite( $passed ? STDOUT : STDERR, json_encode( array( 'assertions' => $assertions, 'passed' => $passed ), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR ) . "\n" ); From 489427aa48bebcba9db16435cd5658f22e7c1809 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 05:40:04 -0400 Subject: [PATCH 3/8] test: verify lifecycle candidate mount --- tests/run-native-wordpress-lifecycle.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/run-native-wordpress-lifecycle.php b/tests/run-native-wordpress-lifecycle.php index e3a5b13..75fc97a 100644 --- a/tests/run-native-wordpress-lifecycle.php +++ b/tests/run-native-wordpress-lifecycle.php @@ -148,6 +148,14 @@ function mdi_native_lifecycle_run( string $wp_codebox, string $recipe_path ): ar $recipe_path = $root . '/recipe.json'; file_put_contents( $recipe_path, json_encode( $recipe, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ) . "\n" ); +$mounted_mdi = $recipe['inputs']['mounts'][1]['source'] ?? null; +$mounted_sha = is_string( $mounted_mdi ) ? trim( (string) shell_exec( 'git -C ' . escapeshellarg( $mounted_mdi ) . ' rev-parse HEAD 2>/dev/null' ) ) : ''; +$candidate_sha = trim( (string) getenv( 'MDI_CANDIDATE_SHA' ) ); +if ( '' === $mounted_sha || ( '' !== $candidate_sha && $candidate_sha !== $mounted_sha ) ) { + fwrite( STDERR, "The generated recipe does not mount the requested MDI candidate source.\n" ); + exit( 2 ); +} + $first = mdi_native_lifecycle_run( $wp_codebox, $recipe_path ); $second = null; if ( 0 === $first['status'] ) { @@ -159,6 +167,7 @@ function mdi_native_lifecycle_run( string $wp_codebox, string $recipe_path ): ar $summary = array( 'schema' => 'mdi-native-wordpress-lifecycle-run/v1', 'passed' => 0 === $status, + 'mounted_mdi' => array( 'source' => $mounted_mdi, 'sha' => $mounted_sha ), 'boots' => array( array( 'phase' => 'activation', From 980c6699dce4a9ffad11b5b84847ab7d843cde98 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 05:46:00 -0400 Subject: [PATCH 4/8] fix: preserve inactive autocommit savepoints --- .../class-wp-markdown-native-transactions.php | 17 ++++++++++++++--- tests/probe-native-transaction-semantics.php | 6 ++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/inc/native/class-wp-markdown-native-transactions.php b/inc/native/class-wp-markdown-native-transactions.php index c8fc543..de0b83f 100644 --- a/inc/native/class-wp-markdown-native-transactions.php +++ b/inc/native/class-wp-markdown-native-transactions.php @@ -221,16 +221,23 @@ public function rollback(): true|string { public function savepoint( string $name ): true|string { if ( ! $this->active ) { // With autocommit on, MySQL accepts SAVEPOINT without opening a transaction. - return $this->autocommit ? true : sprintf( 'SAVEPOINT %s does not exist.', $name ); + if ( $this->autocommit ) { + return true; + } + $this->savepoints[ $name ] = 0; + return true; } $this->savepoints[ $name ] = count( $this->entries ); return true; } public function rollback_to( string $name ): true|string { - if ( ! $this->active || ! isset( $this->savepoints[ $name ] ) ) { + if ( ! isset( $this->savepoints[ $name ] ) || ( ! $this->active && $this->autocommit ) ) { return sprintf( 'SAVEPOINT %s does not exist.', $name ); } + if ( ! $this->active ) { + return true; + } $marker = $this->savepoints[ $name ]; $restored = $this->restore( $this->entries, $marker ); if ( true !== $restored ) { @@ -246,9 +253,13 @@ public function rollback_to( string $name ): true|string { } public function release_savepoint( string $name ): true|string { - if ( ! $this->active || ! isset( $this->savepoints[ $name ] ) ) { + if ( ! isset( $this->savepoints[ $name ] ) || ( ! $this->active && $this->autocommit ) ) { return sprintf( 'SAVEPOINT %s does not exist.', $name ); } + if ( ! $this->active ) { + unset( $this->savepoints[ $name ] ); + return true; + } $marker = $this->savepoints[ $name ]; foreach ( $this->savepoints as $savepoint => $offset ) { if ( $offset >= $marker ) { diff --git a/tests/probe-native-transaction-semantics.php b/tests/probe-native-transaction-semantics.php index 79fae91..2cc935d 100644 --- a/tests/probe-native-transaction-semantics.php +++ b/tests/probe-native-transaction-semantics.php @@ -106,6 +106,7 @@ function probe_session_variable( WP_Markdown_Native_Query_Runtime $runtime, stri probe_statement( $runtime, 'SET autocommit = 0' ); $autocommit_off_savepoint = probe_statement( $runtime, 'SAVEPOINT autocommit_off' ); $autocommit_off_savepoint_state = probe_session_variable( $runtime, 'SELECT @@session.in_transaction' ); +$autocommit_off_release = probe_statement( $runtime, 'RELEASE SAVEPOINT autocommit_off' ); probe_statement( $runtime, 'ROLLBACK' ); probe_statement( $runtime, 'SET autocommit = 1' ); @@ -249,8 +250,9 @@ function probe_canonical_value( string $root, string $option ): ?string { 'savepoint outside an autocommit session is a no-op' => 0 === $inactive_savepoint['return_value'] && '0' === $inactive_state['value'] && false === $inactive_release['return_value'], - 'autocommit-off savepoint does not create an untracked transaction' => false === $autocommit_off_savepoint['return_value'] - && '0' === $autocommit_off_savepoint_state['value'], + 'autocommit-off savepoint remains outside a transaction' => 0 === $autocommit_off_savepoint['return_value'] + && '0' === $autocommit_off_savepoint_state['value'] + && 0 === $autocommit_off_release['return_value'], 'transaction control statements execute' => $control_executes, 'session state reports MySQL-shaped strings and headers' => '0' === $session_before['in_transaction']['value'] && '@@session.in_transaction' === $session_before['in_transaction']['column'] From 75ec2a7c5d9594307ffed6a74bd996955dc4ee19 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 07:31:43 -0400 Subject: [PATCH 5/8] fix: honor consumer advisory lock waits --- inc/native/class-wp-markdown-native-advisory-locks.php | 3 ++- tests/smoke-native-advisory-locks.php | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/inc/native/class-wp-markdown-native-advisory-locks.php b/inc/native/class-wp-markdown-native-advisory-locks.php index b47fe9a..40357c1 100644 --- a/inc/native/class-wp-markdown-native-advisory-locks.php +++ b/inc/native/class-wp-markdown-native-advisory-locks.php @@ -8,7 +8,8 @@ final class WP_Markdown_Native_Advisory_Locks { private const DIRECTORY = '_locks'; - public const MAX_WAIT_SECONDS = 5.0; + /** The largest ordinary consumer lock wait accepted by the native runtime. */ + public const MAX_WAIT_SECONDS = 10.0; /** @var array */ private array $locks = array(); diff --git a/tests/smoke-native-advisory-locks.php b/tests/smoke-native-advisory-locks.php index 1cf6457..5ab8238 100644 --- a/tests/smoke-native-advisory-locks.php +++ b/tests/smoke-native-advisory-locks.php @@ -39,7 +39,9 @@ function advisory_lock_value( WP_Markdown_Query_Runtime $runtime, string $sql, s $escaped_column = $first->execute( new WP_Markdown_Query_Request( "SELECT GET_LOCK('escaped\\nlock', 0)" ) )->wpdb_state()['col_info'][0]->name ?? null; $escaped_release = advisory_lock_value( $first, "SELECT RELEASE_LOCK('escaped\\nlock')" ); $unsupported_release_arity = $first->execute( new WP_Markdown_Query_Request( "SELECT RELEASE_LOCK('native-lock', 0)" ) ); -$unsupported_timeout = $first->execute( new WP_Markdown_Query_Request( "SELECT GET_LOCK('native-lock', 5.1)" ) ); +$maximum_timeout = advisory_lock_value( $first, "SELECT GET_LOCK('maximum-timeout-lock', 10)" ); +$maximum_timeout_release = advisory_lock_value( $first, "SELECT RELEASE_LOCK('maximum-timeout-lock')" ); +$unsupported_timeout = $first->execute( new WP_Markdown_Query_Request( "SELECT GET_LOCK('native-lock', 10.1)" ) ); // The worker has an open descriptor before this owner releases the stable inode. $race_owner = advisory_lock_value( $first, "SELECT GET_LOCK('inode-race-lock', 0)" ); @@ -81,6 +83,7 @@ function advisory_lock_value( WP_Markdown_Query_Runtime $runtime, string $sql, s 'unknown release has MySQL null shape' => null === $unknown_release, 'a stale lock file is not treated as a live owner' => '1' === $persistent_owner && '0' === $failed_acquire && '1' === $persistent_owner_release && null === $unowned_persistent_release, 'lock literals and result metadata retain SQL semantics' => '1' === $escaped_name && "GET_LOCK('escaped\\nlock', 0)" === $escaped_column && '1' === $escaped_release, + 'the documented ten-second consumer wait retains normal result semantics' => '1' === $maximum_timeout && '1' === $maximum_timeout_release, 'release arity and timeout bounds fail explicitly' => false === $unsupported_release_arity->return_value() && false === $unsupported_timeout->return_value(), 'a pre-opened waiter cannot split ownership onto an unlinked inode' => '1' === $race_owner && $worker_descriptor_open && '1' === $race_owner_release && $worker_acquired && '0' === $third_contended && 0 === $worker_status, 'multisite prefixes share one logical connection lock owner' => '1' === $multisite_base && '1' === $multisite_site && '1' === $multisite_release_once && '0' === $external_contended && '1' === $multisite_release_final && '1' === $external_acquire, From 2e1987b2f9661ef6bce6bb56c933759c9be8e474 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 08:00:14 -0400 Subject: [PATCH 6/8] fix: support conditional option updates --- ...ss-wp-markdown-native-option-mutations.php | 21 +++++++++++++++++-- tests/smoke-native-option-query.php | 9 ++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/inc/native/class-wp-markdown-native-option-mutations.php b/inc/native/class-wp-markdown-native-option-mutations.php index 560e03a..b1f65c2 100644 --- a/inc/native/class-wp-markdown-native-option-mutations.php +++ b/inc/native/class-wp-markdown-native-option-mutations.php @@ -10,7 +10,8 @@ final class WP_Markdown_Native_Option_Mutation { public function __construct( private readonly string $operation, private readonly string $option_name, - private readonly array $values + private readonly array $values, + private readonly ?string $expected_option_value = null ) { if ( ! in_array( $operation, array( 'insert', 'upsert', 'update', 'delete' ), true ) ) { throw new InvalidArgumentException( 'Unsupported option mutation operation.' ); @@ -37,6 +38,10 @@ public function option_name(): string { public function values(): array { return $this->values; } + + public function expected_option_value(): ?string { + return $this->expected_option_value; + } } final class WP_Markdown_Native_Option_Mutation_Parser { @@ -168,8 +173,17 @@ private function parse_update( WP_Markdown_Query_Request $request ): WP_Markdown } $this->type( WP_Markdown_Native_SQL_Token::EQUALS ); $option_name = (string) $this->type( WP_Markdown_Native_SQL_Token::STRING )->value(); + $expected_option_value = null; + if ( 0 === strcasecmp( 'AND', (string) $this->current()->value() ) ) { + ++$this->position; + if ( 'option_value' !== $this->identifier() ) { + return $this->failure( 'unsupported_option_update', 'mdi-native option updates may condition only on the current option value.' ); + } + $this->type( WP_Markdown_Native_SQL_Token::EQUALS ); + $expected_option_value = (string) $this->type( WP_Markdown_Native_SQL_Token::STRING )->value(); + } $this->type( WP_Markdown_Native_SQL_Token::END ); - return new WP_Markdown_Native_Option_Mutation( 'update', $option_name, $changes ); + return new WP_Markdown_Native_Option_Mutation( 'update', $option_name, $changes, $expected_option_value ); } private function parse_delete( WP_Markdown_Query_Request $request ): WP_Markdown_Native_Option_Mutation|WP_Markdown_Query_Result { @@ -388,6 +402,9 @@ private function mutate( WP_Markdown_Native_Option_Mutation $mutation ): WP_Mark if ( $mutation->is_insert() && null !== $existing ) { return $this->failure( 'duplicate_key', 'The canonical option identity already exists.' ); } + if ( null !== $mutation->expected_option_value() && $mutation->expected_option_value() !== $existing['row']['option_value'] ) { + return WP_Markdown_Query_Result::mutated( 0 ); + } if ( $mutation->is_delete() ) { $journaled = $this->journal( $existing['path'] ); if ( true !== $journaled ) { diff --git a/tests/smoke-native-option-query.php b/tests/smoke-native-option-query.php index 335bbdc..3df53fa 100644 --- a/tests/smoke-native-option-query.php +++ b/tests/smoke-native-option-query.php @@ -175,6 +175,8 @@ public function get_col_info( string $type ): array { return array_map( static f $read_updated_cron = $runtime->execute( new WP_Markdown_Query_Request( "SELECT option_id, option_value, autoload FROM wp_options WHERE option_name = 'cron' LIMIT 1" ) ); $direct_update_cron = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE `wp_options` SET `option_value` = 'third', `autoload` = 'auto-off' WHERE `option_name` = 'cron'" ) ); $noop_direct_update_cron = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_options SET option_value = 'third', autoload = 'auto-off' WHERE option_name = 'cron'" ) ); +$conditional_update_cron = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_options SET option_value = 'fourth' WHERE option_name = 'cron' AND option_value = 'third'" ) ); +$stale_conditional_update_cron = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_options SET option_value = 'stale' WHERE option_name = 'cron' AND option_value = 'third'" ) ); $read_direct_updated_cron = $runtime->execute( new WP_Markdown_Query_Request( "SELECT option_id, option_value, autoload FROM wp_options WHERE option_name = 'cron' LIMIT 1" ) ); $reopened_runtime = WP_Markdown_Native_Runtime_Factory::runtime( $root, 'wp_' ); $read_persisted_cron = $reopened_runtime->execute( new WP_Markdown_Query_Request( "SELECT option_id, option_value, autoload FROM wp_options WHERE option_name = 'cron' LIMIT 1" ) ); @@ -266,11 +268,14 @@ public function get_col_info( string $type ): array { return array_map( static f && array() === $cron_temp_files, 'canonical option updates mutate exact existing identities only' => 1 === $direct_update_cron->return_value() && 0 === $noop_direct_update_cron->return_value() + && 1 === $conditional_update_cron->return_value() + && 0 === $stale_conditional_update_cron->return_value() + && 'fourth' === ( $read_direct_updated_cron->wpdb_state()['last_result'][0]->option_value ?? null ) && 0 === $missing_direct_update->return_value() && '7' === ( $read_direct_updated_cron->wpdb_state()['last_result'][0]->option_id ?? null ) - && 'third' === ( $read_direct_updated_cron->wpdb_state()['last_result'][0]->option_value ?? null ) + && 'fourth' === ( $read_direct_updated_cron->wpdb_state()['last_result'][0]->option_value ?? null ) && 'auto-off' === ( $read_direct_updated_cron->wpdb_state()['last_result'][0]->autoload ?? null ) - && 'third' === ( $read_persisted_cron->wpdb_state()['last_result'][0]->option_value ?? null ), + && 'fourth' === ( $read_persisted_cron->wpdb_state()['last_result'][0]->option_value ?? null ), 'exact option deletes remove canonical rows and preserve missing-row semantics' => 1 === $delete_cron->return_value() && 1 === $delete_cron->wpdb_state()['rows_affected'] && 0 === $delete_missing->return_value() From ead97cf882e463a539789c42adb6d5ea55301cc7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 08:03:54 -0400 Subject: [PATCH 7/8] fix: correct advisory lock timeout message --- inc/native/class-wp-markdown-native-query-executor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index 64aff16..c07c4ad 100644 --- a/inc/native/class-wp-markdown-native-query-executor.php +++ b/inc/native/class-wp-markdown-native-query-executor.php @@ -141,7 +141,7 @@ private function advisory_lock_query( string $sql ): ?WP_Markdown_Query_Result { return $this->failure( 'unsupported_literal', 'mdi-native cannot decode the requested advisory lock name.' ); } if ( ! is_string( $name ) || ( isset( $match[4] ) && (float) $match[4] > WP_Markdown_Native_Advisory_Locks::MAX_WAIT_SECONDS ) ) { - return $this->failure( 'unsupported_grammar', 'mdi-native advisory lock timeouts must be between 0 and 5 seconds.' ); + return $this->failure( 'unsupported_grammar', 'mdi-native advisory lock timeouts must be between 0 and 10 seconds.' ); } $value = 'GET_LOCK' === $function ? (int) $this->advisory_locks->acquire( $name, (float) $match[4] ) From 3fb04a00c169895f861c0f4dde9ea531cdb237b7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 08:14:37 -0400 Subject: [PATCH 8/8] fix: match option CAS collation --- ...ss-wp-markdown-native-option-mutations.php | 26 ++++++++-- ...class-wp-markdown-native-query-runtime.php | 9 ++++ tests/native-option-cas-worker.php | 16 ++++++ tests/smoke-native-option-query.php | 51 ++++++++++++++++--- 4 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 tests/native-option-cas-worker.php diff --git a/inc/native/class-wp-markdown-native-option-mutations.php b/inc/native/class-wp-markdown-native-option-mutations.php index b1f65c2..7c216fe 100644 --- a/inc/native/class-wp-markdown-native-option-mutations.php +++ b/inc/native/class-wp-markdown-native-option-mutations.php @@ -11,7 +11,8 @@ public function __construct( private readonly string $operation, private readonly string $option_name, private readonly array $values, - private readonly ?string $expected_option_value = null + private readonly ?string $expected_option_value = null, + private readonly bool $expected_option_value_is_binary = false ) { if ( ! in_array( $operation, array( 'insert', 'upsert', 'update', 'delete' ), true ) ) { throw new InvalidArgumentException( 'Unsupported option mutation operation.' ); @@ -42,6 +43,10 @@ public function values(): array { public function expected_option_value(): ?string { return $this->expected_option_value; } + + public function expected_option_value_is_binary(): bool { + return $this->expected_option_value_is_binary; + } } final class WP_Markdown_Native_Option_Mutation_Parser { @@ -174,8 +179,13 @@ private function parse_update( WP_Markdown_Query_Request $request ): WP_Markdown $this->type( WP_Markdown_Native_SQL_Token::EQUALS ); $option_name = (string) $this->type( WP_Markdown_Native_SQL_Token::STRING )->value(); $expected_option_value = null; + $expected_option_value_is_binary = false; if ( 0 === strcasecmp( 'AND', (string) $this->current()->value() ) ) { ++$this->position; + if ( 0 === strcasecmp( 'BINARY', (string) $this->current()->value() ) ) { + ++$this->position; + $expected_option_value_is_binary = true; + } if ( 'option_value' !== $this->identifier() ) { return $this->failure( 'unsupported_option_update', 'mdi-native option updates may condition only on the current option value.' ); } @@ -183,7 +193,7 @@ private function parse_update( WP_Markdown_Query_Request $request ): WP_Markdown $expected_option_value = (string) $this->type( WP_Markdown_Native_SQL_Token::STRING )->value(); } $this->type( WP_Markdown_Native_SQL_Token::END ); - return new WP_Markdown_Native_Option_Mutation( 'update', $option_name, $changes, $expected_option_value ); + return new WP_Markdown_Native_Option_Mutation( 'update', $option_name, $changes, $expected_option_value, $expected_option_value_is_binary ); } private function parse_delete( WP_Markdown_Query_Request $request ): WP_Markdown_Native_Option_Mutation|WP_Markdown_Query_Result { @@ -402,8 +412,16 @@ private function mutate( WP_Markdown_Native_Option_Mutation $mutation ): WP_Mark if ( $mutation->is_insert() && null !== $existing ) { return $this->failure( 'duplicate_key', 'The canonical option identity already exists.' ); } - if ( null !== $mutation->expected_option_value() && $mutation->expected_option_value() !== $existing['row']['option_value'] ) { - return WP_Markdown_Query_Result::mutated( 0 ); + if ( null !== $mutation->expected_option_value() ) { + if ( $mutation->expected_option_value_is_binary() ) { + if ( $mutation->expected_option_value() !== $existing['row']['option_value'] ) { + return WP_Markdown_Query_Result::mutated( 0 ); + } + } elseif ( null === $this->schema->value_key( 'option_value', $mutation->expected_option_value() ) || null === $this->schema->value_key( 'option_value', $existing['row']['option_value'] ) ) { + return $this->failure( 'unsupported_option_collation', 'The option mutation requires an unsupported option-value collation.' ); + } elseif ( ! $this->schema->values_match( 'option_value', $mutation->expected_option_value(), $existing['row']['option_value'] ) ) { + return WP_Markdown_Query_Result::mutated( 0 ); + } } if ( $mutation->is_delete() ) { $journaled = $this->journal( $existing['path'] ); diff --git a/inc/native/class-wp-markdown-native-query-runtime.php b/inc/native/class-wp-markdown-native-query-runtime.php index 8e6fa37..64f2d1d 100644 --- a/inc/native/class-wp-markdown-native-query-runtime.php +++ b/inc/native/class-wp-markdown-native-query-runtime.php @@ -39,6 +39,10 @@ public static function options_schema(): WP_Markdown_Native_Table_Schema { 'lookup_operators' => array( '=', 'IN' ), 'lookup_validator' => static fn( array $values ): bool => self::all_ascii_strings( $values ), ), + // WordPress options use a nonbinary text column. Limit native CAS + // matching to the ASCII portion of that collation rather than guess + // at an unsupported Unicode collation. + 'option_value' => array( 'normalizer' => array( self::class, 'normalize_ascii_ci_padded' ) ), 'autoload' => array( 'lookup_operators' => array( 'IN' ), 'lookup_validator' => static fn( array $values ): bool => ! array_diff( $values, array( 'yes', 'on', 'auto-on', 'auto' ) ), @@ -552,6 +556,11 @@ public static function normalize_ascii_ci( mixed $value ): ?string { return strtolower( $value ); } + public static function normalize_ascii_ci_padded( mixed $value ): ?string { + $value = self::normalize_ascii_ci( $value ); + return null === $value ? null : rtrim( $value, ' ' ); + } + private static function all_normalized_unsigned( array $values ): bool { foreach ( $values as $value ) { if ( null === self::normalize_unsigned( $value ) ) { diff --git a/tests/native-option-cas-worker.php b/tests/native-option-cas-worker.php new file mode 100644 index 0000000..c29c1b3 --- /dev/null +++ b/tests/native-option-cas-worker.php @@ -0,0 +1,16 @@ +execute( new WP_Markdown_Query_Request( "UPDATE wp_options SET option_value = '" . $argv[2] . "' WHERE option_name = 'cas-race' AND option_value = 'pending'" ) ); +fwrite( STDOUT, (string) $result->return_value() ); diff --git a/tests/smoke-native-option-query.php b/tests/smoke-native-option-query.php index 3df53fa..7ecf311 100644 --- a/tests/smoke-native-option-query.php +++ b/tests/smoke-native-option-query.php @@ -175,8 +175,11 @@ public function get_col_info( string $type ): array { return array_map( static f $read_updated_cron = $runtime->execute( new WP_Markdown_Query_Request( "SELECT option_id, option_value, autoload FROM wp_options WHERE option_name = 'cron' LIMIT 1" ) ); $direct_update_cron = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE `wp_options` SET `option_value` = 'third', `autoload` = 'auto-off' WHERE `option_name` = 'cron'" ) ); $noop_direct_update_cron = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_options SET option_value = 'third', autoload = 'auto-off' WHERE option_name = 'cron'" ) ); -$conditional_update_cron = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_options SET option_value = 'fourth' WHERE option_name = 'cron' AND option_value = 'third'" ) ); -$stale_conditional_update_cron = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_options SET option_value = 'stale' WHERE option_name = 'cron' AND option_value = 'third'" ) ); +$conditional_update_cron = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_options SET option_value = 'fourth' WHERE option_name = 'cron' AND option_value = 'THIRD '" ) ); +$stale_conditional_update_cron = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_options SET option_value = 'stale' WHERE option_name = 'cron' AND option_value = 'THIRD'" ) ); +$binary_mismatch_conditional_update_cron = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_options SET option_value = 'binary-stale' WHERE option_name = 'cron' AND BINARY option_value = 'FOURTH'" ) ); +$binary_conditional_update_cron = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_options SET option_value = 'binary-fourth' WHERE option_name = 'cron' AND BINARY option_value = 'fourth'" ) ); +$unsupported_collation_conditional_update_cron = $runtime->execute( new WP_Markdown_Query_Request( "UPDATE wp_options SET option_value = 'unicode-stale' WHERE option_name = 'cron' AND option_value = 'caf" . chr( 195 ) . chr( 169 ) . "'" ) ); $read_direct_updated_cron = $runtime->execute( new WP_Markdown_Query_Request( "SELECT option_id, option_value, autoload FROM wp_options WHERE option_name = 'cron' LIMIT 1" ) ); $reopened_runtime = WP_Markdown_Native_Runtime_Factory::runtime( $root, 'wp_' ); $read_persisted_cron = $reopened_runtime->execute( new WP_Markdown_Query_Request( "SELECT option_id, option_value, autoload FROM wp_options WHERE option_name = 'cron' LIMIT 1" ) ); @@ -208,6 +211,35 @@ public function get_col_info( string $type ): array { return array_map( static f $wpdb_filtered = $database->query( $prepared_query ); $GLOBALS['mdi_native_query_filter'] = null; +$write_option( 'cas-race', array( 'option_id' => 8, 'option_name' => 'cas-race', 'option_value' => 'pending', 'autoload' => 'off' ) ); +$cas_workers = array(); +$cas_worker_pipes = array(); +foreach ( array( 'winner-one', 'winner-two' ) as $replacement ) { + $worker_pipes = array(); + $cas_workers[] = proc_open( + escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( __DIR__ . '/native-option-cas-worker.php' ) . ' ' . escapeshellarg( $root ) . ' ' . escapeshellarg( $replacement ), + array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), + $worker_pipes + ); + $cas_worker_pipes[] = $worker_pipes; +} +$cas_results = array(); +$cas_outputs = array(); +foreach ( $cas_workers as $index => $worker ) { + if ( ! is_resource( $worker ) || ! isset( $cas_worker_pipes[ $index ] ) ) { + $cas_results[] = false; + continue; + } + $worker_pipes = $cas_worker_pipes[ $index ]; + fclose( $worker_pipes[0] ); + $cas_outputs[] = rtrim( (string) stream_get_contents( $worker_pipes[1] ) ); + stream_get_contents( $worker_pipes[2] ); + fclose( $worker_pipes[1] ); + fclose( $worker_pipes[2] ); + $cas_results[] = 0 === proc_close( $worker ); +} +$cas_race = $runtime->execute( new WP_Markdown_Query_Request( "SELECT option_value FROM wp_options WHERE option_name = 'cas-race'" ) ); + $write_option( 'spaced option', array( 'option_id' => 7, 'option_name' => 'spaced option', 'option_value' => 'spaced', 'autoload' => 'off' ) ); $case_insensitive_hashed_option = $runtime->execute( new WP_Markdown_Query_Request( "SELECT option_value FROM wp_options WHERE option_name = 'SPACED OPTION'" ) ); $write_option( 'other option', '{' ); @@ -270,12 +302,19 @@ public function get_col_info( string $type ): array { return array_map( static f && 0 === $noop_direct_update_cron->return_value() && 1 === $conditional_update_cron->return_value() && 0 === $stale_conditional_update_cron->return_value() - && 'fourth' === ( $read_direct_updated_cron->wpdb_state()['last_result'][0]->option_value ?? null ) + && 0 === $binary_mismatch_conditional_update_cron->return_value() + && 1 === $binary_conditional_update_cron->return_value() + && false === $unsupported_collation_conditional_update_cron->return_value() + && 'unsupported_option_collation' === ( $unsupported_collation_conditional_update_cron->diagnostic()['reason'] ?? null ) + && 'binary-fourth' === ( $read_direct_updated_cron->wpdb_state()['last_result'][0]->option_value ?? null ) && 0 === $missing_direct_update->return_value() && '7' === ( $read_direct_updated_cron->wpdb_state()['last_result'][0]->option_id ?? null ) - && 'fourth' === ( $read_direct_updated_cron->wpdb_state()['last_result'][0]->option_value ?? null ) + && 'binary-fourth' === ( $read_direct_updated_cron->wpdb_state()['last_result'][0]->option_value ?? null ) && 'auto-off' === ( $read_direct_updated_cron->wpdb_state()['last_result'][0]->autoload ?? null ) - && 'fourth' === ( $read_persisted_cron->wpdb_state()['last_result'][0]->option_value ?? null ), + && 'binary-fourth' === ( $read_persisted_cron->wpdb_state()['last_result'][0]->option_value ?? null ), + 'independent CAS contenders serialize to one affected row and one winner' => array( '0', '1' ) === ( sort( $cas_outputs, SORT_STRING ) ? $cas_outputs : array() ) + && array( true, true ) === $cas_results + && in_array( $cas_race->wpdb_state()['last_result'][0]->option_value ?? null, array( 'winner-one', 'winner-two' ), true ), 'exact option deletes remove canonical rows and preserve missing-row semantics' => 1 === $delete_cron->return_value() && 1 === $delete_cron->wpdb_state()['rows_affected'] && 0 === $delete_missing->return_value() @@ -318,7 +357,7 @@ public function get_col_info( string $type ): array { return array_map( static f @unlink( $root . '/_options/siteurl.json' ); @unlink( $root . '/_options/' . WP_Markdown_Canonical_Option_Path::filename( $escaped_name ) ); -foreach ( array( 'blogname', 'automatic', 'legacy', 'disabled', 'spaced option', 'SPACED OPTION', 'other option' ) as $option_name ) { +foreach ( array( 'blogname', 'automatic', 'legacy', 'disabled', 'spaced option', 'SPACED OPTION', 'other option', 'cas-race' ) as $option_name ) { @unlink( $root . '/_options/' . WP_Markdown_Canonical_Option_Path::filename( $option_name ) ); } @unlink( $root . '/_options/broken.json' );