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..40357c1 --- /dev/null +++ b/inc/native/class-wp-markdown-native-advisory-locks.php @@ -0,0 +1,90 @@ + */ + 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 = $timeout * 1000000; + $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 ] ) ) { + $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'] ) { + return 1; + } + $lock = $this->locks[ $name ]; + unset( $this->locks[ $name ] ); + flock( $lock['handle'], LOCK_UN ); + fclose( $lock['handle'] ); + 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-option-mutations.php b/inc/native/class-wp-markdown-native-option-mutations.php index 560e03a..7c216fe 100644 --- a/inc/native/class-wp-markdown-native-option-mutations.php +++ b/inc/native/class-wp-markdown-native-option-mutations.php @@ -10,7 +10,9 @@ 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, + 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.' ); @@ -37,6 +39,14 @@ public function option_name(): string { public function values(): array { return $this->values; } + + 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 { @@ -168,8 +178,22 @@ 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.' ); + } + $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, $expected_option_value_is_binary ); } private function parse_delete( WP_Markdown_Query_Request $request ): WP_Markdown_Native_Option_Mutation|WP_Markdown_Query_Result { @@ -388,6 +412,17 @@ 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() ) { + 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'] ); if ( true !== $journaled ) { diff --git a/inc/native/class-wp-markdown-native-query-executor.php b/inc/native/class-wp-markdown-native-query-executor.php index aa56648..c07c4ad 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,38 @@ 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[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.' ); + } + 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 10 seconds.' ); + } + $value = 'GET_LOCK' === $function + ? (int) $this->advisory_locks->acquire( $name, (float) $match[4] ) + : $this->advisory_locks->release( $name ); + $column = $match[1]; + 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..64f2d1d 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 { @@ -38,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' ) ), @@ -229,7 +234,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 +271,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 ) ); } @@ -549,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 ) ) { @@ -598,11 +610,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 +627,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 +668,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. */ @@ -657,6 +684,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, @@ -668,6 +696,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 { @@ -700,7 +729,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( @@ -715,6 +745,10 @@ public function execute( WP_Markdown_Query_Request $request ): WP_Markdown_Query return $this->runtimes[ $prefix ]->execute( $request ); } + public function close(): void { + $this->advisory_locks->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..de0b83f 100644 --- a/inc/native/class-wp-markdown-native-transactions.php +++ b/inc/native/class-wp-markdown-native-transactions.php @@ -220,19 +220,24 @@ public function rollback(): true|string { public function savepoint( string $name ): true|string { if ( ! $this->active ) { - $begun = $this->begin(); - if ( true !== $begun ) { - return $begun; + // With autocommit on, MySQL accepts SAVEPOINT without opening a transaction. + 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 ) { @@ -248,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/inc/native/class-wp-markdown-native-wpdb.php b/inc/native/class-wp-markdown-native-wpdb.php index 3b4a455..207c5af 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/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/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/probe-native-transaction-semantics.php b/tests/probe-native-transaction-semantics.php index 9805c90..2cc935d 100644 --- a/tests/probe-native-transaction-semantics.php +++ b/tests/probe-native-transaction-semantics.php @@ -99,6 +99,17 @@ 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' ); +$autocommit_off_release = probe_statement( $runtime, 'RELEASE SAVEPOINT autocommit_off' ); +probe_statement( $runtime, 'ROLLBACK' ); +probe_statement( $runtime, 'SET autocommit = 1' ); + $session_before = array( 'in_transaction' => probe_session_variable( $runtime, 'SELECT @@session.in_transaction' ), 'autocommit' => probe_session_variable( $runtime, 'SELECT @@autocommit' ), @@ -236,6 +247,12 @@ function probe_canonical_value( string $root, string $option ): ?string { ), ), 'assertions' => array( + '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 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'] 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', diff --git a/tests/smoke-native-advisory-locks.php b/tests/smoke-native-advisory-locks.php new file mode 100644 index 0000000..5ab8238 --- /dev/null +++ b/tests/smoke-native-advisory-locks.php @@ -0,0 +1,93 @@ +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; +} + +$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')" ); +$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)" ) ); +$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)" ); +$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, + '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, + '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, +); +$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 ); diff --git a/tests/smoke-native-option-query.php b/tests/smoke-native-option-query.php index 335bbdc..7ecf311 100644 --- a/tests/smoke-native-option-query.php +++ b/tests/smoke-native-option-query.php @@ -175,6 +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'" ) ); +$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" ) ); @@ -206,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', '{' ); @@ -266,11 +300,21 @@ 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() + && 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 ) - && 'third' === ( $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 ) - && 'third' === ( $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() @@ -313,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' );